feat: Implement critical path analysis for distributed traces

- Added CriticalPathUtil class to compute self-time, critical path, and service breakdown for spans in distributed traces.
- Introduced SpanData, SpanSelfTime, CriticalPathResult, and ServiceBreakdown interfaces for structured data handling.
- Created API for fetching monitor data, including fetching all monitors, monitor details, statuses, and feeds.
- Developed MonitorCard component for displaying individual monitor information.
- Implemented MonitorsScreen and MonitorDetailScreen for listing and detailing monitors, respectively.
- Added hooks for managing monitor data fetching and state.
- Integrated navigation for monitor detail views.
This commit is contained in:
Nawaz Dhandala
2026-03-17 12:13:16 +00:00
parent 7984e5d1ab
commit bcb1e92cab
18 changed files with 2905 additions and 56 deletions

View File

@@ -28,6 +28,7 @@ import Log from "Common/Models/AnalyticsModels/Log";
import Span, {
SpanEvent,
SpanEventType,
SpanLink,
} from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import React, { FunctionComponent, ReactElement, useEffect } from "react";
@@ -37,6 +38,11 @@ import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
import PageMap from "../../Utils/PageMap";
import Route from "Common/Types/API/Route";
import Link from "Common/UI/Components/Link/Link";
import CriticalPathUtil, {
SpanData,
SpanSelfTime,
} from "Common/Utils/Traces/CriticalPath";
export interface ComponentProps {
id: string;
@@ -45,6 +51,7 @@ export interface ComponentProps {
onClose: () => void;
telemetryService: Service;
divisibilityFactor: DivisibilityFactor;
allTraceSpans?: Span[];
}
const SpanViewer: FunctionComponent<ComponentProps> = (
@@ -76,7 +83,9 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
serviceId: true,
spanId: true,
traceId: true,
parentSpanId: true,
events: true,
links: true,
startTime: true,
endTime: true,
startTimeUnixNano: true,
@@ -533,6 +542,109 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
);
};
// Compute self-time for this span
const selfTimeInfo: SpanSelfTime | null = React.useMemo(() => {
if (!span || !props.allTraceSpans || props.allTraceSpans.length === 0) {
return null;
}
const spanDataList: SpanData[] = props.allTraceSpans.map(
(s: Span): SpanData => {
return {
spanId: s.spanId!,
parentSpanId: s.parentSpanId || undefined,
startTimeUnixNano: s.startTimeUnixNano!,
endTimeUnixNano: s.endTimeUnixNano!,
durationUnixNano: s.durationUnixNano!,
serviceId: s.serviceId?.toString(),
name: s.name,
};
},
);
const selfTimes: Map<string, SpanSelfTime> =
CriticalPathUtil.computeSelfTimes(spanDataList);
return selfTimes.get(span.spanId!) || null;
}, [span, props.allTraceSpans]);
const getLinksContentElement: GetReactElementFunction = (): ReactElement => {
if (!span) {
return <ErrorMessage message="Span not found" />;
}
const links: Array<SpanLink> | undefined = span.links;
if (!links || links.length === 0) {
return <ErrorMessage message="No linked spans found." />;
}
return (
<div className="space-y-2">
{links.map((link: SpanLink, index: number) => {
const traceRoute: Route = RouteUtil.populateRouteParams(
RouteMap[PageMap.TRACE_VIEW]!,
{
modelId: link.traceId,
},
);
const routeWithSpanId: Route = new Route(traceRoute.toString());
routeWithSpanId.addQueryParams({ spanId: link.spanId });
return (
<div
key={index}
className="rounded-md border border-gray-200 p-3 space-y-2"
>
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-gray-700">
Link {index + 1}
</div>
<Link
to={routeWithSpanId}
className="text-xs font-medium text-indigo-600 hover:text-indigo-700 hover:underline"
openInNewTab={true}
>
View Trace
</Link>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-gray-500 font-medium">Trace ID</div>
<code className="text-gray-800 font-mono text-[11px] break-all">
{link.traceId}
</code>
</div>
<div>
<div className="text-gray-500 font-medium">Span ID</div>
<code className="text-gray-800 font-mono text-[11px] break-all">
{link.spanId}
</code>
</div>
</div>
{link.attributes &&
Object.keys(link.attributes).length > 0 ? (
<div>
<div className="text-xs text-gray-500 font-medium mb-1">
Attributes
</div>
<JSONTable
json={JSONFunctions.nestJson(
(link.attributes as any) || {},
)}
title="Link Attributes"
/>
</div>
) : (
<></>
)}
</div>
);
})}
</div>
);
};
const getBasicInfo: GetReactElementFunction = (): ReactElement => {
if (!span) {
return <ErrorMessage message="Span not found" />;
@@ -664,6 +776,33 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
);
},
},
...(selfTimeInfo
? [
{
key: "selfTime" as keyof Span,
title: "Self Time",
description:
"Time spent in this span excluding child span durations.",
fieldType: FieldType.Element,
getElement: () => {
return (
<div className="flex items-center space-x-2">
<span>
{SpanUtil.getSpanDurationAsString({
divisibilityFactor: props.divisibilityFactor,
spanDurationInUnixNano:
selfTimeInfo.selfTimeUnixNano,
})}
</span>
<span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{selfTimeInfo.selfTimePercent.toFixed(1)}% of span
</span>
</div>
);
},
},
]
: []),
{
key: "kind",
title: "Span Kind",
@@ -710,6 +849,12 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
return event.name === SpanEventType.Exception.toLowerCase();
}).length,
},
{
name: "Links",
children: getLinksContentElement(),
countBadge: span?.links?.length || 0,
tabType: TabType.Info,
},
]}
onTabChange={() => {}}
/>

View File

