mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
fix: service graph v2 UI (#10889)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">< 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">> 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
+1
-1
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user