diff --git a/App/FeatureSet/Dashboard/src/Components/Exceptions/ExceptionsDashboard.tsx b/App/FeatureSet/Dashboard/src/Components/Exceptions/ExceptionsDashboard.tsx new file mode 100644 index 0000000000..3cd15f4f76 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Exceptions/ExceptionsDashboard.tsx @@ -0,0 +1,522 @@ +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import TelemetryException from "Common/Models/DatabaseModels/TelemetryException"; +import Service from "Common/Models/DatabaseModels/Service"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import ProjectUtil from "Common/UI/Utils/Project"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import SortOrder from "Common/Types/BaseDatabase/SortOrder"; +import ListResult from "Common/Types/BaseDatabase/ListResult"; +import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax"; +import OneUptimeDate from "Common/Types/Date"; +import ObjectID from "Common/Types/ObjectID"; +import TelemetryServiceElement from "../TelemetryService/TelemetryServiceElement"; +import TelemetryExceptionElement from "./ExceptionElement"; +import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; +import PageMap from "../../Utils/PageMap"; +import Route from "Common/Types/API/Route"; +import AppLink from "../AppLink/AppLink"; + +interface ServiceExceptionSummary { + service: Service; + unresolvedCount: number; + totalOccurrences: number; +} + +const ExceptionsDashboard: FunctionComponent = (): ReactElement => { + const [unresolvedCount, setUnresolvedCount] = useState(0); + const [resolvedCount, setResolvedCount] = useState(0); + const [archivedCount, setArchivedCount] = useState(0); + const [topExceptions, setTopExceptions] = useState< + Array + >([]); + const [serviceSummaries, setServiceSummaries] = useState< + Array + >([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const loadDashboard: () => Promise = async (): Promise => { + try { + setIsLoading(true); + setError(""); + + const projectId: ObjectID = ProjectUtil.getCurrentProjectId()!; + + // Load counts, top exceptions, and services in parallel + const [ + unresolvedResult, + resolvedResult, + archivedResult, + topExceptionsResult, + servicesResult, + ] = await Promise.all([ + ModelAPI.count({ + modelType: TelemetryException, + query: { + projectId, + isResolved: false, + isArchived: false, + }, + }), + ModelAPI.count({ + modelType: TelemetryException, + query: { + projectId, + isResolved: true, + isArchived: false, + }, + }), + ModelAPI.count({ + modelType: TelemetryException, + query: { + projectId, + isArchived: true, + }, + }), + ModelAPI.getList({ + modelType: TelemetryException, + query: { + projectId, + isResolved: false, + isArchived: false, + }, + select: { + message: true, + exceptionType: true, + fingerprint: true, + isResolved: true, + isArchived: true, + occuranceCount: true, + lastSeenAt: true, + firstSeenAt: true, + environment: true, + service: { + name: true, + serviceColor: true, + } as any, + }, + limit: 10, + skip: 0, + sort: { + occuranceCount: SortOrder.Descending, + }, + }), + ModelAPI.getList({ + modelType: Service, + query: { + projectId, + }, + select: { + serviceColor: true, + name: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + sort: { + name: SortOrder.Ascending, + }, + }), + ]); + + setUnresolvedCount(unresolvedResult); + setResolvedCount(resolvedResult); + setArchivedCount(archivedResult); + setTopExceptions(topExceptionsResult.data || []); + + const loadedServices: Array = servicesResult.data || []; + + // Load unresolved exception counts per service + const serviceExceptionCounts: Array = []; + + for (const service of loadedServices) { + // Get unresolved exceptions for this service + const serviceExceptions: ListResult = + await ModelAPI.getList({ + modelType: TelemetryException, + query: { + projectId, + serviceId: service.id!, + isResolved: false, + isArchived: false, + }, + select: { + occuranceCount: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + sort: { + occuranceCount: SortOrder.Descending, + }, + }); + + const exceptions: Array = + serviceExceptions.data || []; + + if (exceptions.length > 0) { + let totalOccurrences: number = 0; + + for (const ex of exceptions) { + totalOccurrences += ex.occuranceCount || 0; + } + + serviceExceptionCounts.push({ + service, + unresolvedCount: exceptions.length, + totalOccurrences, + }); + } + } + + // Sort by unresolved count descending + serviceExceptionCounts.sort( + (a: ServiceExceptionSummary, b: ServiceExceptionSummary) => { + return b.unresolvedCount - a.unresolvedCount; + }, + ); + + setServiceSummaries(serviceExceptionCounts); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadDashboard(); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ( + { + void loadDashboard(); + }} + /> + ); + } + + const totalCount: number = unresolvedCount + resolvedCount + archivedCount; + + if (totalCount === 0) { + return ( +
+
+ + + +
+