@@ -0,0 +1,388 @@
import SpanUtil from "../../Utils/SpanUtil";
import CriticalPathUtil, {
SpanData,
SpanSelfTime,
} from "Common/Utils/Traces/CriticalPath";
import Span from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import Color from "Common/Types/Color";
import { Black } from "Common/Types/BrandColors";
import React, { FunctionComponent, ReactElement } from "react";
export interface FlameGraphProps {
spans: Span[];
telemetryServices: Service[];
onSpanSelect?: (spanId: string) => void;
selectedSpanId: string | undefined;
}
interface FlameGraphNode {
span: Span;
children: FlameGraphNode[];
depth: number;
startTimeUnixNano: number;
endTimeUnixNano: number;
durationUnixNano: number;
selfTimeUnixNano: number;
serviceColor: Color;
serviceName: string;
}
const MIN_BLOCK_WIDTH_PX: number = 2;
const FlameGraph: FunctionComponent<FlameGraphProps> = (
props: FlameGraphProps,
): ReactElement => {
const { spans, telemetryServices, onSpanSelect, selectedSpanId } = props;
const [hoveredSpanId, setHoveredSpanId] = React.useState<string | null>(null);
const [focusedSpanId, setFocusedSpanId] = React.useState<string | null>(null);
const containerRef: React.RefObject<HTMLDivElement | null> = React.useRef<HTMLDivElement>(null);
// Build span data for critical path utility
const spanDataList: SpanData[] = React.useMemo(() => {
return spans.map((span: Span): SpanData => {
return {
spanId: span.spanId!,
parentSpanId: span.parentSpanId || undefined,
startTimeUnixNano: span.startTimeUnixNano!,
endTimeUnixNano: span.endTimeUnixNano!,
durationUnixNano: span.durationUnixNano!,
serviceId: span.serviceId?.toString(),
name: span.name,
};
});
}, [spans]);
// Compute self-times
const selfTimes: Map<string, SpanSelfTime> = React.useMemo(() => {
return CriticalPathUtil.computeSelfTimes(spanDataList);
}, [spanDataList]);
// Build tree structure
const { rootNodes, traceStart, traceEnd } = React.useMemo(() => {
if (spans.length === 0) {
return { rootNodes: [], traceStart: 0, traceEnd: 0 };
}
const spanMap: Map<string, Span> = new Map();
const childrenMap: Map<string, Span[]> = new Map();
const allSpanIds: Set<string> = new Set();
let tStart: number = spans[0]!.startTimeUnixNano!;
let tEnd: number = spans[0]!.endTimeUnixNano!;
for (const span of spans) {
spanMap.set(span.spanId!, span);
allSpanIds.add(span.spanId!);
if (span.startTimeUnixNano! < tStart) {
tStart = span.startTimeUnixNano!;
}
if (span.endTimeUnixNano! > tEnd) {
tEnd = span.endTimeUnixNano!;
}
}
for (const span of spans) {
if (span.parentSpanId && allSpanIds.has(span.parentSpanId)) {
const children: Span[] = childrenMap.get(span.parentSpanId) || [];
children.push(span);
childrenMap.set(span.parentSpanId, children);
}
}
const getServiceInfo = (
span: Span,
): { color: Color; name: string } => {
const service: Service | undefined = telemetryServices.find(
(s: Service) => {
return s._id?.toString() === span.serviceId?.toString();
},
);
return {
color: (service?.serviceColor as Color) || Black,
name: service?.name || "Unknown",
};
};
const buildNode = (span: Span, depth: number): FlameGraphNode => {
const children: Span[] = childrenMap.get(span.spanId!) || [];
const selfTime: SpanSelfTime | undefined = selfTimes.get(span.spanId!);
const serviceInfo: { color: Color; name: string } = getServiceInfo(span);
// Sort children by start time
children.sort((a: Span, b: Span) => {
return a.startTimeUnixNano! - b.startTimeUnixNano!;
});
return {
span,
children: children.map((child: Span) => {
return buildNode(child, depth + 1);
}),
depth,
startTimeUnixNano: span.startTimeUnixNano!,
endTimeUnixNano: span.endTimeUnixNano!,
durationUnixNano: span.durationUnixNano!,
selfTimeUnixNano: selfTime ? selfTime.selfTimeUnixNano : span.durationUnixNano!,
serviceColor: serviceInfo.color,
serviceName: serviceInfo.name,
};
};
// Find root spans
const roots: Span[] = spans.filter((span: Span) => {
const p: string | undefined = span.parentSpanId;
if (!p || p.trim() === "") {
return true;
}
if (!allSpanIds.has(p)) {
return true;
}
return false;
});
const effectiveRoots: Span[] = roots.length > 0 ? roots : [spans[0]!];
return {
rootNodes: effectiveRoots.map((root: Span) => {
return buildNode(root, 0);
}),
traceStart: tStart,
traceEnd: tEnd,
};
}, [spans, telemetryServices, selfTimes]);
// Find max depth for height calculation
const maxDepth: number = React.useMemo(() => {
let max: number = 0;
const traverse = (node: FlameGraphNode): void => {
if (node.depth > max) {
max = node.depth;
}
for (const child of node.children) {
traverse(child);
}
};
for (const root of rootNodes) {
traverse(root);
}
return max;
}, [rootNodes]);
// Find the focused subtree range for zoom
const { viewStart, viewEnd } = React.useMemo(() => {
if (!focusedSpanId) {
return { viewStart: traceStart, viewEnd: traceEnd };
}
const findNode = (nodes: FlameGraphNode[]): FlameGraphNode | null => {
for (const node of nodes) {
if (node.span.spanId === focusedSpanId) {
return node;
}
const found: FlameGraphNode | null = findNode(node.children);
if (found) {
return found;
}
}
return null;
};
const focused: FlameGraphNode | null = findNode(rootNodes);
if (focused) {
return {
viewStart: focused.startTimeUnixNano,
viewEnd: focused.endTimeUnixNano,
};
}
return { viewStart: traceStart, viewEnd: traceEnd };
}, [focusedSpanId, rootNodes, traceStart, traceEnd]);
const totalDuration: number = viewEnd - viewStart;
const rowHeight: number = 24;
const chartHeight: number = (maxDepth + 1) * rowHeight + 8;
if (spans.length === 0) {
return (
<div className="p-8 text-center text-gray-500 text-sm">
No spans to display
</div>
);
}
const renderNode = (node: FlameGraphNode): ReactElement | null => {
// Calculate position relative to view
const nodeStart: number = Math.max(node.startTimeUnixNano, viewStart);
const nodeEnd: number = Math.min(node.endTimeUnixNano, viewEnd);
if (nodeEnd <= nodeStart) {
return null; // Not in view
}
const leftPercent: number =
totalDuration > 0
? ((nodeStart - viewStart) / totalDuration) * 100
: 0;
const widthPercent: number =
totalDuration > 0
? ((nodeEnd - nodeStart) / totalDuration) * 100
: 0;
const isHovered: boolean = hoveredSpanId === node.span.spanId;
const isSelected: boolean = selectedSpanId === node.span.spanId;
const isFocused: boolean = focusedSpanId === node.span.spanId;
const durationStr: string = SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: node.durationUnixNano,
divisibilityFactor: SpanUtil.getDivisibilityFactor(totalDuration),
});
const selfTimeStr: string = SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: node.selfTimeUnixNano,
divisibilityFactor: SpanUtil.getDivisibilityFactor(totalDuration),
});
const colorStr: string = String(node.serviceColor);
return (
<React.Fragment key={node.span.spanId}>
<div
className={`absolute cursor-pointer border border-white/30 transition-opacity overflow-hidden ${
isSelected
? "ring-2 ring-indigo-500 ring-offset-1 z-10"
: isHovered
? "ring-1 ring-gray-400 z-10"
: ""
} ${isFocused ? "ring-2 ring-amber-400 z-10" : ""}`}
style={{
left: `${leftPercent}%`,
width: `${Math.max(widthPercent, 0.1)}%`,
top: `${node.depth * rowHeight}px`,
height: `${rowHeight - 2}px`,
backgroundColor: colorStr,
opacity: isHovered || isSelected ? 1 : 0.85,
minWidth: `${MIN_BLOCK_WIDTH_PX}px`,
}}
onMouseEnter={() => {
setHoveredSpanId(node.span.spanId!);
}}
onMouseLeave={() => {
setHoveredSpanId(null);
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
if (onSpanSelect) {
onSpanSelect(node.span.spanId!);
}
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
setFocusedSpanId((prev: string | null) => {
return prev === node.span.spanId! ? null : node.span.spanId!;
});
}}
title={`${node.span.name} (${node.serviceName})\nDuration: ${durationStr}\nSelf Time: ${selfTimeStr}`}
>
{widthPercent > 3 ? (
<div className="px-1 text-[10px] font-medium text-white truncate leading-snug pt-0.5">
{node.span.name}
</div>
) : (
<></>
)}
</div>
{node.children.map((child: FlameGraphNode) => {
return renderNode(child);
})}
</React.Fragment>
);
};
const hoveredNode: FlameGraphNode | null = React.useMemo(() => {
if (!hoveredSpanId) {
return null;
}
const findNode = (nodes: FlameGraphNode[]): FlameGraphNode | null => {
for (const node of nodes) {
if (node.span.spanId === hoveredSpanId) {
return node;
}
const found: FlameGraphNode | null = findNode(node.children);
if (found) {
return found;
}
}
return null;
};
return findNode(rootNodes);
}, [hoveredSpanId, rootNodes]);
return (
<div className="flame-graph" ref={containerRef}>
{/* Controls */}
<div className="flex items-center justify-between mb-2 px-1">
<div className="text-[11px] text-gray-500">
Click a span to view details. Double-click to zoom into a subtree.
</div>
{focusedSpanId ? (
<button
type="button"
onClick={() => {
setFocusedSpanId(null);
}}
className="text-[11px] font-medium text-indigo-600 hover:text-indigo-700 hover:underline"
>
Reset Zoom
</button>
) : (
<></>
)}
</div>
{/* Flame graph */}
<div
className="relative overflow-hidden rounded border border-gray-200 bg-gray-50"
style={{ height: `${chartHeight}px` }}
>
{rootNodes.map((root: FlameGraphNode) => {
return renderNode(root);
})}
</div>
{/* Tooltip */}
{hoveredNode ? (
<div className="mt-2 px-3 py-2 rounded-md border border-gray-200 bg-white/90 text-xs space-y-1">
<div className="font-semibold text-gray-800">
{hoveredNode.span.name}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-gray-600">
<div>
<span className="font-medium text-gray-700">Service: </span>
{hoveredNode.serviceName}
</div>
<div>
<span className="font-medium text-gray-700">Duration: </span>
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: hoveredNode.durationUnixNano,
divisibilityFactor:
SpanUtil.getDivisibilityFactor(totalDuration),
})}
</div>
<div>
<span className="font-medium text-gray-700">Self Time: </span>
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: hoveredNode.selfTimeUnixNano,
divisibilityFactor:
SpanUtil.getDivisibilityFactor(totalDuration),
})}
</div>
</div>
</div>
) : (
<></>
)}
</div>
);
};
export default FlameGraph;

View File

