From fdd3d91016cedbf661f8e7c7bee62d60a4d53c41 Mon Sep 17 00:00:00 2001 From: Sai Nikhil Date: Fri, 13 Mar 2026 13:58:58 +0530 Subject: [PATCH] fix: service graph v2 UI (#10889) --- web/src/plugins/traces/ServiceGraph.spec.ts | 16 +- web/src/plugins/traces/ServiceGraph.vue | 746 ++++----- .../traces/ServiceGraphEdgeSidePanel.vue | 1355 +++++++++++++++++ ...anel.vue => ServiceGraphNodeSidePanel.vue} | 2 +- .../traces/ServiceGraphSidePanel.spec.ts | 2 +- web/src/utils/traces/convertTraceData.spec.ts | 2 +- web/src/utils/traces/convertTraceData.ts | 751 +++++---- 7 files changed, 2224 insertions(+), 650 deletions(-) create mode 100644 web/src/plugins/traces/ServiceGraphEdgeSidePanel.vue rename web/src/plugins/traces/{ServiceGraphSidePanel.vue => ServiceGraphNodeSidePanel.vue} (99%) diff --git a/web/src/plugins/traces/ServiceGraph.spec.ts b/web/src/plugins/traces/ServiceGraph.spec.ts index caa63762f5..d18e1b8891 100644 --- a/web/src/plugins/traces/ServiceGraph.spec.ts +++ b/web/src/plugins/traces/ServiceGraph.spec.ts @@ -1585,9 +1585,9 @@ describe("ServiceGraph.vue - Cache Invalidation & Data Refresh", () => { const series = wrapper.vm.chartData.options.series[0]; expect(series.left).toBe("3%"); - expect(series.right).toBe("3%"); - expect(series.top).toBe("1%"); - expect(series.bottom).toBe("1%"); + expect(series.right).toBe("20%"); + expect(series.top).toBe("2%"); + expect(series.bottom).toBe("2%"); }); it("should set tree bounds for vertical layout", async () => { @@ -1599,10 +1599,10 @@ describe("ServiceGraph.vue - Cache Invalidation & Data Refresh", () => { await nextTick(); const series = wrapper.vm.chartData.options.series[0]; - expect(series.left).toBe("1%"); - expect(series.right).toBe("1%"); - expect(series.top).toBe("3%"); - expect(series.bottom).toBe("3%"); + expect(series.left).toBe("2%"); + expect(series.right).toBe("2%"); + expect(series.top).toBe("8%"); + expect(series.bottom).toBe("8%"); }); it("should disable built-in ECharts tooltip for tree mode", async () => { @@ -1624,7 +1624,7 @@ describe("ServiceGraph.vue - Cache Invalidation & Data Refresh", () => { await nextTick(); const series = wrapper.vm.chartData.options.series[0]; - expect(series.label.position).toBe("inside"); + expect(series.label.position).toBe("right"); }); }); }); diff --git a/web/src/plugins/traces/ServiceGraph.vue b/web/src/plugins/traces/ServiceGraph.vue index df902f84aa..636dfad6a1 100644 --- a/web/src/plugins/traces/ServiceGraph.vue +++ b/web/src/plugins/traces/ServiceGraph.vue @@ -104,7 +104,79 @@ -
+
+ + + +
+ + + + +
Border Color | Error Rate
+
+
+
+
+
Healthy
+
< 1%
+
+
+
+
+
+
Degraded
+
1 – 5%
+
+
+
+
+
+
Warning
+
5 – 10%
+
+
+
+
+
+
Critical
+
> 10%
+
+
+
+
+
+
+
@@ -145,7 +217,7 @@
-
+
- + + + +
@@ -223,7 +303,8 @@ import serviceGraphService from "@/services/service_graph"; import AppTabs from "@/components/common/AppTabs.vue"; import ChartRenderer from "@/components/dashboards/panels/ChartRenderer.vue"; import DateTime from "@/components/DateTime.vue"; -import ServiceGraphSidePanel from "./ServiceGraphSidePanel.vue"; +import ServiceGraphSidePanel from "./ServiceGraphNodeSidePanel.vue"; +import ServiceGraphEdgeSidePanel from "./ServiceGraphEdgeSidePanel.vue"; import { convertServiceGraphToTree, convertServiceGraphToNetwork, @@ -248,6 +329,7 @@ export default defineComponent({ ChartRenderer, DateTime, ServiceGraphSidePanel, + ServiceGraphEdgeSidePanel, }, emits: [], setup(props, { emit }) { @@ -262,12 +344,13 @@ export default defineComponent({ const showSettings = ref(false); const lastUpdated = ref(""); - // Side panel state + // Node side panel state const selectedNode = ref(null); const showSidePanel = ref(false); - // Map of "from->to" -> { p50_avg, p95_avg, p99_avg } — populated on edge hover - const edgeBaselines = ref>(new Map()); + // Edge side panel state + const selectedEdge = ref(null); + const showEdgeSidePanel = ref(false); // Persist visualization type in localStorage const storedVisualizationType = localStorage.getItem( @@ -291,6 +374,7 @@ export default defineComponent({ } const layoutType = ref(storedLayoutType || defaultLayoutType); const chartRendererRef = ref(null); + const graphContainerRef = ref(null); const searchFilter = ref(""); @@ -353,7 +437,6 @@ export default defineComponent({ if (visualizationType.value === "graph" && lastChartOptions.value && chartKey.value === lastChartOptions.value.key && - edgeBaselines.value.size === (lastChartOptions.value.baselineCount ?? 0) && !hasActiveFilters) { return { options: lastChartOptions.value.data.options, @@ -369,15 +452,15 @@ export default defineComponent({ filteredGraphData.value, layoutType.value, $q.dark.isActive, - edgeBaselines.value, ) : convertServiceGraphToNetwork( filteredGraphData.value, layoutType.value, - new Map(), // Empty position cache to allow free movement - $q.dark.isActive, // Pass dark mode state - undefined, // Don't pass selected node - we'll use dispatchAction instead - edgeBaselines.value, + new Map(), + $q.dark.isActive, + undefined, + graphContainerRef.value?.clientWidth || 1200, + graphContainerRef.value?.clientHeight || 700, ); // Cache the options for graph view @@ -386,7 +469,6 @@ export default defineComponent({ lastChartOptions.value = { key: chartKey.value, data: newOptions, - baselineCount: edgeBaselines.value.size, }; } else if (hasActiveFilters) { // Clear cache when filtering to ensure fresh render on filter removal @@ -483,17 +565,61 @@ export default defineComponent({ } ); + // --- Graph view: flowing edge animation on node hover --- + // + // ECharts' emphasis.lineStyle only fires on direct edge hover, NOT when a + // connected node is hovered. We handle it explicitly: on node mouseover we + // call setOption to switch adjacent edges to dashed (→ CSS animation fires), + // on mouseout we restore them to solid. + // + // We replicate the same edge deduplication used in convertServiceGraphToNetwork + // so the links array index order matches ECharts' internal data array. + const updateEdgeStylesForHover = (hoveredNodeId: string | null) => { + const chart = chartRendererRef.value?.chart; + if (!chart) return; + + // Tree view: emphasis.focus:'relative' in the series config handles dimming natively. + // No manual dispatch needed — ECharts triggers it on mouseover automatically. + if (visualizationType.value !== 'graph') return; + + const rawEdges: any[] = filteredGraphData.value.edges || []; + + // Deduplicate exactly as convertServiceGraphToNetwork does + const edgeMap = new Map(); + rawEdges.forEach((edge: any) => { + const key = `${edge.from}|||${edge.to}`; + if (!edgeMap.has(key) || + (edge.total_requests || 0) > (edgeMap.get(key).total_requests || 0)) { + edgeMap.set(key, edge); + } + }); + + const updatedLinks = Array.from(edgeMap.values()).map((edge: any) => { + const isAdj = hoveredNodeId !== null && + (edge.from === hoveredNodeId || edge.to === hoveredNodeId); + return { + source: edge.from, + target: edge.to, + // Preserve tooltip suppression so ECharts merge doesn't re-enable native edge tooltip + tooltip: { show: false }, + lineStyle: { + width: 4, + type: isAdj ? 'dashed' : 'solid', + opacity: hoveredNodeId !== null ? (isAdj ? 1 : 0.15) : 0.6, + }, + }; + }); + + chart.setOption( + { series: [{ links: updatedLinks }] }, + { notMerge: false, lazyUpdate: false }, + ); + }; + // --- Tree edge tooltip: show node tooltip when hovering edge lines --- let edgeTooltipCleanup: (() => void) | null = null; let pendingTooltipSetup: ReturnType | null = null; - // Shared across ALL setupTreeEdgeTooltips invocations so multiple registrations - // (due to chart re-renders) share one debounce timer and one in-flight guard. - const edgeTrendCache = new Map(); - const fetchingEdges = new Set(); - let sharedTrendFetchTimer: ReturnType | null = null; - let sharedCurrentHoverEdgeKey: string | null = null; - let sharedTooltipChart: any = null; const setupTreeEdgeTooltips = (chart: any) => { const zr = chart.getZr(); @@ -533,227 +659,6 @@ export default defineComponent({ } }); - // "Show trend" button click — load history on demand - tooltipEl.addEventListener('click', (e) => { - const btn = (e.target as HTMLElement).closest('[data-show-trend]') as HTMLElement | null; - if (!btn) return; - loadAndShowTrend(btn.dataset.parent ?? '', btn.dataset.child ?? ''); - }); - - const getOrgId = () => store.state.selectedOrganization?.identifier ?? ''; - // Last known mouse position — used to reposition after async chart render - let lastMouseX = 0, lastMouseY = 0; - - // Grid geometry constants — must match the grid config passed to ECharts - const CHART_W = 280, CHART_H = 160; - // right: 38 leaves room for P99/P95/P50 labels outside the plot area - const GRID = { left: 42, right: 38, top: 10, bottom: 22 }; - - const buildSparkOptions = (trendData: any) => { - const points = trendData?.data_points ?? []; - const isDark = $q.dark.isActive; - const toMs = (ns: number) => ns / 1_000_000; - const last = points.length - 1; - const sf = '-apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif'; - - // Use purple-blue-teal gradient to avoid red=danger, green=good semantic confusion - const COLORS = { p99: '#8B5CF6', p95: '#3B82F6', p50: '#06B6D4' }; - - const hexArea = (hex: string, alpha: number) => { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return { - type: 'linear' as const, x: 0, y: 0, x2: 0, y2: 1, - colorStops: [ - { offset: 0, color: `rgba(${r},${g},${b},${alpha})` }, - { offset: 1, color: `rgba(${r},${g},${b},0)` }, - ], - }; - }; - - // Latency formatter for y-axis labels and hover tooltip - const fmtMs = (ms: number) => - ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` - : ms >= 1 ? `${Math.round(ms)}ms` - : `${(ms * 1000).toFixed(0)}μs`; - - // Explicit y-range so manual label math matches ECharts exactly - const allY = points.flatMap((p: any) => [ - toMs(p.p99_latency_ns), toMs(p.p95_latency_ns), toMs(p.p50_latency_ns), - ]); - const rawMin = allY.length ? Math.min(...allY) : 0; - const rawMax = allY.length ? Math.max(...allY) : 1; - const pad = (rawMax - rawMin) * 0.18 || rawMax * 0.18 || 1; - const yMin = Math.max(0, rawMin - pad); - const yMax = rawMax + pad; - - // Right-side identity labels aligned to the LAST data point of each series - const plotH = CHART_H - GRID.top - GRID.bottom; - const yToPixel = (val: number) => GRID.top + plotH * (1 - (val - yMin) / (yMax - yMin)); - const labelX = CHART_W - GRID.right + 6; // just past the right edge of the plot area - - const rightLabels = points.length - ? [ - { name: 'P99', val: toMs(points[last].p99_latency_ns), color: COLORS.p99 }, - { name: 'P95', val: toMs(points[last].p95_latency_ns), color: COLORS.p95 }, - { name: 'P50', val: toMs(points[last].p50_latency_ns), color: COLORS.p50 }, - ].map(({ name, val, color }) => ({ - type: 'text', - x: labelX, - y: yToPixel(val), - style: { text: name, fill: color, fontSize: 10, fontWeight: 700, fontFamily: sf, textBaseline: 'middle' }, - z: 10, - })) - : []; - - const makeSeries = (name: string, color: string, getter: (p: any) => number) => ({ - name, type: 'line', smooth: 0.5, - data: points.map((p: any, i: number) => ({ - value: [Math.round(p.timestamp / 1000), toMs(getter(p))], - symbol: i === last ? 'circle' : 'none', - symbolSize: i === last ? 6 : 0, - })), - lineStyle: { color, width: 1.5, cap: 'round' }, - itemStyle: { color }, - areaStyle: { color: hexArea(color, 0.15), origin: 'auto' }, - label: { show: false }, - }); - - // Calculate time range based on actual data span - let xAxisMin: number | undefined = undefined; - let xAxisMax: number | undefined = undefined; - let displaySpanMs = 0; - - if (points.length > 0) { - // Timestamps are in microseconds, convert to milliseconds - const firstPointTime = Math.round(points[0].timestamp / 1000); - const lastPointTime = Math.round(points[last].timestamp / 1000); - const actualSpanMs = lastPointTime - firstPointTime; - - // Calculate ceiling - round up to next hour - const spanHours = actualSpanMs / (60 * 60 * 1000); - const ceilingHours = Math.ceil(spanHours); - const ceilingSpanMs = ceilingHours * 60 * 60 * 1000; - - // Set x-axis: end at latest point, start at (latest - ceiling span) - xAxisMax = lastPointTime; - xAxisMin = lastPointTime - ceilingSpanMs; - displaySpanMs = ceilingSpanMs; - } - - // Corner time labels - based on ceiling span - let startLabel = ''; - if (displaySpanMs > 0) { - const spanHours = displaySpanMs / (60 * 60 * 1000); - const spanDays = displaySpanMs / (24 * 60 * 60 * 1000); - - if (spanDays >= 1) { - startLabel = `${Math.round(spanDays)}d ago`; - } else if (spanHours >= 1) { - startLabel = `${Math.round(spanHours)}h ago`; - } else { - const spanMinutes = displaySpanMs / (60 * 1000); - startLabel = `${Math.round(spanMinutes)}m ago`; - } - } - const cornerColor = isDark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.52)'; - const endLabel = 'latest →'; - const cornerLabels = [ - { type: 'text', left: GRID.left, bottom: 3, style: { text: startLabel, fill: cornerColor, fontSize: 9, fontFamily: sf } }, - { type: 'text', right: GRID.right, bottom: 3, style: { text: endLabel, fill: cornerColor, fontSize: 9, fontFamily: sf } }, - ]; - - return { - backgroundColor: 'transparent', - animation: false, - grid: { left: GRID.left, right: GRID.right, top: GRID.top, bottom: GRID.bottom }, - tooltip: { - show: true, - trigger: 'axis', - axisPointer: { - type: 'line', - lineStyle: { color: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.15)', type: 'dashed', width: 1 }, - }, - backgroundColor: isDark ? 'rgba(28,28,32,0.95)' : 'rgba(255,255,255,0.96)', - borderColor: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.07)', - borderRadius: 8, - padding: [6, 10], - textStyle: { color: isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.8)', fontSize: 11, fontFamily: sf }, - formatter: (params: any[]) => { - const t = new Date(params[0].value[0]); - const ts = t.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); - const lines = params.map((p: any) => - `${p.seriesName}: ${fmtMs(p.value[1])}` - ); - return `
${ts}
${lines.join('
')}`; - }, - }, - xAxis: { - type: 'time', - min: xAxisMin, - max: xAxisMax, - axisLine: { show: false }, axisTick: { show: false }, - splitLine: { show: false }, axisLabel: { show: false }, - }, - yAxis: { - type: 'value', - min: yMin, max: yMax, - axisLine: { show: false }, axisTick: { show: false }, - axisLabel: { - show: true, - inside: false, - color: isDark ? 'rgba(255,255,255,0.58)' : 'rgba(0,0,0,0.52)', - fontSize: 9, - fontFamily: sf, - margin: 4, - formatter: fmtMs, - }, - splitNumber: 3, - splitLine: { - show: true, - lineStyle: { color: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', width: 1 }, - }, - }, - graphic: [...rightLabels, ...cornerLabels], - series: [ - makeSeries('P99', COLORS.p99, (p) => p.p99_latency_ns), - makeSeries('P95', COLORS.p95, (p) => p.p95_latency_ns), - makeSeries('P50', COLORS.p50, (p) => p.p50_latency_ns), - ], - _yMin: yMin, - _yMax: yMax, - } as any; - }; - - const renderTrendChart = (trendData: any) => { - tooltipEl.style.padding = '0'; - tooltipEl.style.width = `${CHART_W}px`; - tooltipEl.style.height = `${CHART_H}px`; - // Allow pointer events so ECharts can receive hover for its built-in tooltip - tooltipEl.style.pointerEvents = 'auto'; - - if (!sharedTooltipChart) { - tooltipEl.innerHTML = ''; - sharedTooltipChart = echarts.init(tooltipEl, undefined, { renderer: 'canvas', width: CHART_W, height: CHART_H, devicePixelRatio: window.devicePixelRatio || 1 }); - } - - const opts = buildSparkOptions(trendData); - delete opts._yMin; - delete opts._yMax; - sharedTooltipChart.setOption(opts, true); - }; - - const showLoadingTooltip = () => { - if (sharedTooltipChart) { sharedTooltipChart.dispose(); sharedTooltipChart = null; } - tooltipEl.style.pointerEvents = 'none'; // loading state doesn't need hover - tooltipEl.style.width = `${CHART_W}px`; - tooltipEl.style.height = `${CHART_H}px`; - tooltipEl.style.padding = '0'; - const loaderColor = $q.dark.isActive ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'; - tooltipEl.innerHTML = `
Loading…
`; - }; - // Cached edge data: bezier shapes + parent/child names let edgesGroupEl: any = null; let bezierEdges: Array<{ @@ -903,8 +808,7 @@ export default defineComponent({ }; const resetToTextTooltip = () => { - if (sharedTooltipChart) { sharedTooltipChart.dispose(); sharedTooltipChart = null; } - tooltipEl.style.pointerEvents = 'none'; // node tooltips don't need hover + tooltipEl.style.pointerEvents = 'none'; tooltipEl.style.width = ''; tooltipEl.style.height = ''; tooltipEl.style.padding = '9px 13px'; @@ -918,36 +822,8 @@ export default defineComponent({ const showNodeTooltip = (mouseX: number, mouseY: number, nodeName: string) => { resetToTextTooltip(); - const edges = graphData.value?.edges || []; - // Find incoming edge for this node (direction-aware, matches tree label) - const incomingBezier = bezierEdges.find(b => b.childName === nodeName); - if (incomingBezier) { - const edge = findIncomingEdgeForNode(nodeName, incomingBezier.parentName, edges); - if (edge) { - const total = edge.total_requests || 0; - const failed = edge.failed_requests || 0; - const errRate = edge.error_rate ?? (total > 0 ? (failed / total) * 100 : 0); - tooltipEl.innerHTML = generateNodeTooltipContent(nodeName, total, failed, errRate); - positionTooltip(mouseX, mouseY); - return; - } - } - - // Root node or no incoming edge: sum outgoing edges - const metrics = calculateRootNodeMetrics(nodeName, edges); - if (metrics.requests > 0) { - tooltipEl.innerHTML = generateNodeTooltipContent( - nodeName, - metrics.requests, - metrics.errors, - metrics.errorRate - ); - positionTooltip(mouseX, mouseY); - return; - } - - // Fallback: use node aggregate + // Always use node-level data for the tooltip — same source as border color const nodes = graphData.value?.nodes || []; const node = nodes.find((n: any) => (n.label || n.id) === nodeName); if (!node) { tooltipEl.style.display = 'none'; return; } @@ -965,89 +841,21 @@ export default defineComponent({ if (!edge) { tooltipEl.style.display = 'none'; return; } resetToTextTooltip(); - // edge tooltip needs pointer-events:auto for the button to be clickable - tooltipEl.style.pointerEvents = 'auto'; const total = edge.total_requests || 0; const failed = edge.failed_requests || 0; const errRate = edge.error_rate ?? (total > 0 ? (failed / total) * 100 : 0); - const statsHtml = generateEdgeTooltipContent(total, failed, errRate, edge.p50_latency_ns, edge.p95_latency_ns, edge.p99_latency_ns); - const isDark = $q.dark.isActive; - const btnStyle = [ - 'margin-top:6px;padding-top:6px;border-top:1px solid', - isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.07)', - ].join(' '); - const btnElStyle = [ - 'cursor:pointer;background:transparent;border:1px solid', - isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.15)', - ';border-radius:5px;padding:2px 8px;font-size:10px;font-family:inherit;', - 'color:', isDark ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.55)', - ';letter-spacing:0.02em;', - ].join(''); - - tooltipEl.innerHTML = ` - ${statsHtml} -
- -
`; + tooltipEl.innerHTML = generateEdgeTooltipContent(total, failed, errRate, edge.p50_latency_ns, edge.p95_latency_ns, edge.p99_latency_ns); positionTooltip(mouseX, mouseY); }; - const loadAndShowTrend = async (parentName: string, childName: string) => { - const edgeKey = `${parentName}->${childName}`; - sharedCurrentHoverEdgeKey = edgeKey; - - if (edgeTrendCache.has(edgeKey)) { - renderTrendChart(edgeTrendCache.get(edgeKey)); - positionTooltip(lastMouseX, lastMouseY); - return; - } - if (fetchingEdges.has(edgeKey)) return; - - showLoadingTooltip(); - positionTooltip(lastMouseX, lastMouseY); - fetchingEdges.add(edgeKey); - try { - const orgId = getOrgId(); - if (!orgId) return; - const res = await serviceGraphService.getEdgeHistory(orgId, { - client_service: parentName, - server_service: childName, - }); - const data = res.data; - edgeTrendCache.set(edgeKey, data); - // Baselines come from the topology response, not from history - if (sharedCurrentHoverEdgeKey === edgeKey) { - renderTrendChart(data); - positionTooltip(lastMouseX, lastMouseY); - } - } catch { - // On error revert to stats view - if (sharedCurrentHoverEdgeKey === edgeKey) { - showStatsTooltip(lastMouseX, lastMouseY, parentName, childName); - } - } finally { - fetchingEdges.delete(edgeKey); - } - }; - const showEdgeTooltip = (mouseX: number, mouseY: number, parentName: string, childName: string) => { - const edgeKey = `${parentName}->${childName}`; - sharedCurrentHoverEdgeKey = edgeKey; - lastMouseX = mouseX; - lastMouseY = mouseY; - // Always show stats first on hover. User explicitly clicks "Show 24h trend" - // to see the chart. loadAndShowTrend uses cache when available (no re-fetch). showStatsTooltip(mouseX, mouseY, parentName, childName); }; const hideTooltip = () => { tooltipEl.style.display = 'none'; tooltipEl.style.pointerEvents = 'none'; - sharedCurrentHoverEdgeKey = null; - if (sharedTrendFetchTimer) { clearTimeout(sharedTrendFetchTimer); sharedTrendFetchTimer = null; } }; let activeKey: string | null = null; // tracks current tooltip target @@ -1118,8 +926,42 @@ export default defineComponent({ hideTooltip(); }; + // Tree mode: click on an edge opens the edge side panel + const onZrClick = (e: any) => { + if (!edgesGroupEl || bezierEdges.length === 0) return; + if (!edgesGroupEl.transformCoordToLocal) return; + + const [mx, my] = edgesGroupEl.transformCoordToLocal(e.offsetX, e.offsetY); + const [ox] = edgesGroupEl.transformCoordToLocal(0, 0); + const [ox1] = edgesGroupEl.transformCoordToLocal(1, 0); + const pxToLayout = Math.abs(ox1 - ox) || 1; + const hitThreshold = HIT_PIXELS * pxToLayout; + const nodeRadius = 42 * pxToLayout; + + // Ignore clicks near nodes (those are handled by ECharts) + for (const np of nodePositions) { + if (Math.hypot(np.x - mx, np.y - my) < nodeRadius) return; + } + + let bestDist = Infinity; + let bestIdx = -1; + for (let i = 0; i < bezierEdges.length; i++) { + const d = pointToBezierDistance(mx, my, bezierEdges[i].shape); + if (d < bestDist) { bestDist = d; bestIdx = i; } + } + + if (bestIdx >= 0 && bestDist < hitThreshold) { + const { parentName, childName } = bezierEdges[bestIdx]; + const edgeData = graphData.value.edges.find( + (ed: any) => ed.from === parentName && ed.to === childName + ); + if (edgeData) openEdgePanel(edgeData); + } + }; + zr.on('mousemove', onMouseMove); zr.on('globalout', onGlobalOut); + zr.on('click', onZrClick); // Graph mode: ECharts fires mouseover/mouseout for graph series edges. // These share the same showEdgeTooltip/hideTooltip as tree mode. @@ -1147,19 +989,32 @@ export default defineComponent({ } }; + // Node hover → animate adjacent edges + const onNodeMouseover = (params: any) => { + if (params.dataType !== 'node') return; + updateEdgeStylesForHover(params.data?.id ?? params.name ?? null); + }; + const onNodeMouseout = (params: any) => { + if (params.dataType !== 'node') return; + updateEdgeStylesForHover(null); + }; + chart.on('mouseover', onEChartsEdgeMouseover); chart.on('mouseout', onEChartsEdgeMouseout); + chart.on('mouseover', onNodeMouseover); + chart.on('mouseout', onNodeMouseout); return () => { zr.off('mousemove', onMouseMove); zr.off('globalout', onGlobalOut); + zr.off('click', onZrClick); chart.off('mouseover', onEChartsEdgeMouseover); chart.off('mouseout', onEChartsEdgeMouseout); + chart.off('mouseover', onNodeMouseover); + chart.off('mouseout', onNodeMouseout); chart.off('finished', debouncedBuild); if (buildTimer) clearTimeout(buildTimer); if (hideTimer) clearTimeout(hideTimer); - if (sharedTrendFetchTimer) { clearTimeout(sharedTrendFetchTimer); sharedTrendFetchTimer = null; } - if (sharedTooltipChart) { sharedTooltipChart.dispose(); sharedTooltipChart = null; } tooltipEl.remove(); }; }; @@ -1269,9 +1124,6 @@ export default defineComponent({ // Clear cache to force chart regeneration with fresh data lastChartOptions.value = null; chartKey.value++; - // Invalidate edge history cache so trends are re-fetched on next button click - edgeTrendCache.clear(); - try { const orgId = store.state.selectedOrganization.identifier; @@ -1339,19 +1191,6 @@ export default defineComponent({ edges, }; - // Populate edgeBaselines from topology prev-slot data for edge coloring - const newBaselines = new Map(); - for (const edge of edges) { - if (edge.baseline_p95_latency_ns != null) { - newBaselines.set(`${edge.from}->${edge.to}`, { - p50_avg: edge.baseline_p50_latency_ns ?? 0, - p95_avg: edge.baseline_p95_latency_ns ?? 0, - p99_avg: edge.baseline_p99_latency_ns ?? 0, - }); - } - } - edgeBaselines.value = newBaselines; - // Calculate stats const totalRequests = graphData.value.edges.reduce( (sum: number, e: any) => sum + e.total_requests, @@ -1583,21 +1422,36 @@ export default defineComponent({ }; // Side Panel Handlers + const openEdgePanel = (edgeData: any) => { + // Close node panel + showSidePanel.value = false; + selectedNode.value = null; + + if (selectedEdge.value?.from === edgeData.from && selectedEdge.value?.to === edgeData.to) { + // Toggle: clicking same edge closes it + showEdgeSidePanel.value = false; + selectedEdge.value = null; + } else { + selectedEdge.value = edgeData; + showEdgeSidePanel.value = true; + } + }; + const handleNodeClick = (params: any) => { // Check if it's an edge click (for graph visualization) if (params.dataType === 'edge' && params.data) { - // Close node panel when opening edge panel - showSidePanel.value = false; - selectedNode.value = null; - - // Find the full edge data from graphData const edgeData = graphData.value.edges.find( (e: any) => e.from === params.data.source && e.to === params.data.target ); - // Edge clicks in graph view: no action (tooltip handles it on hover) + if (edgeData) openEdgePanel(edgeData); + return; } // Check if it's a node click (for graph visualization) else if (params.dataType === 'node' && params.data) { + // Close edge panel when opening node panel + showEdgeSidePanel.value = false; + selectedEdge.value = null; + // Check if clicking the same node - if so, close the panel if (selectedNode.value && selectedNode.value.id === params.data.id) { showSidePanel.value = false; @@ -1639,6 +1493,13 @@ export default defineComponent({ }, 300); }; + const handleCloseEdgeSidePanel = () => { + showEdgeSidePanel.value = false; + setTimeout(() => { + selectedEdge.value = null; + }, 300); + }; + onMounted(async () => { await loadTraceStreams(); loadServiceGraph(); @@ -1672,11 +1533,15 @@ export default defineComponent({ setVisualizationType, updateTimeRange, resetSettings, - // Side panel + // Node side panel selectedNode, showSidePanel, handleNodeClick, handleCloseSidePanel, + // Edge side panel + selectedEdge, + showEdgeSidePanel, + handleCloseEdgeSidePanel, }; }, }); @@ -1704,6 +1569,134 @@ export default defineComponent({ overflow: hidden; } +.sg-info-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 10; + transition: transform 0.2s ease; +} + +.sg-info-btn:hover { + transform: scale(1.1); +} + +:global(.sg-legend-tooltip), +:global(.sg-legend-tooltip.q-tooltip) { + padding: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: inherit !important; +} + +:global(.sg-legend) { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 14px 16px; + min-width: 260px; + box-shadow: 0 4px 16px rgba(0,0,0,0.12); + color: #374151; + font-size: 12px; +} + +:global(.body--dark .sg-legend) { + background: #1f2937; + border-color: #374151; + color: #e5e7eb; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} + +:global(.sg-legend-title) { + font-weight: 700; + font-size: 12px; + margin-bottom: 10px; + color: inherit; +} + +:global(.sg-legend-subtitle) { + font-weight: 400; + opacity: 0.55; +} + +:global(.sg-legend-divider) { + border-top: 1px solid #e5e7eb; + margin: 12px 0; +} + +:global(.body--dark .sg-legend-divider) { + border-color: #374151; +} + +:global(.sg-legend-sizes) { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 0 6px; +} + +:global(.sg-legend-size-item) { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +:global(.sg-legend-circle) { + border-radius: 50%; + border-width: 3px; + border-style: solid; + background: transparent; + flex-shrink: 0; +} + +:global(.sg-legend-size-dots) { + opacity: 0.35; + font-size: 16px; + letter-spacing: 2px; + margin-bottom: 18px; +} + +:global(.sg-legend-label) { + font-size: 11px; + opacity: 0.6; +} + +:global(.sg-legend-color-row) { + display: flex; + gap: 12px; +} + +:global(.sg-legend-color-item) { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + flex: 1; +} + +:global(.sg-legend-dot) { + width: 20px; + height: 20px; + border-radius: 50%; + border-width: 3px; + border-style: solid; + background: transparent; +} + +:global(.sg-legend-color-label) { + font-size: 11px; + font-weight: 600; + text-align: center; + color: inherit; +} + +:global(.sg-legend-color-value) { + font-size: 10px; + opacity: 0.55; + text-align: center; +} + .service-graph-container { position: relative; overflow: hidden; @@ -1727,3 +1720,22 @@ code { font-size: 0.9em; } + + + diff --git a/web/src/plugins/traces/ServiceGraphEdgeSidePanel.vue b/web/src/plugins/traces/ServiceGraphEdgeSidePanel.vue new file mode 100644 index 0000000000..0729d91cea --- /dev/null +++ b/web/src/plugins/traces/ServiceGraphEdgeSidePanel.vue @@ -0,0 +1,1355 @@ + + + + + + + + + diff --git a/web/src/plugins/traces/ServiceGraphSidePanel.vue b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue similarity index 99% rename from web/src/plugins/traces/ServiceGraphSidePanel.vue rename to web/src/plugins/traces/ServiceGraphNodeSidePanel.vue index 65838c385f..92a7aa3e9c 100644 --- a/web/src/plugins/traces/ServiceGraphSidePanel.vue +++ b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue @@ -286,7 +286,7 @@ const TelemetryCorrelationDashboard = defineAsyncComponent( ); export default defineComponent({ - name: 'ServiceGraphSidePanel', + name: 'ServiceGraphNodeSidePanel', components: { TelemetryCorrelationDashboard, }, diff --git a/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts b/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts index 78c647f6fe..464cee7055 100644 --- a/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts +++ b/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts @@ -18,7 +18,7 @@ import { mount, VueWrapper, flushPromises } from "@vue/test-utils"; import { installQuasar } from "@/test/unit/helpers"; import { Notify } from "quasar"; import { nextTick } from "vue"; -import ServiceGraphSidePanel from "./ServiceGraphSidePanel.vue"; +import ServiceGraphSidePanel from "./ServiceGraphNodeSidePanel.vue"; installQuasar({ plugins: [Notify], diff --git a/web/src/utils/traces/convertTraceData.spec.ts b/web/src/utils/traces/convertTraceData.spec.ts index ec1d3367c0..5e8a27405c 100644 --- a/web/src/utils/traces/convertTraceData.spec.ts +++ b/web/src/utils/traces/convertTraceData.spec.ts @@ -407,7 +407,7 @@ describe("convertTraceData", () => { expect(result.options.series[0].roam).toBe(true); expect(result.options.series[0].label.show).toBe(true); - expect(result.options.series[0].draggable).toBe(true); + expect(result.options.series[0].draggable).toBe(false); expect(result.options.series[0].focusNodeAdjacency).toBe(true); }); diff --git a/web/src/utils/traces/convertTraceData.ts b/web/src/utils/traces/convertTraceData.ts index 807a113c85..fd76e69096 100644 --- a/web/src/utils/traces/convertTraceData.ts +++ b/web/src/utils/traces/convertTraceData.ts @@ -1,5 +1,4 @@ import { toZonedTime } from "date-fns-tz"; -import { forceSimulation, forceManyBody, forceLink, forceCenter, forceCollide, forceX, forceY } from "d3-force"; export const convertTraceData = (props: any, timezone: string) => { const options: any = { backgroundColor: "transparent", @@ -203,7 +202,6 @@ export const convertServiceGraphToTree = ( graphData: { nodes: any[]; edges: any[] }, layoutType: string = 'horizontal', isDarkMode: boolean = true, - edgeBaselines?: Map ) => { // Build adjacency map for edges const edgesMap = new Map(); @@ -227,54 +225,16 @@ export const convertServiceGraphToTree = ( const nodesWithIncoming = new Set(graphData.edges.map((e: any) => e.to)); const rootNodes = graphData.nodes.filter((n: any) => !nodesWithIncoming.has(n.id)); - // --- Compute adaptive baselines from the full dataset --- - const percentile = (sorted: number[], p: number): number => { - if (sorted.length === 0) return 0; - const idx = (p / 100) * (sorted.length - 1); - const lo = Math.floor(idx); - const hi = Math.ceil(idx); - return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo); - }; - - // Node error rates (from node-level data) - const allErrorRates = graphData.nodes - .map((n: any) => n.error_rate ?? (n.requests > 0 ? (n.errors / n.requests) * 100 : 0)) - .filter((r: number) => r > 0) - .sort((a: number, b: number) => a - b); - - // Edge P95 latencies in ms - const allLatenciesMs = graphData.edges - .map((e: any) => (e.p95_latency_ns ?? 0) / 1000000) - .filter((l: number) => l > 0) - .sort((a: number, b: number) => a - b); - - const errP50 = percentile(allErrorRates, 50); - const errP75 = percentile(allErrorRates, 75); - const errP90 = percentile(allErrorRates, 90); - - const latP50 = percentile(allLatenciesMs, 50); - const latP75 = percentile(allLatenciesMs, 75); - const latP90 = percentile(allLatenciesMs, 90); - const green = isDarkMode ? "#10b981" : "#52c41a"; - // Node color: error rate relative to the tree's error baseline + // Node color: same absolute thresholds as Graph View so color matches tooltip error rate const getNodeColor = (errRate: number): string => { - if (allErrorRates.length === 0 || errRate === 0) return green; - if (errRate >= errP90) return "#f5222d"; // Red — top 10% - if (errRate >= errP75) return "#faad14"; // Orange — top 25% - if (errRate >= errP50) return "#ffc069"; // Light orange — above median + if (errRate > 10) return isDarkMode ? "#ef4444" : "#f5222d"; // Red — critical + if (errRate > 5) return isDarkMode ? "#f97316" : "#fa8c16"; // Orange — warning + if (errRate > 1) return isDarkMode ? "#fbbf24" : "#faad14"; // Yellow — degraded return green; }; - // Edge color: latency relative to the tree's latency baseline - const getEdgeColor = (p95Ms: number): string => { - if (allLatenciesMs.length === 0 || p95Ms === 0) return isDarkMode ? "#4a5568" : "#d9d9d9"; - if (p95Ms >= latP90) return "#f5222d"; // Red — top 10% - if (p95Ms >= latP75) return "#ff7875"; // Light red — top 25% - if (p95Ms >= latP50) return "#ffc069"; // Light orange — above median - return green; // Below median — healthy - }; // Track all visited nodes across all trees to find orphaned components const globalVisited = new Set(); @@ -314,33 +274,20 @@ export const convertServiceGraphToTree = ( const nodeErrorRate = node.error_rate ?? (node.requests > 0 ? (node.errors / node.requests) * 100 : 0); const borderColor = getNodeColor(nodeErrorRate); - // Edge line: colored by this edge's P95 latency vs 24h baseline (if available), else percentile-relative - const edgeP95Ms = incomingEdge ? (incomingEdge.p95_latency_ns || 0) / 1000000 : 0; - let edgeColor: string; - if (!incomingEdge) { - edgeColor = isDarkMode ? "#4a5568" : "#d9d9d9"; - } else { - const bKey = `${incomingEdge.from}->${incomingEdge.to}`; - const baseline = edgeBaselines?.get(bKey); - if (baseline && baseline.p95_avg > 0) { - const ratio = (incomingEdge.p95_latency_ns || 0) / baseline.p95_avg; - edgeColor = ratio > 2.0 ? "#ff7875" : ratio > 1.5 ? "#ffc069" : "#52c41a"; - } else { - edgeColor = getEdgeColor(edgeP95Ms); - } - } + // Tree edges are always neutral gray — color belongs on node borders only + const edgeColor = isDarkMode ? "#4a5568" : "#b0b7c3"; - // Dynamic size based on request volume — matches graph view formula - const nodeRequests = node.requests ?? totalRequests; - const symbolSize = Math.max(40, Math.min(80, Math.log10(nodeRequests + 1) * 20)); + // Reuse the same SVG icon as graph view (circle + service-type icon) + const iconDataUrl = getServiceIconSvg(node.id, isDarkMode, borderColor); return { name: node.label || node.id, value: totalRequests, - symbolSize: symbolSize, + symbol: iconDataUrl, + symbolSize: 30, // Fixed size so ECharts tree layout spaces nodes consistently lineStyle: { color: edgeColor, - width: incomingEdge ? Math.max(1, Math.min(4, 1 + (totalRequests || 0) / 150)) : 1.5, + width: 2, }, itemStyle: { color: isDarkMode ? '#1a1f2e' : '#ffffff', @@ -382,24 +329,23 @@ export const convertServiceGraphToTree = ( }, label: { show: true, - position: 'inside', + position: layoutType === 'vertical' ? 'bottom' : 'right', + distance: 6, formatter: (params: any) => { return `{name|${params.name}}\n{requests|${formatNumber(totalRequests)} req}`; }, rich: { name: { - fontSize: 11, - fontWeight: '500', - color: isDarkMode ? '#e4e7eb' : '#333', - align: 'center', - lineHeight: 14, + fontSize: 12, + fontWeight: '600', + color: isDarkMode ? '#e4e7eb' : '#1f2937', + lineHeight: 16, }, requests: { - fontSize: 9, + fontSize: 10, fontWeight: 'normal', - color: isDarkMode ? '#9ca3af' : '#666', - align: 'center', - lineHeight: 12, + color: isDarkMode ? '#9ca3af' : '#6b7280', + lineHeight: 14, }, }, }, @@ -477,28 +423,37 @@ export const convertServiceGraphToTree = ( layout: 'orthogonal', orient: layoutType === 'vertical' ? 'TB' : 'LR', // Maximize layout space so siblings spread further apart - left: layoutType === 'vertical' ? '1%' : '3%', - right: layoutType === 'vertical' ? '1%' : '3%', - top: layoutType === 'vertical' ? '3%' : '1%', - bottom: layoutType === 'vertical' ? '3%' : '1%', + left: layoutType === 'vertical' ? '2%' : '3%', + right: layoutType === 'vertical' ? '2%' : '20%', + top: layoutType === 'vertical' ? '8%' : '2%', + bottom: layoutType === 'vertical' ? '8%' : '2%', initialTreeDepth: -1, - symbol: 'circle', - symbolSize: 50, // Default; each node overrides with dynamic size - roam: true, // Enable panning and zooming - selectedMode: 'single', // Enable single node selection + symbolSize: 30, + roam: true, + selectedMode: 'single', label: { - position: 'inside', - fontSize: 11, + position: layoutType === 'vertical' ? 'bottom' : 'right', + distance: 6, + fontSize: 12, rotate: 0, }, leaves: { label: { - position: 'inside', - fontSize: 11, + position: layoutType === 'vertical' ? 'bottom' : 'right', + distance: 6, + fontSize: 12, rotate: 0, }, }, - expandAndCollapse: false, // Disable collapse on click - clicking only selects the node + emphasis: { + focus: 'relative', // dims nodes not connected to the hovered node + }, + blur: { + itemStyle: { opacity: 0.15 }, + label: { opacity: 0.15 }, + lineStyle: { opacity: 0.08 }, + }, + expandAndCollapse: false, animationDuration: 550, animationDurationUpdate: 750, }, @@ -508,92 +463,389 @@ export const convertServiceGraphToTree = ( return { options, positions: null }; }; -// D3-Force simulation physics parameters -const FORCE_PHYSICS_PARAMS = { - // Many-Body Force (Repulsion) - chargeStrength: -3000, // Lower repulsion to tighten clusters - chargeDistanceMax: 1200, // Limit long-range repulsion - - // Link Force - linkDistance: 250, // Shorter links for tighter grouping - linkStrength: 0.6, // Stronger link pull to hold structure - linkIterations: 3, // Fewer passes, less "rubberiness" - - // Center Force - centerStrength: 0.05, // Stronger centering keeps it compact - - // Position Forces - forceXStrength: 0.08, // Gentle horizontal correction - forceYStrength: 0.08, // Gentle vertical correction - - // Collision Force - collisionPadding: 80, // Reasonable padding without huge gaps - collisionStrength: 1.0, // Full collision enforcement - collisionIterations: 2, // Enough to resolve overlaps - - // Simulation - velocityDecay: 0.35, // Slightly more friction for faster settle - totalTicks: 5000, // Iterations for stabilization -}; - /** - * Compute force-directed layout using D3-force simulation - * @param nodes - Array of nodes - * @param edges - Array of edges - * @param width - Container width - * @param height - Container height - * @returns Nodes with computed x, y positions + * Fruchterman-Reingold force-directed layout. + * + * Produces the organic, well-spread graph layout used by tools like DataDog. + * No external dependencies — runs as a pure-JS physics simulation. + * + * Algorithm: + * 1. Initialise nodes in a circle (avoids singularities). + * 2. Iterate: repel all node pairs, attract connected pairs. + * 3. Clamp each step by a temperature that cools each iteration. + * 4. Normalise final bounding box → canvas with padding. */ const computeForceLayout = ( nodes: any[], edges: any[], - width: number = 800, - height: number = 600 -) => { - // Create a copy of nodes to avoid mutation - const nodesCopy = nodes.map(n => ({ ...n })); + width: number, + height: number +): Map => { + const PAD_LEFT = 110; + const PAD_RIGHT = 220; // extra right padding for label text overflow + const PAD_TOP = 110; + const PAD_BOTTOM = 200; // extra bottom padding so bottom-positioned labels don't clip + const W = width - PAD_LEFT - PAD_RIGHT; + const H = height - PAD_TOP - PAD_BOTTOM; + const n = nodes.length; - // Prepare edges with proper source/target references - const edgesCopy = edges.map(e => ({ - source: e.from, - target: e.to, - ...e, - })); + if (n === 0) return new Map(); + if (n === 1) return new Map([[nodes[0].id, { x: width / 2, y: height / 2 }]]); - // Create simulation and compute layout using physics params - const simulation = forceSimulation(nodesCopy) - .force('charge', forceManyBody() - .strength(FORCE_PHYSICS_PARAMS.chargeStrength) - .distanceMax(FORCE_PHYSICS_PARAMS.chargeDistanceMax) - ) - .force('link', forceLink(edgesCopy) - .id((d: any) => d.id) - .distance(FORCE_PHYSICS_PARAMS.linkDistance) - .strength(FORCE_PHYSICS_PARAMS.linkStrength) - .iterations(FORCE_PHYSICS_PARAMS.linkIterations) - ) - .force('center', forceCenter(width / 2, height / 2) - .strength(FORCE_PHYSICS_PARAMS.centerStrength) - ) - .force('x', forceX(width / 2).strength(FORCE_PHYSICS_PARAMS.forceXStrength)) - .force('y', forceY(height / 2).strength(FORCE_PHYSICS_PARAMS.forceYStrength)) - .force('collision', forceCollide() - .radius((d: any) => (d.symbolSize || 60) / 2 + FORCE_PHYSICS_PARAMS.collisionPadding) - .strength(FORCE_PHYSICS_PARAMS.collisionStrength) - .iterations(FORCE_PHYSICS_PARAMS.collisionIterations) - ) - .velocityDecay(FORCE_PHYSICS_PARAMS.velocityDecay) - .stop(); + // Optimal spring length: scales with canvas area / node count + const k = Math.sqrt((W * H) / n) * 1.0; - // Run simulation for specified iterations to stabilize layout - for (let i = 0; i < FORCE_PHYSICS_PARAMS.totalTicks; i++) { - simulation.tick(); + // ── 1. Initialise on a circle to avoid singularities ───────────────────── + const pos = new Map(); + nodes.forEach((node, i) => { + const angle = (2 * Math.PI * i) / n; + const r = Math.min(W, H) * 0.32; + pos.set(node.id, { + x: W / 2 + r * Math.cos(angle), + y: H / 2 + r * Math.sin(angle), + }); + }); + + // Deduplicate edges (undirected for layout purposes) + const seen = new Set(); + const layoutEdges: { u: string; v: string }[] = []; + edges.forEach((e: any) => { + const key = [e.from, e.to].sort().join('→'); + if (!seen.has(key)) { seen.add(key); layoutEdges.push({ u: e.from, v: e.to }); } + }); + + const ids = nodes.map((nd: any) => nd.id); + + // ── 2. Simulation ───────────────────────────────────────────────────────── + const ITERATIONS = 350; + let temp = Math.min(W, H) * 0.3; // initial max displacement + const cooling = temp / (ITERATIONS + 1); + + for (let iter = 0; iter < ITERATIONS; iter++) { + const disp = new Map(); + ids.forEach((id: string) => disp.set(id, { x: 0, y: 0 })); + + // Repulsive forces — O(n²), fast enough for typical service graphs (<200 nodes) + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const u = ids[i], v = ids[j]; + const pu = pos.get(u)!, pv = pos.get(v)!; + const dx = pu.x - pv.x; + const dy = pu.y - pv.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + const fr = (k * k) / dist; + const nx = (dx / dist) * fr; + const ny = (dy / dist) * fr; + const du = disp.get(u)!; du.x += nx; du.y += ny; + const dv = disp.get(v)!; dv.x -= nx; dv.y -= ny; + } + } + + // Node-on-edge repulsion: push nodes away from edges they are not endpoints of + const edgeRepelDist = k * 1.2; // min clearance between a node and a passing edge + ids.forEach((id: string) => { + const p = pos.get(id)!; + layoutEdges.forEach(({ u, v }) => { + if (id === u || id === v) return; + const pu = pos.get(u)!, pv = pos.get(v)!; + const ex = pv.x - pu.x, ey = pv.y - pu.y; + const len2 = ex * ex + ey * ey; + if (len2 < 1) return; + const t = Math.max(0, Math.min(1, ((p.x - pu.x) * ex + (p.y - pu.y) * ey) / len2)); + const cx = pu.x + t * ex, cy = pu.y + t * ey; + const dx = p.x - cx, dy = p.y - cy; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + if (dist < edgeRepelDist) { + const force = ((edgeRepelDist - dist) / dist) * k * 0.8; + const d = disp.get(id)!; + d.x += (dx / dist) * force; + d.y += (dy / dist) * force; + } + }); + }); + + // Attractive forces — along edges only + layoutEdges.forEach(({ u, v }) => { + if (!pos.has(u) || !pos.has(v)) return; + const pu = pos.get(u)!, pv = pos.get(v)!; + const dx = pu.x - pv.x; + const dy = pu.y - pv.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + const fa = (dist * dist) / k; + const nx = (dx / dist) * fa; + const ny = (dy / dist) * fa; + const du = disp.get(u)!; du.x -= nx; du.y -= ny; + const dv = disp.get(v)!; dv.x += nx; dv.y += ny; + }); + + // Apply displacements clamped by temperature, keep within working area + ids.forEach((id: string) => { + const p = pos.get(id)!; + const d = disp.get(id)!; + const dlen = Math.sqrt(d.x * d.x + d.y * d.y) || 0.01; + const scale = Math.min(dlen, temp) / dlen; + p.x = Math.max(0, Math.min(W, p.x + d.x * scale)); + p.y = Math.max(0, Math.min(H, p.y + d.y * scale)); + }); + + temp = Math.max(0, temp - cooling); } - // Return nodes with computed positions - return simulation.nodes().map(n => ({ ...n })); + // ── 3. Normalise bounding box → full canvas with padding ───────────────── + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + pos.forEach(p => { + if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y; + }); + const rangeX = maxX - minX || 1; + const rangeY = maxY - minY || 1; + + // Scale uniformly so the layout fills without distortion, then centre + const scale = Math.min(W / rangeX, H / rangeY); + const offsetX = PAD_LEFT + (W - rangeX * scale) / 2; + const offsetY = PAD_TOP + (H - rangeY * scale) / 2; + + const result = new Map(); + pos.forEach((p, id) => { + result.set(id, { + x: offsetX + (p.x - minX) * scale, + y: offsetY + (p.y - minY) * scale, + }); + }); + return result; }; +// ── Service-type icon helper ────────────────────────────────────────────────── + +/** + * Returns an ECharts image:// data URL containing a full SVG node: + * outer circle (health-based border) + monochrome Feather-style icon. + * The icon type is inferred from the service name via keyword matching. + */ +// Ordered icon rules: first match wins. Priority: infra → data → domain → UI → default. +// Each SVG path is a 24×24 Feather-style stroke icon (no fill). +const SERVICE_ICON_RULES: { regex: RegExp; svg: string }[] = [ + // ── Infrastructure / networking ────────────────────────────────────────── + { + // Globe — API gateway, reverse proxy, ingress, router + regex: /gateway|proxy|ingress|nginx|envoy|router/, + svg: `` + + `` + + ``, + }, + { + // Wifi — websocket / realtime / streaming + regex: /websocket|realtime|streaming|socket/, + svg: `` + + `` + + `` + + ``, + }, + { + // Share network — message broker, queue, pubsub + regex: /queue|kafka|rabbit|broker|mq|pubsub|event/, + svg: `` + + `` + + ``, + }, + { + // Clock / cog — scheduler, cron, background worker, job, consumer + regex: /schedul|cron|worker|job|consumer|processor/, + svg: `` + + ``, + }, + { + // Sliders — config, consul, etcd, feature flag, feature toggle + regex: /config|consul|etcd|flag|feature/, + svg: `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``, + }, + { + // Activity pulse — load generator, traffic simulator, telemetry, monitoring + regex: /load|generator|traffic|benchmark|prometheus|grafana|otel|telemetry|monitor/, + svg: ``, + }, + { + // Bell — alert manager, alarm, incident + regex: /alert|alarm|incident|pagerduty/, + svg: `` + + ``, + }, + // ── Data / storage ─────────────────────────────────────────────────────── + { + // Database cylinder — any SQL/NoSQL/search engine + regex: /database|elastic|opensearch|mongo|mysql|postgres|sqlite|oracle|cassandra|dynamo|\bdb\b/, + svg: `` + + `` + + ``, + }, + { + // Layers — in-memory cache + regex: /cache|redis|memcache/, + svg: `` + + `` + + ``, + }, + { + // Download arrow — data ingestion, pipeline, ETL, collector + regex: /ingest|pipeline|etl|collector/, + svg: `` + + `` + + ``, + }, + { + // Minimize arrows — compactor, compressor, archiver (data compaction) + regex: /compact|compress|archiv/, + svg: `` + + `` + + `` + + ``, + }, + { + // Image frame — image, media, asset, object storage + regex: /image|photo|media|asset|storage|blob/, + svg: `` + + `` + + ``, + }, + // ── Auth / security ────────────────────────────────────────────────────── + { + // Shield — authentication, identity, SSO + regex: /auth|sso|identity|login|oauth|keycloak/, + svg: ``, + }, + // ── Search ─────────────────────────────────────────────────────────────── + { + // Magnifier — search and query services + regex: /search|quer/, + svg: `` + + ``, + }, + // ── AI / ML ────────────────────────────────────────────────────────────── + { + // CPU chip — ML, AI, inference, recommendation + regex: /\bml\b|\bai\b|model|inference|predict|recommend|suggest/, + svg: `` + + `` + + `` + + `` + + `` + + ``, + }, + // ── Domain / business ──────────────────────────────────────────────────── + { + // Envelope — email, mail, notification, newsletter tools (listmonk etc.) + regex: /email|mail|smtp|notification|notify|newsletter|listmonk/, + svg: `` + + ``, + }, + { + // Credit card — payment, billing, invoice + regex: /payment|\bpay\b|billing|invoice|stripe|paypal/, + svg: `` + + ``, + }, + { + // Shopping cart — cart, basket, checkout, order + regex: /cart|basket|checkout|order/, + svg: `` + + ``, + }, + { + // Truck — shipping, delivery, logistics + regex: /ship|delivery|courier|logistic/, + svg: `` + + `` + + ``, + }, + { + // Dollar sign — currency, accounting, finance, exchange + regex: /currency|exchange|forex|accounting|finance|ledger/, + svg: `` + + ``, + }, + { + // File list — product catalog, inventory + regex: /catalog|inventory|product|listing/, + svg: `` + + `` + + `` + + ``, + }, + { + // Megaphone — ad, advertising, marketing + regex: /\bad\b|\bads\b|advert|marketing/, + svg: `` + + `` + + ``, + }, + { + // Document with plus — quote, pricing service + regex: /quote|pricing/, + svg: `` + + `` + + `` + + ``, + }, + { + // Bot/person — SRE agent, automation agent, bot + regex: /\bagent\b|\bbot\b|sre|automation/, + svg: `` + + `` + + `` + + `` + + `` + + ``, + }, + // ── UI / client ────────────────────────────────────────────────────────── + { + // Monitor — web frontend, client app, browser + regex: /frontend|\bweb\b|\bui\b|\bapp\b|client|browser/, + svg: `` + + `` + + ``, + }, +]; + +// Server SVG — used as fallback when no rule matches +const SERVER_ICON_SVG = + `` + + `` + + `` + + ``; + +function getServiceIconSvg(name: string, isDark: boolean, borderColor: string): string { + const iconColor = isDark ? '#e4e7eb' : '#374151'; + const bgColor = isDark ? '#1a1f2e' : '#ffffff'; + + // Normalize: lowercase and collapse hyphens/underscores to spaces so + // "load-generator" and "load_generator" both match /load/ or /generator/. + const n = (name || '').toLowerCase().replace(/[-_]/g, ' '); + + const matched = SERVICE_ICON_RULES.find(({ regex }) => regex.test(n)); + const icon = matched ? matched.svg : SERVER_ICON_SVG; + + // 56×56 viewBox: circle r=24 centered at (28,28); icon 24×24 translated to (16,16) + const svg = + `` + + `` + + `` + + icon + + ``; + + // btoa with encodeURIComponent so any edge-case Unicode in service names stays safe + return `image://data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`; +} + +// ───────────────────────────────────────────────────────────────────────────── + /** * Convert service graph data to ECharts Graph format (force-directed network) */ @@ -603,7 +855,8 @@ export const convertServiceGraphToNetwork = ( cachedPositions?: Map, isDarkMode: boolean = true, selectedNodeId?: string, - edgeBaselines?: Map + canvasWidth: number = 1200, + canvasHeight: number = 700 ) => { // Graph view only supports force-directed layout // Tree layouts ('horizontal', 'vertical') should use convertServiceGraphToTree instead @@ -667,11 +920,11 @@ export const convertServiceGraphToNetwork = ( else if (errorRate > 1) borderColor = "#faad14"; // Yellow (degraded) } - // Size based on request volume - much smaller nodes - const symbolSize = Math.max(40, Math.min(80, Math.log10(metrics.requests + 1) * 20)); + // Node size: scales with request volume like DataDog (70–110 px range) + const symbolSize = Math.max(70, Math.min(110, Math.log10(metrics.requests + 1) * 28)); - // Check if this node is selected - const isSelected = selectedNodeId === node.id; + // SVG symbol: circle with health-colored border + service-type icon + const iconDataUrl = getServiceIconSvg(node.id, isDarkMode, borderColor); // Use cached position if available const cachedPos = cachedPositions?.get(node.id); @@ -680,47 +933,23 @@ export const convertServiceGraphToNetwork = ( name: node.label || node.id, value: metrics.requests, errors: metrics.errors, - symbolSize: isSelected ? symbolSize * 1.1 : symbolSize, // Scale up selected node - itemStyle: { - color: isDarkMode ? '#1a1f2e' : '#ffffff', - borderColor: borderColor, // Keep health-based border color - borderWidth: 4, - shadowBlur: isSelected ? 25 : 10, // Enhanced shadow for selected node - shadowColor: isSelected - ? 'rgba(59, 130, 246, 0.6)' // Blue glow for selected - : (isDarkMode ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)'), - shadowOffsetX: 0, - shadowOffsetY: 0, - }, - label: { - show: true, - }, + symbol: iconDataUrl, + symbolSize, + // itemStyle opacity is needed for emphasis.focus adjacency dimming to work on images + itemStyle: { opacity: 1 }, + label: { show: true }, emphasis: { scale: true, - scaleSize: 1.15, - itemStyle: { - shadowBlur: 20, // Enhanced shadow on hover - shadowColor: isDarkMode ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)', - }, + scaleSize: 1.12, label: { show: true, - fontSize: 12, fontWeight: 'bold', + fontSize: 13, }, }, select: { - // Persistent selection styling - itemStyle: { - borderColor: borderColor, // Keep health-based border color - borderWidth: 5, - shadowBlur: 45, - shadowColor: 'rgba(59, 130, 246, 0.9)', // Prominent blue glow for selected - shadowOffsetX: 0, - shadowOffsetY: 0, - }, label: { show: true, - fontSize: 12, fontWeight: 'bold', }, }, @@ -824,28 +1053,7 @@ export const convertServiceGraphToNetwork = ( const p95 = formatLatency(edge.p95_latency_ns || 0); const p99 = formatLatency(edge.p99_latency_ns || 0); - // Edge color is latency-only (error rate belongs on nodes, not edges) - const p95Ms = (edge.p95_latency_ns || 0) / 1000000; - const baselineKey = `${edge.from}->${edge.to}`; - const baseline = edgeBaselines?.get(baselineKey); - let edgeColor; - if (baseline && baseline.p95_avg > 0) { - // Baseline-relative coloring: compare current p95 to previous-slot weighted average - const ratio = (edge.p95_latency_ns || 0) / baseline.p95_avg; - if (ratio > 2.0) { - edgeColor = "#ff7875"; // >2x baseline - } else if (ratio > 1.5) { - edgeColor = "#ffc069"; // 1.5–2x baseline - } else { - edgeColor = "#52c41a"; // Within baseline - } - } else if (p95Ms > 1000) { - edgeColor = "#ff7875"; // >1s absolute threshold - } else if (p95Ms > 500) { - edgeColor = "#ffc069"; // >500ms absolute threshold - } else { - edgeColor = "#52c41a"; // Green for healthy - } + const edgeColor = isDarkMode ? "#4a5568" : "#b0b7c3"; return { source: edge.from, @@ -853,21 +1061,24 @@ export const convertServiceGraphToNetwork = ( value: edge.total_requests || 0, // Edge tooltips are handled by the mini chart overlay — disable ECharts native tooltip tooltip: { show: false }, - symbol: ['none', 'arrow'], // Arrow at target end - symbolSize: [0, 12], // Smaller arrows for cleaner look + symbol: ['none', 'arrow'], + symbolSize: [0, 10], lineStyle: { - width: Math.max(1, Math.min(4, 1 + (edge.total_requests || 0) / 150)), + width: 4, color: edgeColor, curveness: curveness, - opacity: 0.5, // More transparent for less visual clutter + type: 'solid', // solid by default — no animation at rest + opacity: 0.6, }, label: { show: false, }, emphasis: { lineStyle: { - width: 5, - opacity: 0.9, + type: 'solid', + width: 4, + opacity: 1, + color: edgeColor, }, }, }; @@ -876,17 +1087,17 @@ export const convertServiceGraphToNetwork = ( // Determine if we should use force layout or fixed positions const hasPositions = cachedPositions && cachedPositions.size > 0; - // For force layout without cached positions, compute layout with D3-force + // Compute hierarchical layout when no cached positions exist. + // Columns = call depth from root services; rows = nodes within each column. + // Positions are mapped directly to canvas pixels so nodes fill the full space. if (normalizedLayoutType === 'force' && !hasPositions) { - const positionedNodes = computeForceLayout(nodes, graphData.edges, 800, 600); - - // Apply computed positions to nodes and mark them as fixed - positionedNodes.forEach((positioned: any) => { - const node = nodes.find((n: any) => n.id === positioned.id); - if (node) { - node.x = positioned.x; - node.y = positioned.y; - node.fixed = true; // Lock positions so ECharts doesn't re-layout + const positions = computeForceLayout(nodes, graphData.edges, canvasWidth, canvasHeight); + nodes.forEach((node: any) => { + const pos = positions.get(node.id); + if (pos) { + node.x = pos.x; + node.y = pos.y; + node.fixed = true; } }); } @@ -895,6 +1106,35 @@ export const convertServiceGraphToNetwork = ( console.log('[convertServiceGraphToNetwork] Using cached positions for', cachedPositions.size, 'nodes'); } + // Set per-node label position based on x/y to avoid canvas edge clipping. + // Must include all styling props since per-node label does not fully inherit series-level label. + const bottomThreshold = canvasHeight * 0.70; + const rightThreshold = canvasWidth * 0.72; + const baseLabelStyle = { + show: true, + distance: 8, + fontSize: 12, + fontWeight: 500, + color: isDarkMode ? '#e4e7eb' : '#374151', + textBorderColor: isDarkMode ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)', + textBorderWidth: 3, + }; + nodes.forEach((node: any) => { + const x = node.x ?? 0; + const y = node.y ?? 0; + let position: string; + if (y >= bottomThreshold) { + // Bottom nodes: right side has leftover canvas space; bottom-right corner → right avoids bottom clip + position = 'bottom'; + } else if (x >= rightThreshold) { + // Right-edge nodes: label goes below the node so it doesn't extend past the canvas right edge + position = 'bottom'; + } else { + position = 'right'; + } + node.label = { ...baseLabelStyle, position }; + }); + // Use "none" layout when we have fixed positions (D3-force computed or cached) const layoutMode = "none"; @@ -922,7 +1162,7 @@ export const convertServiceGraphToNetwork = ( data: nodes, links: edges, roam: true, - draggable: true, // Enable dragging to allow manual position adjustments + draggable: false, focusNodeAdjacency: true, selectedMode: 'single', // Enable single node selection scaleLimit: { @@ -933,40 +1173,7 @@ export const convertServiceGraphToNetwork = ( animationEasingUpdate: 'cubicOut', label: { show: true, - position: 'inside', - formatter: (params: any) => { - const serviceName = params.data.name; - const requests = params.data.value || 0; - - // Format request count with K, M notation - let requestsDisplay; - if (requests >= 1000000) { - requestsDisplay = (requests / 1000000).toFixed(1) + 'M'; - } else if (requests >= 1000) { - requestsDisplay = (requests / 1000).toFixed(1) + 'K'; - } else { - requestsDisplay = requests.toString(); - } - - // Display service name and request count in the middle - return `{name|${serviceName}}\n{requests|${requestsDisplay} req}`; - }, - rich: { - name: { - fontSize: 12, - fontWeight: '500', - color: isDarkMode ? '#e4e7eb' : '#333', // Dark: light text, Light: dark text - align: 'center', - lineHeight: 16, - }, - requests: { - fontSize: 10, - fontWeight: 'normal', - color: isDarkMode ? '#9ca3af' : '#666', // Dark: light gray, Light: dark gray - align: 'center', - lineHeight: 14, - }, - }, + formatter: (params: any) => params.data.name, }, emphasis: { focus: "adjacency", @@ -983,8 +1190,8 @@ export const convertServiceGraphToNetwork = ( }, }, lineStyle: { - opacity: 0.7, - curveness: 'auto', // Let individual edges control curveness + type: 'solid', + opacity: 0.6, }, edgeSymbol: ['none', 'arrow'], edgeSymbolSize: [0, 15],