fix: service graph v2 UI (#10889)

This commit is contained in:
Sai Nikhil
2026-03-13 13:58:58 +05:30
committed by GitHub
parent 9e7336897e
commit fdd3d91016
7 changed files with 2224 additions and 650 deletions
+8 -8
View File
@@ -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");
});
});
});
+379 -367
View File
@@ -104,7 +104,79 @@
<!-- Graph Visualization -->
<q-card flat bordered class="graph-card tw:h-[calc(100%-4rem)]">
<q-card-section class="q-pa-none tw:h-full" style="height: 100%">
<div class="graph-container tw:h-full tw:bg-[var(--o2-bg)]">
<div class="graph-container tw:h-full tw:bg-[var(--o2-bg)]" style="position: relative;">
<!-- Legend info button -->
<q-btn
v-if="!loading && graphData.nodes.length"
round
flat
icon="info_outline"
size="sm"
class="sg-info-btn"
:class="$q.dark.isActive ? 'tw:text-gray-400' : 'tw:text-gray-500'"
>
<q-tooltip
:delay="150"
anchor="bottom right"
self="top right"
:offset="[0, 8]"
class="sg-legend-tooltip"
>
<div class="sg-legend">
<!-- Node Size — Graph View only (Tree View uses fixed sizes) -->
<template v-if="visualizationType === 'graph'">
<div class="sg-legend-title">Node Size <span class="sg-legend-subtitle">| Request Volume</span></div>
<div class="sg-legend-row sg-legend-sizes">
<div class="sg-legend-size-item">
<div class="sg-legend-circle" style="width:22px;height:22px;border-color:#52c41a;"></div>
<span class="sg-legend-label">Low</span>
</div>
<div class="sg-legend-size-dots">···</div>
<div class="sg-legend-size-item">
<div class="sg-legend-circle" style="width:42px;height:42px;border-color:#52c41a;"></div>
<span class="sg-legend-label">High</span>
</div>
</div>
<div class="sg-legend-divider"></div>
</template>
<!-- Border Color -->
<div class="sg-legend-title">Border Color <span class="sg-legend-subtitle">| Error Rate</span></div>
<div class="sg-legend-color-row">
<div class="sg-legend-color-item">
<div class="sg-legend-dot" style="border-color:#52c41a;"></div>
<div>
<div class="sg-legend-color-label">Healthy</div>
<div class="sg-legend-color-value">&lt; 1%</div>
</div>
</div>
<div class="sg-legend-color-item">
<div class="sg-legend-dot" style="border-color:#faad14;"></div>
<div>
<div class="sg-legend-color-label">Degraded</div>
<div class="sg-legend-color-value">1 – 5%</div>
</div>
</div>
<div class="sg-legend-color-item">
<div class="sg-legend-dot" style="border-color:#fa8c16;"></div>
<div>
<div class="sg-legend-color-label">Warning</div>
<div class="sg-legend-color-value">5 – 10%</div>
</div>
</div>
<div class="sg-legend-color-item">
<div class="sg-legend-dot" style="border-color:#f5222d;"></div>
<div>
<div class="sg-legend-color-label">Critical</div>
<div class="sg-legend-color-value">&gt; 10%</div>
</div>
</div>
</div>
</div>
</q-tooltip>
</q-btn>
<div v-if="loading" class="flex flex-center tw:h-full">
<div class="text-center tw:flex tw:flex-col tw:items-center">
<q-spinner-hourglass color="primary" size="4em" />
@@ -145,7 +217,7 @@
</div>
</div>
</div>
<div v-else class="tw:h-full graph-with-panel-container">
<div v-else ref="graphContainerRef" class="tw:h-full graph-with-panel-container">
<ChartRenderer
ref="chartRendererRef"
data-test="service-graph-chart"
@@ -156,7 +228,7 @@
@click="handleNodeClick"
/>
<!-- Service Graph Side Panel -->
<!-- Service Graph Side Panel (node) -->
<ServiceGraphSidePanel
v-if="selectedNode"
:selected-node="selectedNode"
@@ -167,6 +239,14 @@
@close="handleCloseSidePanel"
/>
<!-- Service Graph Edge Side Panel -->
<ServiceGraphEdgeSidePanel
v-if="selectedEdge"
:selected-edge="selectedEdge"
:visible="showEdgeSidePanel"
@close="handleCloseEdgeSidePanel"
/>
</div>
</div>
</q-card-section>
@@ -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<any>(null);
const showSidePanel = ref(false);
// Map of "from->to" -> { p50_avg, p95_avg, p99_avg } — populated on edge hover
const edgeBaselines = ref<Map<string, { p50_avg: number; p95_avg: number; p99_avg: number }>>(new Map());
// Edge side panel state
const selectedEdge = ref<any>(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<any>(null);
const graphContainerRef = ref<HTMLElement | null>(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<string, any>();
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<typeof setTimeout> | 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<string, any>();
const fetchingEdges = new Set<string>();
let sharedTrendFetchTimer: ReturnType<typeof setTimeout> | 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) =>
`<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${p.color};margin-right:5px;vertical-align:middle"></span>${p.seriesName}: <b>${fmtMs(p.value[1])}</b>`
);
return `<div style="font-size:10px;opacity:0.55;margin-bottom:3px">${ts}</div>${lines.join('<br>')}`;
},
},
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 = `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:11px;letter-spacing:0.02em;color:${loaderColor};font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text',sans-serif;">Loading…</div>`;
};
// 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}
<div style="${btnStyle}">
<button data-show-trend data-parent="${parentName}" data-child="${childName}" style="${btnElStyle}">
Show trend ↗
</button>
</div>`;
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<string, { p50_avg: number; p95_avg: number; p99_avg: number }>();
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;
}
</style>
<!-- Flowing edge animation — non-scoped so it reaches inside ECharts SVG output -->
<style lang="scss">
@keyframes sg-edge-flow {
from { stroke-dashoffset: 14; }
to { stroke-dashoffset: 0; }
}
/*
* Target dashed edge paths rendered by ECharts graph series.
* ECharts SVG mode may set stroke-dasharray as an HTML attribute OR inside
* an inline style depending on the version — we cover both.
*/
.graph-container svg path[stroke-dasharray],
.graph-container svg path[style*="stroke-dasharray"] {
animation: sg-edge-flow 0.5s linear infinite;
animation-fill-mode: both;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -286,7 +286,7 @@ const TelemetryCorrelationDashboard = defineAsyncComponent(
);
export default defineComponent({
name: 'ServiceGraphSidePanel',
name: 'ServiceGraphNodeSidePanel',
components: {
TelemetryCorrelationDashboard,
},
@@ -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],
@@ -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);
});
+479 -272
View File
@@ -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<string, { p50_avg: number; p95_avg: number; p99_avg: number }>
) => {
// Build adjacency map for edges
const edgesMap = new Map<string, any[]>();
@@ -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<string>();
@@ -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<string, { x: number; y: number }> => {
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<string, { x: number; y: number }>();
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<string>();
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<string, { x: number; y: number }>();
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<string, { x: number; y: number }>();
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: `<circle cx="12" cy="12" r="10"/>` +
`<line x1="2" y1="12" x2="22" y2="12"/>` +
`<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>`,
},
{
// Wifi — websocket / realtime / streaming
regex: /websocket|realtime|streaming|socket/,
svg: `<path d="M5 12.55a11 11 0 0 1 14.08 0"/>` +
`<path d="M1.42 9a16 16 0 0 1 21.16 0"/>` +
`<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>` +
`<line x1="12" y1="20" x2="12.01" y2="20"/>`,
},
{
// Share network — message broker, queue, pubsub
regex: /queue|kafka|rabbit|broker|mq|pubsub|event/,
svg: `<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>` +
`<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>` +
`<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>`,
},
{
// Clock / cog — scheduler, cron, background worker, job, consumer
regex: /schedul|cron|worker|job|consumer|processor/,
svg: `<circle cx="12" cy="12" r="10"/>` +
`<polyline points="12 6 12 12 16 14"/>`,
},
{
// Sliders — config, consul, etcd, feature flag, feature toggle
regex: /config|consul|etcd|flag|feature/,
svg: `<line x1="4" y1="21" x2="4" y2="14"/>` +
`<line x1="4" y1="10" x2="4" y2="3"/>` +
`<line x1="12" y1="21" x2="12" y2="12"/>` +
`<line x1="12" y1="8" x2="12" y2="3"/>` +
`<line x1="20" y1="21" x2="20" y2="16"/>` +
`<line x1="20" y1="12" x2="20" y2="3"/>` +
`<line x1="1" y1="14" x2="7" y2="14"/>` +
`<line x1="9" y1="8" x2="15" y2="8"/>` +
`<line x1="17" y1="16" x2="23" y2="16"/>`,
},
{
// Activity pulse — load generator, traffic simulator, telemetry, monitoring
regex: /load|generator|traffic|benchmark|prometheus|grafana|otel|telemetry|monitor/,
svg: `<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>`,
},
{
// Bell — alert manager, alarm, incident
regex: /alert|alarm|incident|pagerduty/,
svg: `<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>` +
`<path d="M13.73 21a2 2 0 0 1-3.46 0"/>`,
},
// ── Data / storage ───────────────────────────────────────────────────────
{
// Database cylinder — any SQL/NoSQL/search engine
regex: /database|elastic|opensearch|mongo|mysql|postgres|sqlite|oracle|cassandra|dynamo|\bdb\b/,
svg: `<ellipse cx="12" cy="5" rx="9" ry="3"/>` +
`<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>` +
`<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>`,
},
{
// Layers — in-memory cache
regex: /cache|redis|memcache/,
svg: `<polygon points="12 2 2 7 12 12 22 7 12 2"/>` +
`<polyline points="2 17 12 22 22 17"/>` +
`<polyline points="2 12 12 17 22 12"/>`,
},
{
// Download arrow — data ingestion, pipeline, ETL, collector
regex: /ingest|pipeline|etl|collector/,
svg: `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>` +
`<polyline points="7 10 12 15 17 10"/>` +
`<line x1="12" y1="15" x2="12" y2="3"/>`,
},
{
// Minimize arrows — compactor, compressor, archiver (data compaction)
regex: /compact|compress|archiv/,
svg: `<polyline points="4 14 10 14 10 20"/>` +
`<polyline points="20 10 14 10 14 4"/>` +
`<line x1="10" y1="14" x2="3" y2="21"/>` +
`<line x1="21" y1="3" x2="14" y2="10"/>`,
},
{
// Image frame — image, media, asset, object storage
regex: /image|photo|media|asset|storage|blob/,
svg: `<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>` +
`<circle cx="8.5" cy="8.5" r="1.5"/>` +
`<polyline points="21 15 16 10 5 21"/>`,
},
// ── Auth / security ──────────────────────────────────────────────────────
{
// Shield — authentication, identity, SSO
regex: /auth|sso|identity|login|oauth|keycloak/,
svg: `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>`,
},
// ── Search ───────────────────────────────────────────────────────────────
{
// Magnifier — search and query services
regex: /search|quer/,
svg: `<circle cx="11" cy="11" r="8"/>` +
`<line x1="21" y1="21" x2="16.65" y2="16.65"/>`,
},
// ── AI / ML ──────────────────────────────────────────────────────────────
{
// CPU chip — ML, AI, inference, recommendation
regex: /\bml\b|\bai\b|model|inference|predict|recommend|suggest/,
svg: `<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>` +
`<rect x="9" y="9" width="6" height="6"/>` +
`<line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/>` +
`<line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/>` +
`<line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/>` +
`<line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>`,
},
// ── Domain / business ────────────────────────────────────────────────────
{
// Envelope — email, mail, notification, newsletter tools (listmonk etc.)
regex: /email|mail|smtp|notification|notify|newsletter|listmonk/,
svg: `<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>` +
`<polyline points="22,6 12,13 2,6"/>`,
},
{
// Credit card — payment, billing, invoice
regex: /payment|\bpay\b|billing|invoice|stripe|paypal/,
svg: `<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>` +
`<line x1="1" y1="10" x2="23" y2="10"/>`,
},
{
// Shopping cart — cart, basket, checkout, order
regex: /cart|basket|checkout|order/,
svg: `<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>` +
`<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>`,
},
{
// Truck — shipping, delivery, logistics
regex: /ship|delivery|courier|logistic/,
svg: `<rect x="1" y="3" width="15" height="13"/>` +
`<polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/>` +
`<circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>`,
},
{
// Dollar sign — currency, accounting, finance, exchange
regex: /currency|exchange|forex|accounting|finance|ledger/,
svg: `<line x1="12" y1="1" x2="12" y2="23"/>` +
`<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>`,
},
{
// File list — product catalog, inventory
regex: /catalog|inventory|product|listing/,
svg: `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>` +
`<polyline points="14 2 14 8 20 8"/>` +
`<line x1="16" y1="13" x2="8" y2="13"/>` +
`<line x1="16" y1="17" x2="8" y2="17"/>`,
},
{
// Megaphone — ad, advertising, marketing
regex: /\bad\b|\bads\b|advert|marketing/,
svg: `<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>` +
`<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>` +
`<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>`,
},
{
// Document with plus — quote, pricing service
regex: /quote|pricing/,
svg: `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>` +
`<polyline points="14 2 14 8 20 8"/>` +
`<line x1="12" y1="18" x2="12" y2="12"/>` +
`<line x1="9" y1="15" x2="15" y2="15"/>`,
},
{
// Bot/person — SRE agent, automation agent, bot
regex: /\bagent\b|\bbot\b|sre|automation/,
svg: `<path d="M12 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"/>` +
`<path d="M16 8v1a4 4 0 0 1-8 0V8"/>` +
`<line x1="8" y1="13" x2="16" y2="13"/>` +
`<rect x="8" y="13" width="8" height="8" rx="1"/>` +
`<line x1="10" y1="17" x2="10.01" y2="17"/>` +
`<line x1="14" y1="17" x2="14.01" y2="17"/>`,
},
// ── UI / client ──────────────────────────────────────────────────────────
{
// Monitor — web frontend, client app, browser
regex: /frontend|\bweb\b|\bui\b|\bapp\b|client|browser/,
svg: `<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>` +
`<line x1="8" y1="21" x2="16" y2="21"/>` +
`<line x1="12" y1="17" x2="12" y2="21"/>`,
},
];
// Server SVG — used as fallback when no rule matches
const SERVER_ICON_SVG =
`<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>` +
`<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>` +
`<line x1="6" y1="6" x2="6.01" y2="6"/>` +
`<line x1="6" y1="18" x2="6.01" y2="18"/>`;
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 =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56">` +
`<circle cx="28" cy="28" r="24" fill="${bgColor}" stroke="${borderColor}" stroke-width="4"/>` +
`<g transform="translate(16,16)" fill="none" stroke="${iconColor}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">` +
icon +
`</g></svg>`;
// 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<string, { x: number; y: number }>,
isDarkMode: boolean = true,
selectedNodeId?: string,
edgeBaselines?: Map<string, { p50_avg: number; p95_avg: number; p99_avg: number }>
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],