@@ -1,12 +1,19 @@
import DashboardLogsViewer from "../Logs/LogsViewer";
import SpanStatusElement from "../Span/SpanStatusElement";
import SpanViewer from "../Span/SpanViewer";
import FlameGraph from "./FlameGraph";
import TraceServiceMap from "./TraceServiceMap";
import ServiceElement from "..//Service/ServiceElement";
import ProjectUtil from "Common/UI/Utils/Project";
import SpanUtil, {
DivisibilityFactor,
IntervalUnit,
} from "../../Utils/SpanUtil";
import CriticalPathUtil, {
SpanData,
CriticalPathResult,
ServiceBreakdown,
} from "Common/Utils/Traces/CriticalPath";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Color from "Common/Types/Color";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
@@ -33,6 +40,12 @@ import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
enum TraceViewMode {
Waterfall = "Waterfall",
FlameGraph = "Flame Graph",
ServiceMap = "Service Map",
}
const INITIAL_SPAN_FETCH_SIZE: number = 500;
const SPAN_PAGE_SIZE: number = 500;
const MAX_SPAN_FETCH_BATCH: number = LIMIT_PER_PROJECT;
@@ -86,6 +99,12 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
// UI State Enhancements
const [showErrorsOnly, setShowErrorsOnly] = React.useState<boolean>(false);
const [viewMode, setViewMode] = React.useState<TraceViewMode>(
TraceViewMode.Waterfall,
);
const [spanSearchText, setSpanSearchText] = React.useState<string>("");
const [showCriticalPath, setShowCriticalPath] =
React.useState<boolean>(false);
const [traceId, setTraceId] = React.useState<string | null>(null);
@@ -654,7 +673,7 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
}
}, [servicesInTrace, selectedServiceIds]);
// Final spans after applying filters
// Final spans after applying filters (including search)
const displaySpans: Span[] = React.useMemo(() => {
let filtered: Span[] = spans;
if (showErrorsOnly) {
@@ -669,8 +688,77 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
: false;
});
}
if (spanSearchText.trim().length > 0) {
const searchLower: string = spanSearchText.trim().toLowerCase();
filtered = filtered.filter((s: Span): boolean => {
// Match against span name
if (s.name?.toLowerCase().includes(searchLower)) {
return true;
}
// Match against span ID
if (s.spanId?.toLowerCase().includes(searchLower)) {
return true;
}
// Match against service name
const service: Service | undefined = telemetryServices.find(
(svc: Service) => {
return svc._id?.toString() === s.serviceId?.toString();
},
);
if (service?.name?.toLowerCase().includes(searchLower)) {
return true;
}
return false;
});
}
return filtered;
}, [spans, showErrorsOnly, selectedServiceIds]);
}, [spans, showErrorsOnly, selectedServiceIds, spanSearchText, telemetryServices]);
// Search match count for display
const searchMatchCount: number = React.useMemo(() => {
if (spanSearchText.trim().length === 0) {
return 0;
}
return displaySpans.length;
}, [displaySpans, spanSearchText]);
// Critical path computation
const criticalPathResult: CriticalPathResult | null = React.useMemo(() => {
if (!showCriticalPath || spans.length === 0) {
return null;
}
const spanDataList: SpanData[] = spans.map((s: Span): SpanData => {
return {
spanId: s.spanId!,
parentSpanId: s.parentSpanId || undefined,
startTimeUnixNano: s.startTimeUnixNano!,
endTimeUnixNano: s.endTimeUnixNano!,
durationUnixNano: s.durationUnixNano!,
serviceId: s.serviceId?.toString(),
name: s.name,
};
});
return CriticalPathUtil.computeCriticalPath(spanDataList);
}, [showCriticalPath, spans]);
// Service latency breakdown
const serviceBreakdown: ServiceBreakdown[] = React.useMemo(() => {
if (spans.length === 0) {
return [];
}
const spanDataList: SpanData[] = spans.map((s: Span): SpanData => {
return {
spanId: s.spanId!,
parentSpanId: s.parentSpanId || undefined,
startTimeUnixNano: s.startTimeUnixNano!,
endTimeUnixNano: s.endTimeUnixNano!,
durationUnixNano: s.durationUnixNano!,
serviceId: s.serviceId?.toString(),
name: s.name,
};
});
return CriticalPathUtil.computeServiceBreakdown(spanDataList);
}, [spans]);
const spanStats: {
totalSpans: number;
@@ -846,12 +934,28 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
}),
);
const highlightableSpanIds: string[] = highlightSpanIds.filter(
// Combine highlight span IDs with critical path span IDs
let allHighlightSpanIds: string[] = highlightSpanIds.filter(
(spanId: string) => {
return displaySpanIds.has(spanId);
},
);
if (
criticalPathResult &&
criticalPathResult.criticalPathSpanIds.length > 0
) {
const criticalPathIds: string[] =
criticalPathResult.criticalPathSpanIds.filter((spanId: string) => {
return displaySpanIds.has(spanId);
});
allHighlightSpanIds = [
...new Set([...allHighlightSpanIds, ...criticalPathIds]),
];
}
const highlightableSpanIds: string[] = allHighlightSpanIds;
const ganttChart: GanttChartProps = {
id: "chart",
selectedBarIds: selectedSpans,
@@ -869,7 +973,7 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
};
setGanttChart(ganttChart);
}, [displaySpans, selectedSpans, highlightSpanIds]);
}, [displaySpans, selectedSpans, highlightSpanIds, criticalPathResult]);
if (isLoading && spans.length === 0) {
return <PageLoader isVisible={true} />;
@@ -1086,56 +1190,136 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
</div>
</div>
{/* Toolbar */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => {
return setShowErrorsOnly(false);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all ${
!showErrorsOnly
? "bg-indigo-600 text-white border-indigo-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
All Spans
</button>
<button
type="button"
onClick={() => {
return setShowErrorsOnly(true);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
showErrorsOnly
? "bg-red-600 text-white border-red-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
<span>Errors Only</span>
{spanStats.errorSpans > 0 ? (
<span className="text-[10px] bg-white/20 rounded px-1">
{spanStats.errorSpans}
</span>
{/* View Mode Toggle */}
<div className="flex flex-col gap-3 mb-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="flex items-center space-x-1 bg-gray-100 rounded-lg p-0.5">
{Object.values(TraceViewMode).map((mode: TraceViewMode) => {
return (
<button
key={mode}
type="button"
onClick={() => {
setViewMode(mode);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md transition-all ${
viewMode === mode
? "bg-white text-gray-800 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{mode}
</button>
);
})}
</div>
{/* Search Bar */}
<div className="relative flex items-center">
<input
type="text"
placeholder="Search spans by name, ID, or service..."
value={spanSearchText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSpanSearchText(e.target.value);
}}
className="text-xs border border-gray-200 rounded-md px-3 py-1.5 w-64 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent placeholder-gray-400"
/>
{spanSearchText.length > 0 ? (
<div className="absolute right-2 flex items-center space-x-1">
<span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{searchMatchCount} of {spans.length}
</span>
<button
type="button"
onClick={() => {
setSpanSearchText("");
}}
className="text-gray-400 hover:text-gray-600 text-xs"
>
x
</button>
</div>
) : (
<></>
)}
</button>
</div>
</div>
<div className="flex items-center space-x-3 text-xs text-gray-500">
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-rose-500" />
<span>Error</span>
{/* Toolbar Row 2: Filters & Controls */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => {
return setShowErrorsOnly(false);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all ${
!showErrorsOnly
? "bg-indigo-600 text-white border-indigo-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
All Spans
</button>
<button
type="button"
onClick={() => {
return setShowErrorsOnly(true);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
showErrorsOnly
? "bg-red-600 text-white border-red-600 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
<span>Errors Only</span>
{spanStats.errorSpans > 0 ? (
<span className="text-[10px] bg-white/20 rounded px-1">
{spanStats.errorSpans}
</span>
) : (
<></>
)}
</button>
{/* Critical Path Toggle */}
{viewMode === TraceViewMode.Waterfall ? (
<button
type="button"
onClick={() => {
setShowCriticalPath(
(prev: boolean) => {
return !prev;
},
);
}}
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
showCriticalPath
? "bg-amber-500 text-white border-amber-500 shadow-sm"
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
}`}
>
<span>Critical Path</span>
</button>
) : (
<></>
)}
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-emerald-500" />
<span>OK</span>
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span>Other</span>
<div className="flex items-center space-x-3 text-xs text-gray-500">
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-rose-500" />
<span>Error</span>
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-emerald-500" />
<span>OK</span>
</div>
<div className="flex items-center space-x-1">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span>Other</span>
</div>
</div>
</div>
</div>
@@ -1236,13 +1420,127 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
<></>
)}
<div className="overflow-x-auto rounded-lg border border-gray-200">
{ganttChart ? (
<GanttChart chart={ganttChart} />
) : (
<div className="p-8">
<ErrorMessage message={"No spans found"} />
{/* Service Latency Breakdown */}
{serviceBreakdown.length > 1 ? (
<div className="mb-4 border border-gray-100 rounded-lg p-3 bg-gradient-to-br from-gray-50/60 to-white">
<div className="text-[11px] uppercase tracking-wide text-gray-500 font-medium mb-2">
Latency Breakdown by Service
</div>
<div className="space-y-1.5">
{serviceBreakdown.map((breakdown: ServiceBreakdown) => {
const service: Service | undefined =
telemetryServices.find((s: Service) => {
return (
s._id?.toString() === breakdown.serviceId
);
});
const serviceName: string =
service?.name || "Unknown";
const serviceColor: string = String(
(service?.serviceColor as unknown as string) ||
"#6366f1",
);
const percent: number = Math.min(
breakdown.percentOfTrace,
100,
);
return (
<div key={breakdown.serviceId} className="flex items-center space-x-2">
<span
className="h-2.5 w-2.5 rounded-sm ring-1 ring-black/10 flex-shrink-0"
style={{
backgroundColor: serviceColor,
}}
/>
<span className="text-[11px] font-medium text-gray-700 w-24 truncate">
{serviceName}
</span>
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.max(percent, 1)}%`,
backgroundColor: serviceColor,
opacity: 0.7,
}}
/>
</div>
<span className="text-[10px] text-gray-500 w-20 text-right">
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano:
breakdown.selfTimeUnixNano,
divisibilityFactor: divisibilityFactor,
})}{" "}
({percent.toFixed(1)}%)
</span>
</div>
);
})}
</div>
</div>
) : (
<></>
)}
{/* Critical Path Info */}
{showCriticalPath && criticalPathResult ? (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<span className="font-medium">Critical Path:</span>{" "}
{criticalPathResult.criticalPathSpanIds.length} spans,{" "}
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano:
criticalPathResult.criticalPathDurationUnixNano,
divisibilityFactor: divisibilityFactor,
})}{" "}
of{" "}
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano:
criticalPathResult.totalTraceDurationUnixNano,
divisibilityFactor: divisibilityFactor,
})}{" "}
total trace duration (highlighted in waterfall)
</div>
) : (
<></>
)}
{/* Main Visualization */}
<div className="overflow-x-auto rounded-lg border border-gray-200">
{viewMode === TraceViewMode.Waterfall ? (
<>
{ganttChart ? (
<GanttChart chart={ganttChart} />
) : (
<div className="p-8">
<ErrorMessage message={"No spans found"} />
</div>
)}
</>
) : viewMode === TraceViewMode.FlameGraph ? (
<div className="p-4">
<FlameGraph
spans={displaySpans}
telemetryServices={telemetryServices}
onSpanSelect={(spanId: string) => {
setSelectedSpans([spanId]);
}}
selectedSpanId={
selectedSpans.length > 0
? selectedSpans[0]
: undefined
}
/>
</div>
) : viewMode === TraceViewMode.ServiceMap ? (
<div className="p-4">
<TraceServiceMap
spans={displaySpans}
telemetryServices={telemetryServices}
/>
</div>
) : (
<></>
)}
</div>
</Card>
@@ -1296,6 +1594,7 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
})!
}
divisibilityFactor={divisibilityFactor}
allTraceSpans={spans}
/>
</SideOver>
) : (

View File

@@ -0,0 +1,402 @@
import SpanUtil from "../../Utils/SpanUtil";
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
import Service from "Common/Models/DatabaseModels/Service";
import React, { FunctionComponent, ReactElement } from "react";
export interface TraceServiceMapProps {
spans: Span[];
telemetryServices: Service[];
}
interface ServiceNode {
serviceId: string;
serviceName: string;
serviceColor: string;
spanCount: number;
errorCount: number;
totalDurationUnixNano: number;
}
interface ServiceEdge {
fromServiceId: string;
toServiceId: string;
callCount: number;
totalDurationUnixNano: number;
errorCount: number;
}
const TraceServiceMap: FunctionComponent<TraceServiceMapProps> = (
props: TraceServiceMapProps,
): ReactElement => {
const { spans, telemetryServices } = props;
// Build nodes and edges from spans
const { nodes, edges } = React.useMemo(() => {
const nodeMap: Map<string, ServiceNode> = new Map();
const edgeMap: Map<string, ServiceEdge> = new Map();
const spanServiceMap: Map<string, string> = new Map(); // spanId -> serviceId
// First pass: build span -> service mapping and service nodes
for (const span of spans) {
const serviceId: string = span.serviceId?.toString() || "unknown";
spanServiceMap.set(span.spanId!, serviceId);
const existing: ServiceNode | undefined = nodeMap.get(serviceId);
if (existing) {
existing.spanCount += 1;
existing.totalDurationUnixNano += span.durationUnixNano!;
if (span.statusCode === SpanStatus.Error) {
existing.errorCount += 1;
}
} else {
const service: Service | undefined = telemetryServices.find(
(s: Service) => {
return s._id?.toString() === serviceId;
},
);
nodeMap.set(serviceId, {
serviceId,
serviceName: service?.name || "Unknown",
serviceColor: String(
(service?.serviceColor as unknown as string) || "#6366f1",
),
spanCount: 1,
errorCount: span.statusCode === SpanStatus.Error ? 1 : 0,
totalDurationUnixNano: span.durationUnixNano!,
});
}
}
// Second pass: build edges from parent-child relationships
for (const span of spans) {
if (!span.parentSpanId) {
continue;
}
const parentServiceId: string | undefined = spanServiceMap.get(
span.parentSpanId,
);
const childServiceId: string = span.serviceId?.toString() || "unknown";
if (!parentServiceId || parentServiceId === childServiceId) {
continue; // Skip same-service calls
}
const edgeKey: string = `${parentServiceId}->${childServiceId}`;
const existing: ServiceEdge | undefined = edgeMap.get(edgeKey);
if (existing) {
existing.callCount += 1;
existing.totalDurationUnixNano += span.durationUnixNano!;
if (span.statusCode === SpanStatus.Error) {
existing.errorCount += 1;
}
} else {
edgeMap.set(edgeKey, {
fromServiceId: parentServiceId,
toServiceId: childServiceId,
callCount: 1,
totalDurationUnixNano: span.durationUnixNano!,
errorCount: span.statusCode === SpanStatus.Error ? 1 : 0,
});
}
}
return {
nodes: Array.from(nodeMap.values()),
edges: Array.from(edgeMap.values()),
};
}, [spans, telemetryServices]);
// Compute trace duration for context
const traceDuration: number = React.useMemo(() => {
if (spans.length === 0) {
return 0;
}
let minStart: number = spans[0]!.startTimeUnixNano!;
let maxEnd: number = spans[0]!.endTimeUnixNano!;
for (const span of spans) {
if (span.startTimeUnixNano! < minStart) {
minStart = span.startTimeUnixNano!;
}
if (span.endTimeUnixNano! > maxEnd) {
maxEnd = span.endTimeUnixNano!;
}
}
return maxEnd - minStart;
}, [spans]);
const divisibilityFactor = SpanUtil.getDivisibilityFactor(traceDuration);
if (nodes.length === 0) {
return (
<div className="p-8 text-center text-gray-500 text-sm">
No services found in this trace
</div>
);
}
// Layout: arrange nodes in a topological order based on edges
// Simple layout: find entry nodes and lay out left-to-right
const { nodePositions, layoutWidth, layoutHeight } = React.useMemo(() => {
// Build adjacency list
const adjList: Map<string, string[]> = new Map();
const inDegree: Map<string, number> = new Map();
for (const node of nodes) {
adjList.set(node.serviceId, []);
inDegree.set(node.serviceId, 0);
}
for (const edge of edges) {
const neighbors: string[] = adjList.get(edge.fromServiceId) || [];
neighbors.push(edge.toServiceId);
adjList.set(edge.fromServiceId, neighbors);
inDegree.set(
edge.toServiceId,
(inDegree.get(edge.toServiceId) || 0) + 1,
);
}
// Topological sort using BFS (Kahn's algorithm)
const queue: string[] = [];
for (const [nodeId, degree] of inDegree.entries()) {
if (degree === 0) {
queue.push(nodeId);
}
}
const levels: Map<string, number> = new Map();
let level: number = 0;
const levelNodes: string[][] = [];
while (queue.length > 0) {
const levelSize: number = queue.length;
const currentLevel: string[] = [];
for (let i: number = 0; i < levelSize; i++) {
const nodeId: string = queue.shift()!;
levels.set(nodeId, level);
currentLevel.push(nodeId);
const neighbors: string[] = adjList.get(nodeId) || [];
for (const neighbor of neighbors) {
const newDegree: number = (inDegree.get(neighbor) || 1) - 1;
inDegree.set(neighbor, newDegree);
if (newDegree === 0) {
queue.push(neighbor);
}
}
}
levelNodes.push(currentLevel);
level++;
}
// Handle cycles - place unvisited nodes at the end
for (const node of nodes) {
if (!levels.has(node.serviceId)) {
if (levelNodes.length === 0) {
levelNodes.push([]);
}
levelNodes[levelNodes.length - 1]!.push(node.serviceId);
levels.set(node.serviceId, levelNodes.length - 1);
}
}
// Compute positions
const nodeWidth: number = 200;
const nodeHeight: number = 80;
const horizontalGap: number = 120;
const verticalGap: number = 40;
const positions: Map<string, { x: number; y: number }> = new Map();
let maxX: number = 0;
let maxY: number = 0;
for (let l: number = 0; l < levelNodes.length; l++) {
const levelNodeIds: string[] = levelNodes[l]!;
const x: number = l * (nodeWidth + horizontalGap) + 20;
for (let n: number = 0; n < levelNodeIds.length; n++) {
const y: number = n * (nodeHeight + verticalGap) + 20;
positions.set(levelNodeIds[n]!, { x, y });
if (x + nodeWidth > maxX) {
maxX = x + nodeWidth;
}
if (y + nodeHeight > maxY) {
maxY = y + nodeHeight;
}
}
}
return {
nodePositions: positions,
layoutWidth: maxX + 40,
layoutHeight: maxY + 40,
};
}, [nodes, edges]);
const nodeWidth: number = 200;
const nodeHeight: number = 80;
return (
<div className="trace-service-map">
<div className="text-[11px] text-gray-500 mb-2 px-1">
Service flow for this trace. Arrows show cross-service calls with count
and latency.
</div>
<div
className="relative overflow-auto rounded border border-gray-200 bg-gray-50"
style={{
minHeight: `${Math.max(layoutHeight, 200)}px`,
}}
>
<svg
width={layoutWidth}
height={layoutHeight}
className="absolute top-0 left-0"
>
{/* Render edges */}
{edges.map((edge: ServiceEdge) => {
const fromPos: { x: number; y: number } | undefined =
nodePositions.get(edge.fromServiceId);
const toPos: { x: number; y: number } | undefined =
nodePositions.get(edge.toServiceId);
if (!fromPos || !toPos) {
return null;
}
const x1: number = fromPos.x + nodeWidth;
const y1: number = fromPos.y + nodeHeight / 2;
const x2: number = toPos.x;
const y2: number = toPos.y + nodeHeight / 2;
const midX: number = (x1 + x2) / 2;
const hasError: boolean = edge.errorCount > 0;
const strokeColor: string = hasError ? "#ef4444" : "#9ca3af";
const avgDuration: number =
edge.callCount > 0
? edge.totalDurationUnixNano / edge.callCount
: 0;
const durationStr: string = SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: avgDuration,
divisibilityFactor: divisibilityFactor,
});
const edgeKey: string = `${edge.fromServiceId}->${edge.toServiceId}`;
return (
<g key={edgeKey}>
{/* Curved path */}
<path
d={`M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`}
fill="none"
stroke={strokeColor}
strokeWidth={Math.min(2 + edge.callCount * 0.5, 5)}
strokeDasharray={hasError ? "4,4" : "none"}
markerEnd="url(#arrowhead)"
/>
{/* Label */}
<text
x={midX}
y={(y1 + y2) / 2 - 8}
textAnchor="middle"
className="text-[10px] fill-gray-500"
>
{edge.callCount}x | avg {durationStr}
</text>
{hasError ? (
<text
x={midX}
y={(y1 + y2) / 2 + 6}
textAnchor="middle"
className="text-[9px] fill-red-500 font-medium"
>
{edge.errorCount} error{edge.errorCount > 1 ? "s" : ""}
</text>
) : (
<></>
)}
</g>
);
})}
{/* Arrow marker definition */}
<defs>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon
points="0 0, 8 3, 0 6"
fill="#9ca3af"
/>
</marker>
</defs>
</svg>
{/* Render nodes */}
{nodes.map((node: ServiceNode) => {
const pos: { x: number; y: number } | undefined =
nodePositions.get(node.serviceId);
if (!pos) {
return null;
}
const hasErrors: boolean = node.errorCount > 0;
return (
<div
key={node.serviceId}
className={`absolute rounded-lg border-2 bg-white shadow-sm p-3 ${
hasErrors
? "border-red-300"
: "border-gray-200"
}`}
style={{
left: `${pos.x}px`,
top: `${pos.y}px`,
width: `${nodeWidth}px`,
height: `${nodeHeight}px`,
}}
>
<div className="flex items-center space-x-2 mb-1">
<span
className="h-3 w-3 rounded-sm ring-1 ring-black/10 flex-shrink-0"
style={{ backgroundColor: node.serviceColor }}
/>
<span className="text-xs font-semibold text-gray-800 truncate">
{node.serviceName}
</span>
</div>
<div className="flex flex-wrap gap-x-3 text-[10px] text-gray-500">
<span>{node.spanCount} spans</span>
{hasErrors ? (
<span className="text-red-600 font-medium">
{node.errorCount} errors
</span>
) : (
<></>
)}
</div>
<div className="text-[10px] text-gray-400 mt-0.5">
{SpanUtil.getSpanDurationAsString({
spanDurationInUnixNano: node.totalDurationUnixNano,
divisibilityFactor: divisibilityFactor,
})}
</div>
</div>
);
})}
</div>
</div>
);
};
export default TraceServiceMap;

