diff --git a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx index a71f9ca8b2..b5871a80e9 100644 --- a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx +++ b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx @@ -117,7 +117,7 @@ const DashboardNavbar: FunctionComponent = ( }, { title: "Traces", - description: "Distributed tracing analysis.", + description: "Track requests across your services.", route: RouteUtil.populateRouteParams(RouteMap[PageMap.TRACES] as Route), activeRoute: RouteMap[PageMap.TRACES], icon: IconProp.Waterfall, diff --git a/App/FeatureSet/Dashboard/src/Components/Traces/TracesDashboard.tsx b/App/FeatureSet/Dashboard/src/Components/Traces/TracesDashboard.tsx new file mode 100644 index 0000000000..1768cf650b --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Traces/TracesDashboard.tsx @@ -0,0 +1,537 @@ +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +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/Utils/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 Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span"; +import AnalyticsModelAPI, { + ListResult as AnalyticsListResult, +} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import IsNull from "Common/Types/BaseDatabase/IsNull"; +import OneUptimeDate from "Common/Types/Date"; +import ObjectID from "Common/Types/ObjectID"; +import ServiceElement from "../Service/ServiceElement"; +import SpanStatusElement from "../Span/SpanStatusElement"; +import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; +import PageMap from "../../Utils/PageMap"; +import Route from "Common/Types/API/Route"; +import AppLink from "../AppLink/AppLink"; + +interface ServiceTraceSummary { + service: Service; + totalTraces: number; + errorTraces: number; + latestTraceTime: Date | null; +} + +interface RecentTrace { + traceId: string; + name: string; + serviceId: string; + startTime: Date; + statusCode: SpanStatus; + durationNano: number; +} + +const TracesDashboard: FunctionComponent = (): ReactElement => { + const [serviceSummaries, setServiceSummaries] = useState< + Array + >([]); + const [recentErrorTraces, setRecentErrorTraces] = useState< + Array + >([]); + const [recentSlowTraces, setRecentSlowTraces] = useState< + Array + >([]); + const [services, setServices] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const formatDuration: (nanos: number) => string = ( + nanos: number, + ): string => { + if (nanos >= 1_000_000_000) { + return `${(nanos / 1_000_000_000).toFixed(2)}s`; + } + if (nanos >= 1_000_000) { + return `${(nanos / 1_000_000).toFixed(1)}ms`; + } + if (nanos >= 1_000) { + return `${(nanos / 1_000).toFixed(0)}us`; + } + return `${nanos}ns`; + }; + + const loadDashboard: () => Promise = async (): Promise => { + try { + setIsLoading(true); + setError(""); + + const now: Date = OneUptimeDate.getCurrentDate(); + const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1); + + // Load services + const servicesResult: ListResult = await ModelAPI.getList({ + modelType: Service, + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + }, + select: { + serviceColor: true, + name: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + sort: { + name: SortOrder.Ascending, + }, + }); + + const loadedServices: Array = servicesResult.data || []; + setServices(loadedServices); + + // Load recent root spans (last 1 hour) to build per-service summaries + const rootSpansResult: AnalyticsListResult = + await AnalyticsModelAPI.getList({ + modelType: Span, + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + startTime: new InBetween(oneHourAgo, now), + parentSpanId: new IsNull(), + }, + select: { + traceId: true, + serviceId: true, + name: true, + startTime: true, + statusCode: true, + durationUnixNano: true, + }, + limit: 5000, + skip: 0, + sort: { + startTime: SortOrder.Descending, + }, + }); + + const rootSpans: Array = rootSpansResult.data || []; + + // Build per-service summaries from root spans + const summaryMap: Map = new Map(); + + for (const service of loadedServices) { + const serviceId: string = service.id?.toString() || ""; + summaryMap.set(serviceId, { + service, + totalTraces: 0, + errorTraces: 0, + latestTraceTime: null, + }); + } + + const errorTraces: Array = []; + const allTraces: Array = []; + + for (const span of rootSpans) { + const serviceId: string = span.serviceId?.toString() || ""; + const summary: ServiceTraceSummary | undefined = + summaryMap.get(serviceId); + + if (summary) { + summary.totalTraces += 1; + + if (span.statusCode === SpanStatus.Error) { + summary.errorTraces += 1; + } + + const spanTime: Date | undefined = span.startTime + ? new Date(span.startTime) + : undefined; + + if ( + spanTime && + (!summary.latestTraceTime || spanTime > summary.latestTraceTime) + ) { + summary.latestTraceTime = spanTime; + } + } + + const traceRecord: RecentTrace = { + traceId: span.traceId?.toString() || "", + name: span.name?.toString() || "Unknown", + serviceId: serviceId, + startTime: span.startTime ? new Date(span.startTime) : new Date(), + statusCode: span.statusCode || SpanStatus.Unset, + durationNano: (span.durationUnixNano as number) || 0, + }; + + if (span.statusCode === SpanStatus.Error) { + errorTraces.push(traceRecord); + } + + allTraces.push(traceRecord); + } + + // Only show services that have traces + const summariesWithData: Array = Array.from( + summaryMap.values(), + ).filter((s: ServiceTraceSummary) => { + return s.totalTraces > 0; + }); + + // Sort by total traces descending + summariesWithData.sort( + (a: ServiceTraceSummary, b: ServiceTraceSummary) => { + return b.totalTraces - a.totalTraces; + }, + ); + + setServiceSummaries(summariesWithData); + setRecentErrorTraces(errorTraces.slice(0, 10)); + + // Get slowest traces + const slowTraces: Array = [...allTraces] + .sort((a: RecentTrace, b: RecentTrace) => { + return b.durationNano - a.durationNano; + }) + .slice(0, 10); + setRecentSlowTraces(slowTraces); + } catch (err) { + setError(API.getFriendlyErrorMessage(err as Error)); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadDashboard(); + }, []); + + const getServiceName: (serviceId: string) => string = ( + serviceId: string, + ): string => { + const service: Service | undefined = services.find((s: Service) => { + return s.id?.toString() === serviceId; + }); + return service?.name?.toString() || "Unknown"; + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + { + void loadDashboard(); + }} + /> + ); + } + + if (serviceSummaries.length === 0) { + return ( +
+
+ + + +
+

+ No trace data yet +

+

+ Once your services start sending distributed tracing data, you{"'"}ll + see a summary of requests flowing through your system, error rates, + and slow operations. +

+
+ ); + } + + return ( + + {/* Service Cards */} +
+
+
+

+ Services Overview +

+

+ Request activity across your services in the last hour +

+
+ + View all spans + +
+
+ {serviceSummaries.map((summary: ServiceTraceSummary) => { + const errorRate: number = + summary.totalTraces > 0 + ? (summary.errorTraces / summary.totalTraces) * 100 + : 0; + + return ( +
+
+ + {errorRate > 5 ? ( + + {errorRate.toFixed(1)}% errors + + ) : ( + + Healthy + + )} +
+ +
+
+

Requests

+

+ {summary.totalTraces.toLocaleString()} +

+
+
+

Errors

+

0 ? "text-red-600" : "text-gray-900"}`} + > + {summary.errorTraces.toLocaleString()} +

+
+
+

Last Seen

+

+ {summary.latestTraceTime + ? OneUptimeDate.getDateAsLocalFormattedString( + summary.latestTraceTime, + true, + ) + : "-"} +

+
+
+ + {/* Error rate bar */} +
+
+ Error Rate + {errorRate.toFixed(1)}% +
+
+
5 ? "bg-red-500" : errorRate > 0 ? "bg-yellow-400" : "bg-green-400"}`} + style={{ + width: `${Math.max(errorRate, errorRate > 0 ? 2 : 0)}%`, + }} + /> +
+
+ +
+ + View service traces + +
+
+ ); + })} +
+
+ + {/* Two-column layout for errors and slow traces */} +
+ {/* Recent Errors */} +
+
+