+ No exceptions caught yet +

+

+ Once your services start reporting exceptions, you{"'"}ll see a + summary of bugs, their frequency, and which services are most + affected. +

+
+ ); + } + + return ( + + {/* Summary Stats */} +
+ +
+
+
+

Unresolved Bugs

+

+ {unresolvedCount.toLocaleString()} +

+
+
+ + + +
+
+

+ Needs attention +

+
+
+ + +
+
+
+

Resolved

+

+ {resolvedCount.toLocaleString()} +

+
+
+ + + +
+
+

+ Fixed and verified +

+
+
+ + +
+
+
+

Archived

+

+ {archivedCount.toLocaleString()} +

+
+
+ + + +
+
+

+ Dismissed or won{"'"}t fix +

+
+
+
+ +
+ {/* Most Frequent Exceptions */} + {topExceptions.length > 0 && ( +
+
+
+

+ Most Frequent Bugs +

+

+ Unresolved exceptions with the highest occurrence count +

+
+ + View all + +
+
+
+ {topExceptions.map( + (exception: TelemetryException, index: number) => { + const maxOccurrences: number = + topExceptions[0]?.occuranceCount || 1; + const barWidth: number = + ((exception.occuranceCount || 0) / maxOccurrences) * 100; + + return ( + +
+
+ +
+ {exception.service && ( + + {exception.service.name?.toString()} + + )} + {exception.environment && ( + + {exception.environment} + + )} +
+
+
+

+ {( + exception.occuranceCount || 0 + ).toLocaleString()} +

+

+ occurrences +

+
+
+
+
+
+
+
+ + ); + }, + )} +
+
+
+ )} + + {/* Services Affected */} + {serviceSummaries.length > 0 && ( +
+
+

+ Affected Services +

+

+ Services with unresolved exceptions +

+
+
+
+ {serviceSummaries.map( + (summary: ServiceExceptionSummary) => { + return ( +
+
+ +
+
+

+ {summary.unresolvedCount} +

+

+ unresolved +

+
+
+

+ {summary.totalOccurrences.toLocaleString()} +

+

+ total hits +

+
+
+
+
+ ); + }, + )} +
+
+
+ )} +
+ + ); +}; + +export default ExceptionsDashboard; diff --git a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx index b5871a80e9..7ba9cf1b38 100644 --- a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx +++ b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx @@ -135,7 +135,7 @@ const DashboardNavbar: FunctionComponent = ( }, { title: "Exceptions", - description: "Catch and fix bugs early.", + description: "Track and resolve bugs across your services.", route: RouteUtil.populateRouteParams( RouteMap[PageMap.EXCEPTIONS] as Route, ), diff --git a/App/FeatureSet/Dashboard/src/Pages/Exceptions/Layout.tsx b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Layout.tsx index 97ade5b2cb..62f19e5b1c 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Exceptions/Layout.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Layout.tsx @@ -15,7 +15,7 @@ const ExceptionsLayout: FunctionComponent< if (path.endsWith("exceptions") || path.endsWith("exceptions/*")) { Navigation.navigate( - RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_UNRESOLVED]!), + RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_OVERVIEW]!), ); return <>; diff --git a/App/FeatureSet/Dashboard/src/Pages/Exceptions/Overview.tsx b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Overview.tsx new file mode 100644 index 0000000000..dffa321bca --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Overview.tsx @@ -0,0 +1,68 @@ +import PageComponentProps from "../PageComponentProps"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ExceptionsDashboard from "../../Components/Exceptions/ExceptionsDashboard"; +import Service from "Common/Models/DatabaseModels/Service"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; + +const ExceptionsOverviewPage: FunctionComponent = ( + props: PageComponentProps, +): ReactElement => { + const disableTelemetryForThisProject: boolean = + props.currentProject?.reseller?.enableTelemetryFeatures === false; + + const [serviceCount, setServiceCount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchServiceCount: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const count: number = await ModelAPI.count({ + modelType: Service, + query: {}, + }); + setServiceCount(count); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchServiceCount().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (disableTelemetryForThisProject) { + return ( + + ); + } + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (serviceCount === 0) { + return ; + } + + return ; +}; + +export default ExceptionsOverviewPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx index 8ff658d563..6fc2aa5dfd 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx @@ -15,6 +15,15 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { { title: "Exceptions", items: [ + { + link: { + title: "Overview", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.EXCEPTIONS_OVERVIEW] as Route, + ), + }, + icon: IconProp.Home, + }, { link: { title: "Unresolved", @@ -52,11 +61,11 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { ], }, { - title: "Documentation", + title: "Help", items: [ { link: { - title: "Documentation", + title: "Setup Guide", to: RouteUtil.populateRouteParams( RouteMap[PageMap.EXCEPTIONS_DOCUMENTATION] as Route, ), diff --git a/App/FeatureSet/Dashboard/src/Pages/Exceptions/View/Layout.tsx b/App/FeatureSet/Dashboard/src/Pages/Exceptions/View/Layout.tsx index 90a99e4144..e7d8a1df98 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Exceptions/View/Layout.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Exceptions/View/Layout.tsx @@ -14,7 +14,7 @@ const ExceptionViewLayout: FunctionComponent< if (path.endsWith("exceptions")) { Navigation.navigate( - RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_UNRESOLVED]!), + RouteUtil.populateRouteParams(RouteMap[PageMap.EXCEPTIONS_OVERVIEW]!), ); return <>; @@ -22,7 +22,7 @@ const ExceptionViewLayout: FunctionComponent< return ( diff --git a/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx index 2d9f367dcd..7b06baf495 100644 --- a/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx @@ -8,6 +8,7 @@ import React, { FunctionComponent, ReactElement } from "react"; import { Route as PageRoute, Routes } from "react-router-dom"; // Pages +import ExceptionsOverview from "../Pages/Exceptions/Overview"; import ExceptionsUnresolved from "../Pages/Exceptions/Unresolved"; import ExceptionsResolved from "../Pages/Exceptions/Resolved"; import ExceptionsArchived from "../Pages/Exceptions/Archived"; @@ -23,13 +24,23 @@ const ExceptionsRoutes: FunctionComponent = ( } /> + + } + /> + = { }; export const ExceptionsRoutePath: Dictionary = { - [PageMap.EXCEPTIONS]: "unresolved", + [PageMap.EXCEPTIONS]: "overview", + [PageMap.EXCEPTIONS_OVERVIEW]: "overview", [PageMap.EXCEPTIONS_UNRESOLVED]: "unresolved", [PageMap.EXCEPTIONS_RESOLVED]: "resolved", [PageMap.EXCEPTIONS_ARCHIVED]: "archived", @@ -2782,6 +2783,12 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.EXCEPTIONS_OVERVIEW]: new Route( + `/dashboard/${RouteParams.ProjectID}/exceptions/${ + ExceptionsRoutePath[PageMap.EXCEPTIONS_OVERVIEW] + }`, + ), + [PageMap.EXCEPTIONS_UNRESOLVED]: new Route( `/dashboard/${RouteParams.ProjectID}/exceptions/${ ExceptionsRoutePath[PageMap.EXCEPTIONS_UNRESOLVED]