View File

@@ -0,0 +1,340 @@
// Critical Path Analysis for distributed traces
// Computes self-time, critical path, and bottleneck identification
export interface SpanData {
spanId: string;
parentSpanId: string | undefined;
startTimeUnixNano: number;
endTimeUnixNano: number;
durationUnixNano: number;
serviceId: string | undefined;
name: string | undefined;
}
export interface SpanSelfTime {
spanId: string;
selfTimeUnixNano: number;
childTimeUnixNano: number;
totalTimeUnixNano: number;
selfTimePercent: number;
}
export interface CriticalPathResult {
criticalPathSpanIds: string[];
totalTraceDurationUnixNano: number;
criticalPathDurationUnixNano: number;
}
export interface ServiceBreakdown {
serviceId: string;
totalDurationUnixNano: number;
selfTimeUnixNano: number;
spanCount: number;
percentOfTrace: number;
}
export default class CriticalPathUtil {
/**
* Compute self-time for each span.
* Self-time = span duration minus the time covered by direct children,
* accounting for overlapping children.
*/
public static computeSelfTimes(spans: SpanData[]): Map<string, SpanSelfTime> {
const result: Map<string, SpanSelfTime> = new Map();
// Build parent -> children index
const childrenMap: Map<string, SpanData[]> = new Map();
for (const span of spans) {
if (span.parentSpanId) {
const children: SpanData[] = childrenMap.get(span.parentSpanId) || [];
children.push(span);
childrenMap.set(span.parentSpanId, children);
}
}
for (const span of spans) {
const children: SpanData[] = childrenMap.get(span.spanId) || [];
const childTimeUnixNano: number =
CriticalPathUtil.computeMergedChildDuration(
children,
span.startTimeUnixNano,
span.endTimeUnixNano,
);
const selfTimeUnixNano: number = Math.max(
0,
span.durationUnixNano - childTimeUnixNano,
);
result.set(span.spanId, {
spanId: span.spanId,
selfTimeUnixNano,
childTimeUnixNano,
totalTimeUnixNano: span.durationUnixNano,
selfTimePercent: span.durationUnixNano > 0
? (selfTimeUnixNano / span.durationUnixNano) * 100
: 0,
});
}
return result;
}
/**
* Compute the merged duration of child spans within the parent's time window.
* Handles overlapping children by merging intervals.
*/
private static computeMergedChildDuration(
children: SpanData[],
parentStart: number,
parentEnd: number,
): number {
if (children.length === 0) {
return 0;
}
// Clamp children to parent boundaries and create intervals
const intervals: Array<{ start: number; end: number }> = children
.map((child: SpanData) => {
return {
start: Math.max(child.startTimeUnixNano, parentStart),
end: Math.min(child.endTimeUnixNano, parentEnd),
};
})
.filter((interval: { start: number; end: number }) => {
return interval.end > interval.start;
});
if (intervals.length === 0) {
return 0;
}
// Sort by start time
intervals.sort((a: { start: number; end: number }, b: { start: number; end: number }) => {
return a.start - b.start;
});
// Merge overlapping intervals
let mergedDuration: number = 0;
let currentStart: number = intervals[0]!.start;
let currentEnd: number = intervals[0]!.end;
for (let i: number = 1; i < intervals.length; i++) {
const interval: { start: number; end: number } = intervals[i]!;
if (interval.start <= currentEnd) {
// Overlapping - extend
currentEnd = Math.max(currentEnd, interval.end);
} else {
// Non-overlapping - add previous and start new
mergedDuration += currentEnd - currentStart;
currentStart = interval.start;
currentEnd = interval.end;
}
}
mergedDuration += currentEnd - currentStart;
return mergedDuration;
}
/**
* Compute the critical path through the trace.
* The critical path is the longest sequential chain of spans,
* accounting for parallelism (parallel children don't add to the critical path together).
*/
public static computeCriticalPath(spans: SpanData[]): CriticalPathResult {
if (spans.length === 0) {
return {
criticalPathSpanIds: [],
totalTraceDurationUnixNano: 0,
criticalPathDurationUnixNano: 0,
};
}
// Find total trace duration
let traceStart: number = spans[0]!.startTimeUnixNano;
let traceEnd: number = spans[0]!.endTimeUnixNano;
for (const span of spans) {
if (span.startTimeUnixNano < traceStart) {
traceStart = span.startTimeUnixNano;
}
if (span.endTimeUnixNano > traceEnd) {
traceEnd = span.endTimeUnixNano;
}
}
// Build parent -> children index
const childrenMap: Map<string, SpanData[]> = new Map();
const spanMap: Map<string, SpanData> = new Map();
const allSpanIds: Set<string> = new Set();
for (const span of spans) {
spanMap.set(span.spanId, span);
allSpanIds.add(span.spanId);
}
for (const span of spans) {
if (span.parentSpanId && allSpanIds.has(span.parentSpanId)) {
const children: SpanData[] = childrenMap.get(span.parentSpanId) || [];
children.push(span);
childrenMap.set(span.parentSpanId, children);
}
}
// Find root spans
const rootSpans: SpanData[] = spans.filter((span: SpanData) => {
return !span.parentSpanId || !allSpanIds.has(span.parentSpanId);
});
if (rootSpans.length === 0) {
return {
criticalPathSpanIds: [],
totalTraceDurationUnixNano: traceEnd - traceStart,
criticalPathDurationUnixNano: 0,
};
}
// For each span, compute the critical path weight (longest path through this span and descendants)
const criticalPathCache: Map<string, { weight: number; path: string[] }> =
new Map();
const computeWeight = (
spanId: string,
): { weight: number; path: string[] } => {
const cached: { weight: number; path: string[] } | undefined =
criticalPathCache.get(spanId);
if (cached) {
return cached;
}
const span: SpanData | undefined = spanMap.get(spanId);
if (!span) {
return { weight: 0, path: [] };
}
const children: SpanData[] = childrenMap.get(spanId) || [];
if (children.length === 0) {
const result: { weight: number; path: string[] } = {
weight: span.durationUnixNano,
path: [spanId],
};
criticalPathCache.set(spanId, result);
return result;
}
// Find the child with the longest critical path
let maxChildWeight: number = 0;
let maxChildPath: string[] = [];
for (const child of children) {
const childResult: { weight: number; path: string[] } = computeWeight(
child.spanId,
);
if (childResult.weight > maxChildWeight) {
maxChildWeight = childResult.weight;
maxChildPath = childResult.path;
}
}
// Self time contribution
const selfTimes: Map<string, SpanSelfTime> =
CriticalPathUtil.computeSelfTimes([span, ...children]);
const selfTime: SpanSelfTime | undefined = selfTimes.get(spanId);
const selfTimeValue: number = selfTime ? selfTime.selfTimeUnixNano : 0;
const result: { weight: number; path: string[] } = {
weight: selfTimeValue + maxChildWeight,
path: [spanId, ...maxChildPath],
};
criticalPathCache.set(spanId, result);
return result;
};
// Find the root with the longest critical path
let maxWeight: number = 0;
let criticalPath: string[] = [];
for (const rootSpan of rootSpans) {
const result: { weight: number; path: string[] } = computeWeight(
rootSpan.spanId,
);
if (result.weight > maxWeight) {
maxWeight = result.weight;
criticalPath = result.path;
}
}
return {
criticalPathSpanIds: criticalPath,
totalTraceDurationUnixNano: traceEnd - traceStart,
criticalPathDurationUnixNano: maxWeight,
};
}
/**
* Compute latency breakdown by service.
*/
public static computeServiceBreakdown(
spans: SpanData[],
): ServiceBreakdown[] {
const selfTimes: Map<string, SpanSelfTime> =
CriticalPathUtil.computeSelfTimes(spans);
// Find total trace duration
let traceStart: number = Number.MAX_SAFE_INTEGER;
let traceEnd: number = 0;
for (const span of spans) {
if (span.startTimeUnixNano < traceStart) {
traceStart = span.startTimeUnixNano;
}
if (span.endTimeUnixNano > traceEnd) {
traceEnd = span.endTimeUnixNano;
}
}
const totalDuration: number = traceEnd - traceStart;
// Aggregate by service
const serviceMap: Map<
string,
{ totalDuration: number; selfTime: number; spanCount: number }
> = new Map();
for (const span of spans) {
const serviceId: string = span.serviceId || "unknown";
const entry: { totalDuration: number; selfTime: number; spanCount: number } =
serviceMap.get(serviceId) || {
totalDuration: 0,
selfTime: 0,
spanCount: 0,
};
entry.totalDuration += span.durationUnixNano;
const selfTime: SpanSelfTime | undefined = selfTimes.get(span.spanId);
entry.selfTime += selfTime ? selfTime.selfTimeUnixNano : 0;
entry.spanCount += 1;
serviceMap.set(serviceId, entry);
}
const result: ServiceBreakdown[] = [];
for (const [serviceId, data] of serviceMap.entries()) {
result.push({
serviceId,
totalDurationUnixNano: data.totalDuration,
selfTimeUnixNano: data.selfTime,
spanCount: data.spanCount,
percentOfTrace:
totalDuration > 0 ? (data.selfTime / totalDuration) * 100 : 0,
});
}
// Sort by self-time descending (biggest contributors first)
result.sort(
(a: ServiceBreakdown, b: ServiceBreakdown) => {
return b.selfTimeUnixNano - a.selfTimeUnixNano;
},
);
return result;
}
}

