feat: add TracesDashboard and TracesListPage components, update routing and breadcrumbs for traces

This commit is contained in:
Nawaz Dhandala
2026-04-02 17:24:19 +01:00
parent 20f314512d
commit 7569a50c56
10 changed files with 589 additions and 9 deletions

View File

@@ -117,7 +117,7 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
},
{
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,

View File

@@ -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<ServiceTraceSummary>
>([]);
const [recentErrorTraces, setRecentErrorTraces] = useState<
Array<RecentTrace>
>([]);
const [recentSlowTraces, setRecentSlowTraces] = useState<
Array<RecentTrace>
>([]);
const [services, setServices] = useState<Array<Service>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
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<void> = async (): Promise<void> => {
try {
setIsLoading(true);
setError("");
const now: Date = OneUptimeDate.getCurrentDate();
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
// Load services
const servicesResult: ListResult<Service> = 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<Service> = servicesResult.data || [];
setServices(loadedServices);
// Load recent root spans (last 1 hour) to build per-service summaries
const rootSpansResult: AnalyticsListResult<Span> =
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<Span> = rootSpansResult.data || [];
// Build per-service summaries from root spans
const summaryMap: Map<string, ServiceTraceSummary> = 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<RecentTrace> = [];
const allTraces: Array<RecentTrace> = [];
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<ServiceTraceSummary> = 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<RecentTrace> = [...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 <PageLoader isVisible={true} />;
}
if (error) {
return (
<ErrorMessage
message={error}
onRefreshClick={() => {
void loadDashboard();
}}
/>
);
}
if (serviceSummaries.length === 0) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-12 text-center">
<div className="text-gray-400 text-5xl mb-4">
<svg
className="mx-auto h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No trace data yet
</h3>
<p className="text-sm text-gray-500 max-w-md mx-auto">
Once your services start sending distributed tracing data, you{"'"}ll
see a summary of requests flowing through your system, error rates,
and slow operations.
</p>
</div>
);
}
return (
<Fragment>
{/* Service Cards */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Services Overview
</h3>
<p className="text-sm text-gray-500 mt-1">
Request activity across your services in the last hour
</p>
</div>
<AppLink
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
to={RouteUtil.populateRouteParams(
RouteMap[PageMap.TRACES_LIST] as Route,
)}
>
View all spans
</AppLink>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{serviceSummaries.map((summary: ServiceTraceSummary) => {
const errorRate: number =
summary.totalTraces > 0
? (summary.errorTraces / summary.totalTraces) * 100
: 0;
return (
<div
key={summary.service.id?.toString()}
className="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<ServiceElement service={summary.service} />
{errorRate > 5 ? (
<span className="text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded-full font-medium">
{errorRate.toFixed(1)}% errors
</span>
) : (
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full font-medium">
Healthy
</span>
)}
</div>
<div className="grid grid-cols-3 gap-3 mb-3">
<div>
<p className="text-xs text-gray-500">Requests</p>
<p className="text-lg font-semibold text-gray-900">
{summary.totalTraces.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Errors</p>
<p
className={`text-lg font-semibold ${summary.errorTraces > 0 ? "text-red-600" : "text-gray-900"}`}
>
{summary.errorTraces.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Last Seen</p>
<p className="text-sm text-gray-700">
{summary.latestTraceTime
? OneUptimeDate.getDateAsLocalFormattedString(
summary.latestTraceTime,
true,
)
: "-"}
</p>
</div>
</div>
{/* Error rate bar */}
<div>
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Error Rate</span>
<span>{errorRate.toFixed(1)}%</span>
</div>
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${errorRate > 5 ? "bg-red-500" : errorRate > 0 ? "bg-yellow-400" : "bg-green-400"}`}
style={{
width: `${Math.max(errorRate, errorRate > 0 ? 2 : 0)}%`,
}}
/>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100">
<AppLink
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
to={RouteUtil.populateRouteParams(
RouteMap[PageMap.SERVICE_VIEW_TRACES] as Route,
{
modelId: new ObjectID(
summary.service._id as string,
),
},
)}
>
View service traces
</AppLink>
</div>
</div>
);
})}
</div>
</div>
{/* Two-column layout for errors and slow traces */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Errors */}
<div>
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Recent Errors
</h3>
<p className="text-sm text-gray-500 mt-1">
Failed requests in the last hour
</p>
</div>
{recentErrorTraces.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
<p className="text-sm text-gray-500">
No errors in the last hour
</p>
</div>
) : (
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="divide-y divide-gray-100">
{recentErrorTraces.map(
(trace: RecentTrace, index: number) => {
return (
<AppLink
key={`${trace.traceId}-${index}`}
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
to={RouteUtil.populateRouteParams(
RouteMap[PageMap.TRACE_VIEW]!,
{
modelId: trace.traceId,
},
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3 min-w-0">
<SpanStatusElement
spanStatusCode={trace.statusCode}
title=""
/>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{trace.name}
</p>
<p className="text-xs text-gray-500">
{getServiceName(trace.serviceId)}
</p>
</div>
</div>
<div className="text-right flex-shrink-0 ml-3">
<p className="text-xs font-mono text-gray-600">
{formatDuration(trace.durationNano)}
</p>
<p className="text-xs text-gray-400">
{OneUptimeDate.getDateAsLocalFormattedString(
trace.startTime,
true,
)}
</p>
</div>
</div>
</AppLink>
);
},
)}
</div>
</div>
)}
</div>
{/* Slowest Traces */}
<div>
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Slowest Requests
</h3>
<p className="text-sm text-gray-500 mt-1">
Longest running operations in the last hour
</p>
</div>
{recentSlowTraces.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
<p className="text-sm text-gray-500">
No traces found in the last hour
</p>
</div>
) : (
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="divide-y divide-gray-100">
{recentSlowTraces.map(
(trace: RecentTrace, index: number) => {
const maxDuration: number =
recentSlowTraces[0]?.durationNano || 1;
const barWidth: number =
(trace.durationNano / maxDuration) * 100;
return (
<AppLink
key={`${trace.traceId}-slow-${index}`}
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
to={RouteUtil.populateRouteParams(
RouteMap[PageMap.TRACE_VIEW]!,
{
modelId: trace.traceId,
},
)}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-3 min-w-0">
<SpanStatusElement
spanStatusCode={trace.statusCode}
title=""
/>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{trace.name}
</p>
<p className="text-xs text-gray-500">
{getServiceName(trace.serviceId)}
</p>
</div>
</div>
<div className="text-right flex-shrink-0 ml-3">
<p className="text-sm font-mono font-semibold text-gray-900">
{formatDuration(trace.durationNano)}
</p>
</div>
</div>
<div className="ml-8">
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-amber-400"
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
</AppLink>
);
},
)}
</div>
</div>
)}
</div>
</div>
</Fragment>
);
};
export default TracesDashboard;

View File

@@ -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<PageComponentProps> = (
return <TelemetryDocumentation telemetryType="traces" />;
}
return <TraceTable />;
return <TracesDashboard />;
};
export default TracesPage;

View File

@@ -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 <TraceTable />;
};
export default TracesListPage;

View File

@@ -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,
),

View File

@@ -11,7 +11,7 @@ const TracesViewLayout: FunctionComponent<
> = (): ReactElement => {
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
return (
<Page title="Trace Explorer" breadcrumbLinks={getTracesBreadcrumbs(path)}>
<Page title="Trace Details" breadcrumbLinks={getTracesBreadcrumbs(path)}>
<Outlet />
</Page>
);

View File

@@ -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<ComponentProps> = (
/>
}
/>
<PageRoute
path={TracesRoutePath[PageMap.TRACES_LIST] || ""}
element={
<TracesListPage
{...props}
pageRoute={RouteMap[PageMap.TRACES_LIST] as Route}
/>
}
/>
<PageRoute
path={TracesRoutePath[PageMap.TRACES_DOCUMENTATION] || ""}
element={

View File

@@ -6,15 +6,20 @@ import Link from "Common/Types/Link";
export function getTracesBreadcrumbs(path: string): Array<Link> | undefined {
const breadcrumpLinksMap: Dictionary<Link[]> = {
...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];

View File

@@ -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",

View File

@@ -130,6 +130,7 @@ export const MetricsRoutePath: Dictionary<string> = {
// Traces product routes
export const TracesRoutePath: Dictionary<string> = {
[PageMap.TRACES]: "",
[PageMap.TRACES_LIST]: "list",
[PageMap.TRACE_VIEW]: `view/${RouteParams.ModelID}`,
[PageMap.TRACES_DOCUMENTATION]: "documentation",
};
@@ -2285,6 +2286,12 @@ const RouteMap: Dictionary<Route> = {
[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]