mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
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:
@@ -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={() => {}}
|
||||
/>
|
||||
|
||||
388
App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx
Normal file
388
App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx
Normal 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;
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
340
Common/Utils/Traces/CriticalPath.ts
Normal file
340
Common/Utils/Traces/CriticalPath.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
157
MobileApp/src/api/monitors.ts
Normal file
157
MobileApp/src/api/monitors.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
251
MobileApp/src/components/MonitorCard.tsx
Normal file
251
MobileApp/src/components/MonitorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
64
MobileApp/src/hooks/useAllProjectMonitors.ts
Normal file
64
MobileApp/src/hooks/useAllProjectMonitors.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
32
MobileApp/src/hooks/useMonitorDetail.ts
Normal file
32
MobileApp/src/hooks/useMonitorDetail.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
49
MobileApp/src/navigation/MonitorsStackNavigator.tsx
Normal file
49
MobileApp/src/navigation/MonitorsStackNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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={{
|
||||
|
||||
346
MobileApp/src/screens/MonitorDetailScreen.tsx
Normal file
346
MobileApp/src/screens/MonitorDetailScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
MobileApp/src/screens/MonitorsScreen.tsx
Normal file
275
MobileApp/src/screens/MonitorsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user