View File

@@ -0,0 +1,157 @@
import type { AxiosResponse } from "axios";
import apiClient from "./client";
import type {
ListResponse,
MonitorItem,
MonitorStatusItem,
FeedItem,
} from "./types";
export async function fetchAllMonitors(
options: { skip?: number; limit?: number } = {},
): Promise<ListResponse<MonitorItem>> {
const { skip = 0, limit = 100 } = options;
const response: AxiosResponse = await apiClient.post(
`/api/monitor/get-list?skip=${skip}&limit=${limit}`,
{
query: {},
select: {
_id: true,
name: true,
description: true,
monitorType: true,
currentMonitorStatus: { _id: true, name: true, color: true },
disableActiveMonitoring: true,
createdAt: true,
projectId: true,
},
sort: { name: "ASC" },
},
{
headers: { "is-multi-tenant-query": "true" },
},
);
return response.data;
}
export async function fetchMonitorById(
projectId: string,
monitorId: string,
): Promise<MonitorItem> {
const response: AxiosResponse = await apiClient.post(
"/api/monitor/get-list?skip=0&limit=1",
{
query: { _id: monitorId },
select: {
_id: true,
name: true,
description: true,
monitorType: true,
currentMonitorStatus: { _id: true, name: true, color: true },
disableActiveMonitoring: true,
createdAt: true,
},
sort: {},
},
{
headers: { tenantid: projectId },
},
);
return response.data.data[0];
}
export async function fetchMonitorStatuses(
projectId: string,
): Promise<MonitorStatusItem[]> {
const response: AxiosResponse = await apiClient.post(
"/api/monitor-status/get-list?skip=0&limit=20",
{
query: {},
select: {
_id: true,
name: true,
color: true,
isOperationalState: true,
isOfflineState: true,
priority: true,
},
sort: { priority: "ASC" },
},
{
headers: { tenantid: projectId },
},
);
return response.data.data;
}
export async function fetchMonitorStatusTimeline(
projectId: string,
monitorId: string,
): Promise<FeedItem[]> {
const response: AxiosResponse = await apiClient.post(
"/api/monitor-status-timeline/get-list?skip=0&limit=50",
{
query: { monitorId },
select: {
_id: true,
feedInfoInMarkdown: true,
moreInformationInMarkdown: true,
displayColor: true,
postedAt: true,
createdAt: true,
monitorStatus: { _id: true, name: true, color: true },
startsAt: true,
},
sort: { createdAt: "DESC" },
},
{
headers: { tenantid: projectId },
},
);
return response.data.data;
}
export async function fetchMonitorFeed(
projectId: string,
monitorId: string,
): Promise<FeedItem[]> {
const response: AxiosResponse = await apiClient.post(
"/api/monitor-feed/get-list?skip=0&limit=50",
{
query: { monitorId },
select: {
_id: true,
feedInfoInMarkdown: true,
moreInformationInMarkdown: true,
displayColor: true,
postedAt: true,
createdAt: true,
},
sort: { postedAt: "DESC" },
},
{
headers: { tenantid: projectId },
},
);
return response.data.data;
}
export async function fetchAllMonitorCount(): Promise<
ListResponse<MonitorItem>
> {
const response: AxiosResponse = await apiClient.post(
"/api/monitor/get-list?skip=0&limit=1",
{
query: {},
select: {
_id: true,
},
sort: {},
},
{
headers: { "is-multi-tenant-query": "true" },
},
);
return response.data;
}