+ Recent Errors +

+

+ Failed requests in the last hour +

+
+ {recentErrorTraces.length === 0 ? ( +
+

+ No errors in the last hour +

+
+ ) : ( +
+
+ {recentErrorTraces.map( + (trace: RecentTrace, index: number) => { + return ( + +
+
+ +
+

+ {trace.name} +

+

+ {getServiceName(trace.serviceId)} +

+
+
+
+

+ {formatDuration(trace.durationNano)} +

+

+ {OneUptimeDate.getDateAsLocalFormattedString( + trace.startTime, + true, + )} +

+
+
+
+ ); + }, + )} +
+
+ )} +
+ + {/* Slowest Traces */} +
+
+

+ Slowest Requests +

+

+ Longest running operations in the last hour +

+
+ {recentSlowTraces.length === 0 ? ( +
+

+ No traces found in the last hour +

+
+ ) : ( +
+
+ {recentSlowTraces.map( + (trace: RecentTrace, index: number) => { + const maxDuration: number = + recentSlowTraces[0]?.durationNano || 1; + const barWidth: number = + (trace.durationNano / maxDuration) * 100; + + return ( + +
+
+ +
+

+ {trace.name} +

+

+ {getServiceName(trace.serviceId)} +

+
+
+
+

+ {formatDuration(trace.durationNano)} +

+
+
+
+
+
+
+
+ + ); + }, + )} +
+
+ )} +
+
+ + ); +}; + +export default TracesDashboard; diff --git a/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx index d482e618dc..d11d0543ac 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useState, } from "react"; -import TraceTable from "../../Components/Traces/TraceTable"; +import TracesDashboard from "../../Components/Traces/TracesDashboard"; import Service from "Common/Models/DatabaseModels/Service"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; @@ -62,7 +62,7 @@ const TracesPage: FunctionComponent = ( return ; } - return ; + return ; }; export default TracesPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Traces/List.tsx b/App/FeatureSet/Dashboard/src/Pages/Traces/List.tsx new file mode 100644 index 0000000000..71fd5560e5 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Traces/List.tsx @@ -0,0 +1,11 @@ +import PageComponentProps from "../PageComponentProps"; +import React, { FunctionComponent, ReactElement } from "react"; +import TraceTable from "../../Components/Traces/TraceTable"; + +const TracesListPage: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + return ; +}; + +export default TracesListPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx index 703a567963..a3934985fa 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx @@ -14,21 +14,30 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { items: [ { link: { - title: "All Traces", + title: "Overview", to: RouteUtil.populateRouteParams( RouteMap[PageMap.TRACES] as Route, ), }, + icon: IconProp.Home, + }, + { + link: { + title: "All Spans", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.TRACES_LIST] as Route, + ), + }, icon: IconProp.RectangleStack, }, ], }, { - title: "Documentation", + title: "Help", items: [ { link: { - title: "Documentation", + title: "Setup Guide", to: RouteUtil.populateRouteParams( RouteMap[PageMap.TRACES_DOCUMENTATION] as Route, ), diff --git a/App/FeatureSet/Dashboard/src/Pages/Traces/View/Layout.tsx b/App/FeatureSet/Dashboard/src/Pages/Traces/View/Layout.tsx index c6a3b1e4ee..3a915206d8 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Traces/View/Layout.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Traces/View/Layout.tsx @@ -11,7 +11,7 @@ const TracesViewLayout: FunctionComponent< > = (): ReactElement => { const path: string = Navigation.getRoutePath(RouteUtil.getRoutes()); return ( - + ); diff --git a/App/FeatureSet/Dashboard/src/Routes/TracesRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/TracesRoutes.tsx index 460652fe44..c0e3f24d00 100644 --- a/App/FeatureSet/Dashboard/src/Routes/TracesRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/TracesRoutes.tsx @@ -9,6 +9,7 @@ import { Route as PageRoute, Routes } from "react-router-dom"; // Pages import TracesPage from "../Pages/Traces/Index"; +import TracesListPage from "../Pages/Traces/List"; import TracesDocumentationPage from "../Pages/Traces/Documentation"; import TraceViewPage from "../Pages/Traces/View/Index"; @@ -28,6 +29,15 @@ const TracesRoutes: FunctionComponent = ( /> } /> + + } + /> | undefined { const breadcrumpLinksMap: Dictionary = { ...BuildBreadcrumbLinksByTitles(PageMap.TRACES, ["Project", "Traces"]), + ...BuildBreadcrumbLinksByTitles(PageMap.TRACES_LIST, [ + "Project", + "Traces", + "All Spans", + ]), ...BuildBreadcrumbLinksByTitles(PageMap.TRACE_VIEW, [ "Project", "Traces", - "Trace Explorer", + "Trace Details", ]), ...BuildBreadcrumbLinksByTitles(PageMap.TRACES_DOCUMENTATION, [ "Project", "Traces", - "Documentation", + "Setup Guide", ]), }; return breadcrumpLinksMap[path]; diff --git a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts index c60825812d..c60fa47548 100644 --- a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts @@ -21,6 +21,7 @@ enum PageMap { // Traces (standalone product) TRACES_ROOT = "TRACES_ROOT", TRACES = "TRACES", + TRACES_LIST = "TRACES_LIST", TRACE_VIEW = "TRACE_VIEW", TRACES_DOCUMENTATION = "TRACES_DOCUMENTATION", diff --git a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts index 31c0944804..67bbcc2ad7 100644 --- a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts @@ -130,6 +130,7 @@ export const MetricsRoutePath: Dictionary = { // Traces product routes export const TracesRoutePath: Dictionary = { [PageMap.TRACES]: "", + [PageMap.TRACES_LIST]: "list", [PageMap.TRACE_VIEW]: `view/${RouteParams.ModelID}`, [PageMap.TRACES_DOCUMENTATION]: "documentation", }; @@ -2285,6 +2286,12 @@ const RouteMap: Dictionary = { [PageMap.TRACES]: new Route(`/dashboard/${RouteParams.ProjectID}/traces`), + [PageMap.TRACES_LIST]: new Route( + `/dashboard/${RouteParams.ProjectID}/traces/${ + TracesRoutePath[PageMap.TRACES_LIST] + }`, + ), + [PageMap.TRACE_VIEW]: new Route( `/dashboard/${RouteParams.ProjectID}/traces/${ TracesRoutePath[PageMap.TRACE_VIEW]