diff --git a/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx b/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx index 50920f1821..31e7c5e5e5 100644 --- a/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx @@ -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 = ( @@ -76,7 +83,9 @@ const SpanViewer: FunctionComponent = ( 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 = ( ); }; + // 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 = + CriticalPathUtil.computeSelfTimes(spanDataList); + return selfTimes.get(span.spanId!) || null; + }, [span, props.allTraceSpans]); + + const getLinksContentElement: GetReactElementFunction = (): ReactElement => { + if (!span) { + return ; + } + + const links: Array | undefined = span.links; + + if (!links || links.length === 0) { + return ; + } + + return ( +
+ {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 ( +
+
+
+ Link {index + 1} +
+ + View Trace + +
+
+
+
Trace ID
+ + {link.traceId} + +
+
+
Span ID
+ + {link.spanId} + +
+
+ {link.attributes && + Object.keys(link.attributes).length > 0 ? ( +
+
+ Attributes +
+ +
+ ) : ( + <> + )} +
+ ); + })} +
+ ); + }; + const getBasicInfo: GetReactElementFunction = (): ReactElement => { if (!span) { return ; @@ -664,6 +776,33 @@ const SpanViewer: FunctionComponent = ( ); }, }, + ...(selfTimeInfo + ? [ + { + key: "selfTime" as keyof Span, + title: "Self Time", + description: + "Time spent in this span excluding child span durations.", + fieldType: FieldType.Element, + getElement: () => { + return ( +
+ + {SpanUtil.getSpanDurationAsString({ + divisibilityFactor: props.divisibilityFactor, + spanDurationInUnixNano: + selfTimeInfo.selfTimeUnixNano, + })} + + + {selfTimeInfo.selfTimePercent.toFixed(1)}% of span + +
+ ); + }, + }, + ] + : []), { key: "kind", title: "Span Kind", @@ -710,6 +849,12 @@ const SpanViewer: FunctionComponent = ( return event.name === SpanEventType.Exception.toLowerCase(); }).length, }, + { + name: "Links", + children: getLinksContentElement(), + countBadge: span?.links?.length || 0, + tabType: TabType.Info, + }, ]} onTabChange={() => {}} /> diff --git a/App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx b/App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx new file mode 100644 index 0000000000..c7ce469d1b --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx @@ -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 = ( + props: FlameGraphProps, +): ReactElement => { + const { spans, telemetryServices, onSpanSelect, selectedSpanId } = props; + + const [hoveredSpanId, setHoveredSpanId] = React.useState(null); + const [focusedSpanId, setFocusedSpanId] = React.useState(null); + const containerRef: React.RefObject = React.useRef(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 = 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 = new Map(); + const childrenMap: Map = new Map(); + const allSpanIds: Set = 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 ( +
+ No spans to display +
+ ); + } + + 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 ( + +
{ + 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 ? ( +
+ {node.span.name} +
+ ) : ( + <> + )} +
+ {node.children.map((child: FlameGraphNode) => { + return renderNode(child); + })} +
+ ); + }; + + 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 ( +
+ {/* Controls */} +
+
+ Click a span to view details. Double-click to zoom into a subtree. +
+ {focusedSpanId ? ( + + ) : ( + <> + )} +
+ + {/* Flame graph */} +
+ {rootNodes.map((root: FlameGraphNode) => { + return renderNode(root); + })} +
+ + {/* Tooltip */} + {hoveredNode ? ( +
+
+ {hoveredNode.span.name} +
+
+
+ Service: + {hoveredNode.serviceName} +
+
+ Duration: + {SpanUtil.getSpanDurationAsString({ + spanDurationInUnixNano: hoveredNode.durationUnixNano, + divisibilityFactor: + SpanUtil.getDivisibilityFactor(totalDuration), + })} +
+
+ Self Time: + {SpanUtil.getSpanDurationAsString({ + spanDurationInUnixNano: hoveredNode.selfTimeUnixNano, + divisibilityFactor: + SpanUtil.getDivisibilityFactor(totalDuration), + })} +
+
+
+ ) : ( + <> + )} +
+ ); +}; + +export default FlameGraph; diff --git a/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx b/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx index f413c59149..ba96baa5ef 100644 --- a/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx @@ -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 = ( // UI State Enhancements const [showErrorsOnly, setShowErrorsOnly] = React.useState(false); + const [viewMode, setViewMode] = React.useState( + TraceViewMode.Waterfall, + ); + const [spanSearchText, setSpanSearchText] = React.useState(""); + const [showCriticalPath, setShowCriticalPath] = + React.useState(false); const [traceId, setTraceId] = React.useState(null); @@ -654,7 +673,7 @@ const TraceExplorer: FunctionComponent = ( } }, [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 = ( : 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 = ( }), ); - 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 = ( }; setGanttChart(ganttChart); - }, [displaySpans, selectedSpans, highlightSpanIds]); + }, [displaySpans, selectedSpans, highlightSpanIds, criticalPathResult]); if (isLoading && spans.length === 0) { return ; @@ -1086,56 +1190,136 @@ const TraceExplorer: FunctionComponent = ( - {/* Toolbar */} -
-
- - + ); + })} +
+ + {/* Search Bar */} +
+ ) => { + 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 ? ( +
+ + {searchMatchCount} of {spans.length} + + +
) : ( <> )} - +
-
-
-
- Error + {/* Toolbar Row 2: Filters & Controls */} +
+
+ + + + {/* Critical Path Toggle */} + {viewMode === TraceViewMode.Waterfall ? ( + + ) : ( + <> + )}
-
-
- OK -
-
-
- Other + +
+
+
+ Error +
+
+
+ OK +
+
+
+ Other +
@@ -1236,13 +1420,127 @@ const TraceExplorer: FunctionComponent = ( <> )} -
- {ganttChart ? ( - - ) : ( -
- + {/* Service Latency Breakdown */} + {serviceBreakdown.length > 1 ? ( +
+
+ Latency Breakdown by Service
+
+ {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 ( +
+ + + {serviceName} + +
+
+
+ + {SpanUtil.getSpanDurationAsString({ + spanDurationInUnixNano: + breakdown.selfTimeUnixNano, + divisibilityFactor: divisibilityFactor, + })}{" "} + ({percent.toFixed(1)}%) + +
+ ); + })} +
+
+ ) : ( + <> + )} + + {/* Critical Path Info */} + {showCriticalPath && criticalPathResult ? ( +
+ Critical Path:{" "} + {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) +
+ ) : ( + <> + )} + + {/* Main Visualization */} +
+ {viewMode === TraceViewMode.Waterfall ? ( + <> + {ganttChart ? ( + + ) : ( +
+ +
+ )} + + ) : viewMode === TraceViewMode.FlameGraph ? ( +
+ { + setSelectedSpans([spanId]); + }} + selectedSpanId={ + selectedSpans.length > 0 + ? selectedSpans[0] + : undefined + } + /> +
+ ) : viewMode === TraceViewMode.ServiceMap ? ( +
+ +
+ ) : ( + <> )}
@@ -1296,6 +1594,7 @@ const TraceExplorer: FunctionComponent = ( })! } divisibilityFactor={divisibilityFactor} + allTraceSpans={spans} /> ) : ( diff --git a/App/FeatureSet/Dashboard/src/Components/Traces/TraceServiceMap.tsx b/App/FeatureSet/Dashboard/src/Components/Traces/TraceServiceMap.tsx new file mode 100644 index 0000000000..427be6ef4f --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Traces/TraceServiceMap.tsx @@ -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 = ( + props: TraceServiceMapProps, +): ReactElement => { + const { spans, telemetryServices } = props; + + // Build nodes and edges from spans + const { nodes, edges } = React.useMemo(() => { + const nodeMap: Map = new Map(); + const edgeMap: Map = new Map(); + const spanServiceMap: Map = 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 ( +
+ No services found in this trace +
+ ); + } + + // 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 = new Map(); + const inDegree: Map = 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 = 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 = 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 ( +
+
+ Service flow for this trace. Arrows show cross-service calls with count + and latency. +
+
+ + {/* 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 ( + + {/* Curved path */} + + {/* Label */} + + {edge.callCount}x | avg {durationStr} + + {hasError ? ( + + {edge.errorCount} error{edge.errorCount > 1 ? "s" : ""} + + ) : ( + <> + )} + + ); + })} + {/* Arrow marker definition */} + + + + + + + + {/* 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 ( +
+
+ + + {node.serviceName} + +
+
+ {node.spanCount} spans + {hasErrors ? ( + + {node.errorCount} errors + + ) : ( + <> + )} +
+
+ {SpanUtil.getSpanDurationAsString({ + spanDurationInUnixNano: node.totalDurationUnixNano, + divisibilityFactor: divisibilityFactor, + })} +
+
+ ); + })} +
+
+ ); +}; + +export default TraceServiceMap; diff --git a/Common/Utils/Traces/CriticalPath.ts b/Common/Utils/Traces/CriticalPath.ts new file mode 100644 index 0000000000..847cf194b6 --- /dev/null +++ b/Common/Utils/Traces/CriticalPath.ts @@ -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 { + const result: Map = new Map(); + + // Build parent -> children index + const childrenMap: Map = 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 = new Map(); + const spanMap: Map = new Map(); + const allSpanIds: Set = 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 = + 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 = + 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 = + 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; + } +} diff --git a/MobileApp/src/api/monitors.ts b/MobileApp/src/api/monitors.ts new file mode 100644 index 0000000000..5671a43816 --- /dev/null +++ b/MobileApp/src/api/monitors.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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 +> { + 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; +} diff --git a/MobileApp/src/api/types.ts b/MobileApp/src/api/types.ts index fb1e930df5..a63f8c1096 100644 --- a/MobileApp/src/api/types.ts +++ b/MobileApp/src/api/types.ts @@ -278,6 +278,28 @@ export type ProjectAlertItem = WithProject; export type ProjectIncidentEpisodeItem = WithProject; export type ProjectAlertEpisodeItem = WithProject; +export interface MonitorItem { + _id: string; + name: string; + description?: string; + monitorType?: string; + currentMonitorStatus?: NamedEntityWithColor; + disableActiveMonitoring?: boolean; + createdAt: string; + projectId?: string; +} + +export type ProjectMonitorItem = WithProject; + +export interface MonitorStatusItem { + _id: string; + name: string; + color: ColorField; + isOperationalState?: boolean; + isOfflineState?: boolean; + priority?: number; +} + interface OnCallPolicyRef extends RequiredModelFields { _id?: string; diff --git a/MobileApp/src/components/EmptyState.tsx b/MobileApp/src/components/EmptyState.tsx index 54fbef3116..0840d1d461 100644 --- a/MobileApp/src/components/EmptyState.tsx +++ b/MobileApp/src/components/EmptyState.tsx @@ -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 = { alerts: "notifications-outline", episodes: "layers-outline", notes: "document-text-outline", + monitors: "pulse-outline", default: "remove-circle-outline", }; diff --git a/MobileApp/src/components/MonitorCard.tsx b/MobileApp/src/components/MonitorCard.tsx new file mode 100644 index 0000000000..e5b848a893 --- /dev/null +++ b/MobileApp/src/components/MonitorCard.tsx @@ -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 = { + 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 ( + { + return { + marginBottom: 12, + opacity: pressed ? 0.7 : muted ? 0.5 : 1, + }; + }} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={`Monitor ${monitor.name}. Status: ${monitor.currentMonitorStatus?.name ?? "unknown"}.`} + > + + + + + + {projectName ? : null} + + + + {getMonitorTypeLabel(monitor.monitorType).toUpperCase()} + + + + + + + {timeString} + + + + + + + {monitor.name} + + + + + + {isDisabled ? ( + + + + Disabled + + + ) : monitor.currentMonitorStatus ? ( + + + + {monitor.currentMonitorStatus.name} + + + ) : null} + + + + + ); +} diff --git a/MobileApp/src/hooks/useAllProjectCounts.ts b/MobileApp/src/hooks/useAllProjectCounts.ts index f370cdce35..d529ef40d8 100644 --- a/MobileApp/src/hooks/useAllProjectCounts.ts +++ b/MobileApp/src/hooks/useAllProjectCounts.ts @@ -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; } @@ -78,11 +81,23 @@ export function useAllProjectCounts(): UseAllProjectCountsResult { enabled, }); + const monitorQuery: UseQueryResult< + ListResponse, + 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 = async (): Promise => { 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, }; diff --git a/MobileApp/src/hooks/useAllProjectMonitors.ts b/MobileApp/src/hooks/useAllProjectMonitors.ts new file mode 100644 index 0000000000..b79556b0ef --- /dev/null +++ b/MobileApp/src/hooks/useAllProjectMonitors.ts @@ -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; +} + +export function useAllProjectMonitors(): UseAllProjectMonitorsResult { + const { projectList } = useProject(); + + const query: UseQueryResult, Error> = useQuery({ + queryKey: ["monitors", "all-projects"], + queryFn: () => { + return fetchAllMonitors({ skip: 0, limit: FETCH_LIMIT }); + }, + enabled: projectList.length > 0, + }); + + const projectMap: Map = useMemo(() => { + const map: Map = 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 = async (): Promise => { + await query.refetch(); + }; + + return { + items, + isLoading: query.isPending, + isError: query.isError, + refetch, + }; +} diff --git a/MobileApp/src/hooks/useMonitorDetail.ts b/MobileApp/src/hooks/useMonitorDetail.ts new file mode 100644 index 0000000000..17863ce7d9 --- /dev/null +++ b/MobileApp/src/hooks/useMonitorDetail.ts @@ -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 { + return useQuery({ + queryKey: ["monitor", projectId, monitorId], + queryFn: () => { + return fetchMonitorById(projectId, monitorId); + }, + enabled: Boolean(projectId) && Boolean(monitorId), + }); +} + +export function useMonitorFeed( + projectId: string, + monitorId: string, +): UseQueryResult { + return useQuery({ + queryKey: ["monitor-feed", projectId, monitorId], + queryFn: () => { + return fetchMonitorFeed(projectId, monitorId); + }, + enabled: Boolean(projectId) && Boolean(monitorId), + }); +} diff --git a/MobileApp/src/navigation/MainTabNavigator.tsx b/MobileApp/src/navigation/MainTabNavigator.tsx index f57ce1118b..461d0b794d 100644 --- a/MobileApp/src/navigation/MainTabNavigator.tsx +++ b/MobileApp/src/navigation/MainTabNavigator.tsx @@ -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 { }, }} /> + { + return ( + + ); + }, + }} + /> +> = createNativeStackNavigator(); + +export default function MonitorsStackNavigator(): React.JSX.Element { + const { theme } = useTheme(); + + return ( + + + + + ); +} diff --git a/MobileApp/src/navigation/types.ts b/MobileApp/src/navigation/types.ts index 18c433d8c0..28f25617f8 100644 --- a/MobileApp/src/navigation/types.ts +++ b/MobileApp/src/navigation/types.ts @@ -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 }; +}; diff --git a/MobileApp/src/screens/HomeScreen.tsx b/MobileApp/src/screens/HomeScreen.tsx index b37e80edd6..174be12349 100644 --- a/MobileApp/src/screens/HomeScreen.tsx +++ b/MobileApp/src/screens/HomeScreen.tsx @@ -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 { + + + Monitors + + + + { + return navigation.navigate("Monitors"); + }} + /> + + + + ; + +function getMonitorTypeLabel(monitorType?: string): string { + if (!monitorType) { + return "Monitor"; + } + const labels: Record = { + 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 = useCallback(async () => { + await Promise.all([refetchMonitor(), refetchFeed()]); + }, [refetchMonitor, refetchFeed]); + + if (isLoading) { + return ( + + + + ); + } + + if (!monitor) { + return ( + + + Monitor not found. + + + ); + } + + 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 ( + + } + > + {/* Header card */} + + + + + + {getMonitorTypeLabel(monitor.monitorType)} + + + + {monitor.name} + + + + {isDisabled ? ( + + + + Disabled + + + ) : monitor.currentMonitorStatus ? ( + + + + {monitor.currentMonitorStatus.name} + + + ) : null} + + + + + {/* Description */} + {descriptionText ? ( + + + + + + + ) : null} + + {/* Details */} + + + + + + + Type + + + {getMonitorTypeLabel(monitor.monitorType)} + + + + + + Status + + + {isDisabled + ? "Disabled" + : (monitor.currentMonitorStatus?.name ?? "Unknown")} + + + + + + Created + + + {formatDateTime(monitor.createdAt)} + + + + + + + {/* Activity Feed */} + {feed && feed.length > 0 ? ( + + + + + ) : null} + + ); +} diff --git a/MobileApp/src/screens/MonitorsScreen.tsx b/MobileApp/src/screens/MonitorsScreen.tsx new file mode 100644 index 0000000000..98db1df6c7 --- /dev/null +++ b/MobileApp/src/screens/MonitorsScreen.tsx @@ -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 ( + + + + {title} + + + + {count} + + + + ); +} + +export default function MonitorsScreen(): React.JSX.Element { + const { theme } = useTheme(); + const navigation: NavProp = useNavigation(); + + 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 = 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 ( + + + + + + + + + + ); + } + + if (isError) { + return ( + + + { + return refetch(); + }} + /> + + + ); + } + + return ( + + { + return `${wrapped.projectId}-${wrapped.item._id}`; + }} + contentContainerStyle={ + monitorSections.length === 0 ? { flex: 1 } : { padding: 16 } + } + renderSectionHeader={(params: { + section: DefaultSectionT & MonitorSection; + }) => { + return ( + + ); + }} + renderItem={({ + item: wrapped, + section, + }: SectionListRenderItemInfo< + ProjectMonitorItem, + DefaultSectionT & MonitorSection + >) => { + const isOperational: boolean = !section.isActive; + return ( + { + return handlePress(wrapped); + }} + /> + ); + }} + ListEmptyComponent={ + + } + stickySectionHeadersEnabled={false} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.5} + /> + + ); +}