View File

@@ -278,6 +278,28 @@ export type ProjectAlertItem = WithProject<AlertItem>;
export type ProjectIncidentEpisodeItem = WithProject<IncidentEpisodeItem>;
export type ProjectAlertEpisodeItem = WithProject<AlertEpisodeItem>;
export interface MonitorItem {
_id: string;
name: string;
description?: string;
monitorType?: string;
currentMonitorStatus?: NamedEntityWithColor;
disableActiveMonitoring?: boolean;
createdAt: string;
projectId?: string;
}
export type ProjectMonitorItem = WithProject<MonitorItem>;
export interface MonitorStatusItem {
_id: string;
name: string;
color: ColorField;
isOperationalState?: boolean;
isOfflineState?: boolean;
priority?: number;
}
interface OnCallPolicyRef
extends RequiredModelFields<OnCallDutyPolicy, "name"> {
_id?: string;

View File

@@ -4,7 +4,7 @@ import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "../theme";
import GradientButton from "./GradientButton";
type EmptyIcon = "incidents" | "alerts" | "episodes" | "notes" | "default";
type EmptyIcon = "incidents" | "alerts" | "episodes" | "notes" | "monitors" | "default";
interface EmptyStateProps {
title: string;
@@ -19,6 +19,7 @@ const iconMap: Record<EmptyIcon, keyof typeof Ionicons.glyphMap> = {
alerts: "notifications-outline",
episodes: "layers-outline",
notes: "document-text-outline",
monitors: "pulse-outline",
default: "remove-circle-outline",
};

View File

@@ -0,0 +1,251 @@
import React from "react";
import { View, Text, Pressable } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "../theme";
import { rgbToHex } from "../utils/color";
import { formatRelativeTime } from "../utils/date";
import ProjectBadge from "./ProjectBadge";
import type { MonitorItem } from "../api/types";
interface MonitorCardProps {
monitor: MonitorItem;
onPress: () => void;
projectName?: string;
muted?: boolean;
}
function getMonitorTypeLabel(monitorType?: string): string {
if (!monitorType) {
return "Monitor";
}
const labels: Record<string, string> = {
Website: "Website",
API: "API",
Ping: "Ping",
IP: "IP",
Port: "Port",
DNS: "DNS",
SSLCertificate: "SSL",
Domain: "Domain",
Server: "Server",
IncomingRequest: "Incoming",
SyntheticMonitor: "Synthetic",
CustomJavaScriptCode: "Custom",
Logs: "Logs",
Metrics: "Metrics",
Traces: "Traces",
Manual: "Manual",
};
return labels[monitorType] ?? monitorType;
}
export default function MonitorCard({
monitor,
onPress,
projectName,
muted,
}: MonitorCardProps): React.JSX.Element {
const { theme } = useTheme();
const statusColor: string = monitor.currentMonitorStatus?.color
? rgbToHex(monitor.currentMonitorStatus.color)
: theme.colors.textTertiary;
const timeString: string = formatRelativeTime(monitor.createdAt);
const isDisabled: boolean = monitor.disableActiveMonitoring === true;
return (
<Pressable
style={({ pressed }: { pressed: boolean }) => {
return {
marginBottom: 12,
opacity: pressed ? 0.7 : muted ? 0.5 : 1,
};
}}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`Monitor ${monitor.name}. Status: ${monitor.currentMonitorStatus?.name ?? "unknown"}.`}
>
<View
style={{
borderRadius: 24,
overflow: "hidden",
backgroundColor: theme.colors.backgroundElevated,
borderWidth: 1,
borderColor: theme.colors.borderGlass,
shadowColor: "#000",
shadowOpacity: 0.22,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 14,
elevation: 5,
}}
>
<View
style={{
height: 3,
backgroundColor: isDisabled
? theme.colors.textTertiary
: statusColor,
}}
/>
<View style={{ padding: 16 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
{projectName ? <ProjectBadge name={projectName} /> : null}
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 9999,
backgroundColor: theme.colors.iconBackground,
}}
>
<Ionicons
name="pulse-outline"
size={10}
color={theme.colors.textSecondary}
style={{ marginRight: 4 }}
/>
<Text
style={{
fontSize: 10,
fontWeight: "600",
color: theme.colors.textSecondary,
letterSpacing: 0.3,
}}
>
{getMonitorTypeLabel(monitor.monitorType).toUpperCase()}
</Text>
</View>
</View>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Ionicons
name="time-outline"
size={12}
color={theme.colors.textTertiary}
style={{ marginRight: 4 }}
/>
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
{timeString}
</Text>
</View>
</View>
<View
style={{
flexDirection: "row",
alignItems: "flex-start",
marginTop: 2,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: "600",
flex: 1,
paddingRight: 8,
color: theme.colors.textPrimary,
letterSpacing: -0.2,
}}
numberOfLines={2}
>
{monitor.name}
</Text>
<Ionicons
name="chevron-forward"
size={16}
color={theme.colors.textTertiary}
style={{ marginTop: 2 }}
/>
</View>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
marginTop: 12,
}}
>
{isDisabled ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 9999,
backgroundColor: theme.colors.backgroundTertiary,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 9999,
marginRight: 6,
backgroundColor: theme.colors.textTertiary,
}}
/>
<Text
style={{
fontSize: 11,
fontWeight: "600",
color: theme.colors.textTertiary,
}}
>
Disabled
</Text>
</View>
) : monitor.currentMonitorStatus ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 9999,
backgroundColor: theme.colors.backgroundTertiary,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 9999,
marginRight: 6,
backgroundColor: statusColor,
}}
/>
<Text
style={{
fontSize: 11,
fontWeight: "600",
color: statusColor,
}}
>
{monitor.currentMonitorStatus.name}
</Text>
</View>
) : null}
</View>
</View>
</View>
</Pressable>
);
}

View File

@@ -4,12 +4,14 @@ import { fetchAllIncidents } from "../api/incidents";
import { fetchAllAlerts } from "../api/alerts";
import { fetchAllIncidentEpisodes } from "../api/incidentEpisodes";
import { fetchAllAlertEpisodes } from "../api/alertEpisodes";
import { fetchAllMonitorCount } from "../api/monitors";
import type {
ListResponse,
IncidentItem,
AlertItem,
IncidentEpisodeItem,
AlertEpisodeItem,
MonitorItem,
} from "../api/types";
interface UseAllProjectCountsResult {
@@ -17,6 +19,7 @@ interface UseAllProjectCountsResult {
alertCount: number;
incidentEpisodeCount: number;
alertEpisodeCount: number;
monitorCount: number;
isLoading: boolean;
refetch: () => Promise<void>;
}
@@ -78,11 +81,23 @@ export function useAllProjectCounts(): UseAllProjectCountsResult {
enabled,
});
const monitorQuery: UseQueryResult<
ListResponse<MonitorItem>,
Error
> = useQuery({
queryKey: ["monitors", "count", "all-projects"],
queryFn: () => {
return fetchAllMonitorCount();
},
enabled,
});
const isLoading: boolean =
incidentQuery.isPending ||
alertQuery.isPending ||
incidentEpisodeQuery.isPending ||
alertEpisodeQuery.isPending;
alertEpisodeQuery.isPending ||
monitorQuery.isPending;
const refetch: () => Promise<void> = async (): Promise<void> => {
await Promise.all([
@@ -90,6 +105,7 @@ export function useAllProjectCounts(): UseAllProjectCountsResult {
alertQuery.refetch(),
incidentEpisodeQuery.refetch(),
alertEpisodeQuery.refetch(),
monitorQuery.refetch(),
]);
};
@@ -98,6 +114,7 @@ export function useAllProjectCounts(): UseAllProjectCountsResult {
alertCount: alertQuery.data?.count ?? 0,
incidentEpisodeCount: incidentEpisodeQuery.data?.count ?? 0,
alertEpisodeCount: alertEpisodeQuery.data?.count ?? 0,
monitorCount: monitorQuery.data?.count ?? 0,
isLoading,
refetch,
};

View File

@@ -0,0 +1,64 @@
import { useMemo } from "react";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { useProject } from "./useProject";
import { fetchAllMonitors } from "../api/monitors";
import type {
ListResponse,
MonitorItem,
ProjectMonitorItem,
ProjectItem,
} from "../api/types";
const FETCH_LIMIT: number = 100;
interface UseAllProjectMonitorsResult {
items: ProjectMonitorItem[];
isLoading: boolean;
isError: boolean;
refetch: () => Promise<void>;
}
export function useAllProjectMonitors(): UseAllProjectMonitorsResult {
const { projectList } = useProject();
const query: UseQueryResult<ListResponse<MonitorItem>, Error> = useQuery({
queryKey: ["monitors", "all-projects"],
queryFn: () => {
return fetchAllMonitors({ skip: 0, limit: FETCH_LIMIT });
},
enabled: projectList.length > 0,
});
const projectMap: Map<string, string> = useMemo(() => {
const map: Map<string, string> = new Map();
projectList.forEach((p: ProjectItem) => {
map.set(p._id, p.name);
});
return map;
}, [projectList]);
const items: ProjectMonitorItem[] = useMemo(() => {
if (!query.data) {
return [];
}
return query.data.data.map((item: MonitorItem): ProjectMonitorItem => {
const pid: string = item.projectId ?? "";
return {
item,
projectId: pid,
projectName: projectMap.get(pid) ?? "",
};
});
}, [query.data, projectMap]);
const refetch: () => Promise<void> = async (): Promise<void> => {
await query.refetch();
};
return {
items,
isLoading: query.isPending,
isError: query.isError,
refetch,
};
}

View File

@@ -0,0 +1,32 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
fetchMonitorById,
fetchMonitorFeed,
} from "../api/monitors";
import type { MonitorItem, FeedItem } from "../api/types";
export function useMonitorDetail(
projectId: string,
monitorId: string,
): UseQueryResult<MonitorItem, Error> {
return useQuery({
queryKey: ["monitor", projectId, monitorId],
queryFn: () => {
return fetchMonitorById(projectId, monitorId);
},
enabled: Boolean(projectId) && Boolean(monitorId),
});
}
export function useMonitorFeed(
projectId: string,
monitorId: string,
): UseQueryResult<FeedItem[], Error> {
return useQuery({
queryKey: ["monitor-feed", projectId, monitorId],
queryFn: () => {
return fetchMonitorFeed(projectId, monitorId);
},
enabled: Boolean(projectId) && Boolean(monitorId),
});
}

View File

@@ -4,6 +4,7 @@ import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons";
import { MainTabParamList } from "./types";
import HomeScreen from "../screens/HomeScreen";
import MonitorsStackNavigator from "./MonitorsStackNavigator";
import IncidentsStackNavigator from "./IncidentsStackNavigator";
import AlertsStackNavigator from "./AlertsStackNavigator";
import OnCallStackNavigator from "./OnCallStackNavigator";
@@ -122,6 +123,30 @@ export default function MainTabNavigator(): React.JSX.Element {
},
}}
/>
<Tab.Screen
name="Monitors"
component={MonitorsStackNavigator}
options={{
headerShown: false,
tabBarIcon: ({
color,
focused,
}: {
color: string;
focused: boolean;
}) => {
return (
<TabIcon
name="pulse-outline"
focusedName="pulse"
color={color}
focused={focused}
accentColor={theme.colors.actionPrimary}
/>
);
},
}}
/>
<Tab.Screen
name="Incidents"
component={IncidentsStackNavigator}

View File

@@ -0,0 +1,49 @@
import React from "react";
import { Platform } from "react-native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { useTheme } from "../theme";
import MonitorsScreen from "../screens/MonitorsScreen";
import MonitorDetailScreen from "../screens/MonitorDetailScreen";
import type { MonitorsStackParamList } from "./types";
const Stack: ReturnType<
typeof createNativeStackNavigator<MonitorsStackParamList>
> = createNativeStackNavigator<MonitorsStackParamList>();
export default function MonitorsStackNavigator(): React.JSX.Element {
const { theme } = useTheme();
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: theme.colors.backgroundPrimary,
},
headerTintColor: theme.colors.textPrimary,
headerShadowVisible: false,
...(Platform.OS === "ios"
? {
headerLargeTitle: true,
headerLargeStyle: {
backgroundColor: theme.colors.backgroundPrimary,
},
}
: {}),
}}
>
<Stack.Screen
name="MonitorsList"
component={MonitorsScreen}
options={{ title: "Monitors" }}
/>
<Stack.Screen
name="MonitorDetail"
component={MonitorDetailScreen}
options={{
title: "Monitor",
...(Platform.OS === "ios" ? { headerLargeTitle: false } : {}),
}}
/>
</Stack.Navigator>
);
}

View File

@@ -6,6 +6,7 @@ export type AuthStackParamList = {
export type MainTabParamList = {
Home: undefined;
Monitors: undefined;
Incidents: undefined;
Alerts: undefined;
OnCall: undefined;
@@ -37,3 +38,8 @@ export type AlertsStackParamList = {
AlertDetail: { alertId: string; projectId: string };
AlertEpisodeDetail: { episodeId: string; projectId: string };
};
export type MonitorsStackParamList = {
MonitorsList: undefined;
MonitorDetail: { monitorId: string; projectId: string };
};

View File

@@ -166,6 +166,7 @@ export default function HomeScreen(): React.JSX.Element {
alertCount,
incidentEpisodeCount,
alertEpisodeCount,
monitorCount,
isLoading: anyLoading,
refetch,
} = useAllProjectCounts();
@@ -622,6 +623,35 @@ export default function HomeScreen(): React.JSX.Element {
</Pressable>
</View>
<View>
<Text
style={{
fontSize: 12,
fontWeight: "600",
textTransform: "uppercase",
marginBottom: 8,
color: theme.colors.textSecondary,
letterSpacing: 1,
}}
>
Monitors
</Text>
<View style={{ flexDirection: "row" }}>
<View style={{ flex: 1 }}>
<StatCard
count={monitorCount}
label="Total Monitors"
accentColor={theme.colors.oncallActive}
iconName="pulse-outline"
isLoading={anyLoading}
onPress={() => {
return navigation.navigate("Monitors");
}}
/>
</View>
</View>
</View>
<View>
<Text
style={{

View File

@@ -0,0 +1,346 @@
import React, { useCallback } from "react";
import { View, Text, ScrollView, RefreshControl } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import { useTheme } from "../theme";
import { useMonitorDetail, useMonitorFeed } from "../hooks/useMonitorDetail";
import { rgbToHex } from "../utils/color";
import { formatDateTime } from "../utils/date";
import { toPlainText } from "../utils/text";
import type { MonitorsStackParamList } from "../navigation/types";
import FeedTimeline from "../components/FeedTimeline";
import SkeletonCard from "../components/SkeletonCard";
import SectionHeader from "../components/SectionHeader";
import MarkdownContent from "../components/MarkdownContent";
type Props = NativeStackScreenProps<MonitorsStackParamList, "MonitorDetail">;
function getMonitorTypeLabel(monitorType?: string): string {
if (!monitorType) {
return "Monitor";
}
const labels: Record<string, string> = {
Website: "Website",
API: "API",
Ping: "Ping",
IP: "IP",
Port: "Port",
DNS: "DNS",
SSLCertificate: "SSL Certificate",
Domain: "Domain",
Server: "Server",
IncomingRequest: "Incoming Request",
SyntheticMonitor: "Synthetic Monitor",
CustomJavaScriptCode: "Custom JavaScript",
Logs: "Logs",
Metrics: "Metrics",
Traces: "Traces",
Manual: "Manual",
};
return labels[monitorType] ?? monitorType;
}
export default function MonitorDetailScreen({
route,
}: Props): React.JSX.Element {
const { monitorId, projectId } = route.params;
const { theme } = useTheme();
const {
data: monitor,
isLoading,
refetch: refetchMonitor,
} = useMonitorDetail(projectId, monitorId);
const { data: feed, refetch: refetchFeed } = useMonitorFeed(
projectId,
monitorId,
);
const onRefresh: () => Promise<void> = useCallback(async () => {
await Promise.all([refetchMonitor(), refetchFeed()]);
}, [refetchMonitor, refetchFeed]);
if (isLoading) {
return (
<View
style={{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }}
>
<SkeletonCard variant="detail" />
</View>
);
}
if (!monitor) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.backgroundPrimary,
}}
>
<Text style={{ fontSize: 15, color: theme.colors.textSecondary }}>
Monitor not found.
</Text>
</View>
);
}
const statusColor: string = monitor.currentMonitorStatus?.color
? rgbToHex(monitor.currentMonitorStatus.color)
: theme.colors.textTertiary;
const isDisabled: boolean = monitor.disableActiveMonitoring === true;
const descriptionText: string = toPlainText(monitor.description);
return (
<ScrollView
style={{ backgroundColor: theme.colors.backgroundPrimary }}
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={onRefresh}
tintColor={theme.colors.actionPrimary}
/>
}
>
{/* Header card */}
<View
style={{
borderRadius: 24,
overflow: "hidden",
marginBottom: 20,
backgroundColor: theme.colors.backgroundElevated,
borderWidth: 1,
borderColor: theme.colors.borderGlass,
shadowColor: "#000",
shadowOpacity: 0.28,
shadowOffset: { width: 0, height: 10 },
shadowRadius: 18,
elevation: 7,
}}
>
<LinearGradient
colors={[statusColor + "26", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
position: "absolute",
top: -50,
left: -10,
right: -10,
height: 190,
}}
/>
<View
style={{
height: 3,
backgroundColor: isDisabled
? theme.colors.textTertiary
: statusColor,
}}
/>
<View style={{ padding: 20 }}>
<Text
style={{
fontSize: 13,
fontWeight: "600",
marginBottom: 8,
color: theme.colors.textSecondary,
}}
>
{getMonitorTypeLabel(monitor.monitorType)}
</Text>
<Text
style={{
fontSize: 24,
fontWeight: "bold",
color: theme.colors.textPrimary,
letterSpacing: -0.6,
}}
>
{monitor.name}
</Text>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
marginTop: 12,
}}
>
{isDisabled ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
backgroundColor: theme.colors.backgroundTertiary,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 9999,
marginRight: 6,
backgroundColor: theme.colors.textTertiary,
}}
/>
<Text
style={{
fontSize: 12,
fontWeight: "600",
color: theme.colors.textTertiary,
}}
>
Disabled
</Text>
</View>
) : monitor.currentMonitorStatus ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
backgroundColor: statusColor + "14",
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 9999,
marginRight: 6,
backgroundColor: statusColor,
}}
/>
<Text
style={{
fontSize: 12,
fontWeight: "600",
color: statusColor,
}}
>
{monitor.currentMonitorStatus.name}
</Text>
</View>
) : null}
</View>
</View>
</View>
{/* Description */}
{descriptionText ? (
<View style={{ marginBottom: 24 }}>
<SectionHeader title="Description" iconName="document-text-outline" />
<View
style={{
borderRadius: 16,
padding: 16,
backgroundColor: theme.colors.backgroundElevated,
borderWidth: 1,
borderColor: theme.colors.borderGlass,
}}
>
<MarkdownContent content={descriptionText} />
</View>
</View>
) : null}
{/* Details */}
<View style={{ marginBottom: 24 }}>
<SectionHeader title="Details" iconName="information-circle-outline" />
<View
style={{
borderRadius: 16,
overflow: "hidden",
backgroundColor: theme.colors.backgroundElevated,
borderWidth: 1,
borderColor: theme.colors.borderGlass,
}}
>
<View style={{ padding: 16 }}>
<View style={{ flexDirection: "row", marginBottom: 12 }}>
<Text
style={{
fontSize: 13,
width: 90,
color: theme.colors.textTertiary,
}}
>
Type
</Text>
<Text
style={{
fontSize: 13,
color: theme.colors.textPrimary,
}}
>
{getMonitorTypeLabel(monitor.monitorType)}
</Text>
</View>
<View style={{ flexDirection: "row", marginBottom: 12 }}>
<Text
style={{
fontSize: 13,
width: 90,
color: theme.colors.textTertiary,
}}
>
Status
</Text>
<Text
style={{
fontSize: 13,
color: isDisabled ? theme.colors.textTertiary : statusColor,
}}
>
{isDisabled
? "Disabled"
: (monitor.currentMonitorStatus?.name ?? "Unknown")}
</Text>
</View>
<View style={{ flexDirection: "row" }}>
<Text
style={{
fontSize: 13,
width: 90,
color: theme.colors.textTertiary,
}}
>
Created
</Text>
<Text
style={{
fontSize: 13,
color: theme.colors.textPrimary,
}}
>
{formatDateTime(monitor.createdAt)}
</Text>
</View>
</View>
</View>
</View>
{/* Activity Feed */}
{feed && feed.length > 0 ? (
<View style={{ marginBottom: 24 }}>
<SectionHeader title="Activity Feed" iconName="list-outline" />
<FeedTimeline feed={feed} />
</View>
) : null}
</ScrollView>
);
}

View File

@@ -0,0 +1,275 @@
import React, { useState, useCallback, useMemo } from "react";
import {
View,
SectionList,
ScrollView,
RefreshControl,
Text,
SectionListRenderItemInfo,
DefaultSectionT,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTheme } from "../theme";
import { useAllProjectMonitors } from "../hooks/useAllProjectMonitors";
import { useHaptics } from "../hooks/useHaptics";
import MonitorCard from "../components/MonitorCard";
import SkeletonCard from "../components/SkeletonCard";
import EmptyState from "../components/EmptyState";
import type { MonitorsStackParamList } from "../navigation/types";
import type { ProjectMonitorItem } from "../api/types";
const PAGE_SIZE: number = 20;
type NavProp = NativeStackNavigationProp<
MonitorsStackParamList,
"MonitorsList"
>;
interface MonitorSection {
title: string;
isActive: boolean;
data: ProjectMonitorItem[];
}
function SectionHeader({
title,
count,
isActive,
}: {
title: string;
count: number;
isActive: boolean;
}): React.JSX.Element {
const { theme } = useTheme();
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingBottom: 8,
paddingTop: 4,
backgroundColor: theme.colors.backgroundPrimary,
}}
>
<Ionicons
name={isActive ? "alert-circle" : "checkmark-circle"}
size={13}
color={
isActive ? theme.colors.severityCritical : theme.colors.textTertiary
}
style={{ marginRight: 6 }}
/>
<Text
style={{
fontSize: 12,
fontWeight: "600",
textTransform: "uppercase",
color: isActive
? theme.colors.textPrimary
: theme.colors.textTertiary,
letterSpacing: 0.6,
}}
>
{title}
</Text>
<View
style={{
marginLeft: 8,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: isActive
? theme.colors.severityCritical + "18"
: theme.colors.backgroundTertiary,
}}
>
<Text
style={{
fontSize: 11,
fontWeight: "bold",
color: isActive
? theme.colors.severityCritical
: theme.colors.textTertiary,
}}
>
{count}
</Text>
</View>
</View>
);
}
export default function MonitorsScreen(): React.JSX.Element {
const { theme } = useTheme();
const navigation: NavProp = useNavigation<NavProp>();
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const {
items: allMonitors,
isLoading,
isError,
refetch,
} = useAllProjectMonitors();
const { lightImpact } = useHaptics();
const monitorSections: MonitorSection[] = useMemo(() => {
const issues: ProjectMonitorItem[] = [];
const operational: ProjectMonitorItem[] = [];
for (const wrapped of allMonitors) {
const statusName: string =
wrapped.item.currentMonitorStatus?.name?.toLowerCase() ?? "";
const isDisabled: boolean =
wrapped.item.disableActiveMonitoring === true;
if (
isDisabled ||
statusName === "offline" ||
statusName === "degraded" ||
statusName === "down"
) {
issues.push(wrapped);
} else {
operational.push(wrapped);
}
}
const sections: MonitorSection[] = [];
if (issues.length > 0) {
sections.push({
title: "Issues",
isActive: true,
data: issues.slice(0, visibleCount),
});
}
if (operational.length > 0) {
sections.push({
title: "Operational",
isActive: false,
data: operational.slice(0, visibleCount),
});
}
return sections;
}, [allMonitors, visibleCount]);
const totalCount: number = allMonitors.length;
const onRefresh: () => Promise<void> = useCallback(async () => {
lightImpact();
setVisibleCount(PAGE_SIZE);
await refetch();
}, [refetch, lightImpact]);
const loadMore: () => void = useCallback(() => {
if (visibleCount < totalCount) {
setVisibleCount((prev: number) => {
return prev + PAGE_SIZE;
});
}
}, [visibleCount, totalCount]);
const handlePress: (wrapped: ProjectMonitorItem) => void = useCallback(
(wrapped: ProjectMonitorItem) => {
navigation.navigate("MonitorDetail", {
monitorId: wrapped.item._id,
projectId: wrapped.projectId,
});
},
[navigation],
);
if (isLoading && allMonitors.length === 0) {
return (
<View
style={{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }}
>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View style={{ padding: 16 }}>
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</View>
</ScrollView>
</View>
);
}
if (isError) {
return (
<View
style={{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }}
>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<EmptyState
title="Something went wrong"
subtitle="Failed to load monitors. Pull to refresh or try again."
icon="monitors"
actionLabel="Retry"
onAction={() => {
return refetch();
}}
/>
</ScrollView>
</View>
);
}
return (
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }}>
<SectionList
sections={monitorSections}
style={{ flex: 1 }}
contentInsetAdjustmentBehavior="automatic"
keyExtractor={(wrapped: ProjectMonitorItem) => {
return `${wrapped.projectId}-${wrapped.item._id}`;
}}
contentContainerStyle={
monitorSections.length === 0 ? { flex: 1 } : { padding: 16 }
}
renderSectionHeader={(params: {
section: DefaultSectionT & MonitorSection;
}) => {
return (
<SectionHeader
title={params.section.title}
count={params.section.data.length}
isActive={params.section.isActive}
/>
);
}}
renderItem={({
item: wrapped,
section,
}: SectionListRenderItemInfo<
ProjectMonitorItem,
DefaultSectionT & MonitorSection
>) => {
const isOperational: boolean = !section.isActive;
return (
<MonitorCard
monitor={wrapped.item}
projectName={wrapped.projectName}
muted={isOperational}
onPress={() => {
return handlePress(wrapped);
}}
/>
);
}}
ListEmptyComponent={
<EmptyState
title="No monitors"
subtitle="Monitors from your projects will appear here."
icon="monitors"
/>
}
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl refreshing={false} onRefresh={onRefresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
/>
</View>
);
}