This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 4a02eda ui/bff: process topology — row layout, full-canvas peers,
16px labels; edge dashboard 4-up + profiling-window time range
4a02eda is described below
commit 4a02edac371fbf05100388c92bd15dbd01975932
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 13:00:10 2026 +0800
ui/bff: process topology — row layout, full-canvas peers, 16px labels;
edge dashboard 4-up + profiling-window time range
Topology graph:
- Inside processes now lay out in centred LEFT-RIGHT rows (last row
centred on its own count) instead of the spiral/arc honeycomb.
- External peers spread on a canvas-filling ellipse (skipping the
bottom arc for the pod label) so the graph uses the whole widget
rather than clustering at the centre.
- Dragging an inside cell reshapes the pod boundary; external peers
stay anchored to the canvas (removed the buggy boundary-follow that
made them jump).
- Node labels bumped to 16px mono / 600 to match the service-map
topology's node-name label exactly.
- Dropped now-unused hex-grid helpers (spiralHex / circlePoints /
axialToPixel).
Edge dashboard modal:
- Widened to 1600px; each side (client | server) is a 2-up widget grid
→ 4 charts per row (2 client + 2 server).
- Metrics are queried over the selected profiling TASK's run window
(taskStartTime → taskStartTime + duration), not the page/topology
window — the relation data only exists for the span the task ran.
BFF endpoint accepts startTime/endTime (ms); windowMinutes stays as
the no-task fallback. Validated against the demo (8 client + 8
server series, write_cpm ≈ 4).
---
apps/bff/src/http/query/ebpf.ts | 25 ++++-
apps/ui/src/api/scopes/network-profile.ts | 3 +
.../layer/profiling/LayerNetworkProfilingView.vue | 43 +++++--
.../src/layer/profiling/ProcessTopologyGraph.vue | 124 ++++++---------------
4 files changed, 91 insertions(+), 104 deletions(-)
diff --git a/apps/bff/src/http/query/ebpf.ts b/apps/bff/src/http/query/ebpf.ts
index 9990dbc..b7d7e7a 100644
--- a/apps/bff/src/http/query/ebpf.ts
+++ b/apps/bff/src/http/query/ebpf.ts
@@ -543,6 +543,11 @@ export function registerEBPFRoutes(app: FastifyInstance,
deps: EBPFRouteDeps): v
| {
source?: ProcessRelationEndpointRef;
dest?: ProcessRelationEndpointRef;
+ /** Profiling-task window (ms epoch). Preferred — the metrics
+ * only exist for the span the task actually ran. */
+ startTime?: number;
+ endTime?: number;
+ /** Fallback when no task window is supplied. */
windowMinutes?: number;
}
| undefined;
@@ -555,16 +560,26 @@ export function registerEBPFRoutes(app: FastifyInstance,
deps: EBPFRouteDeps): v
}
const cfg = processTopologyConfigFor(getLayerTemplate(params.key));
- const minutes = Math.max(5, Math.min(180, Number(body?.windowMinutes) ||
30));
- const end = new Date();
- const start = new Date(end.getTime() - minutes * 60_000);
+ // Prefer the profiling-task time range (the data only exists for
+ // that span). Fall back to a rolling window when none is given.
+ let startMs: number;
+ let endMs: number;
+ if (body?.startTime && body?.endTime && body.endTime > body.startTime) {
+ startMs = body.startTime;
+ endMs = body.endTime;
+ } else {
+ const minutes = Math.max(5, Math.min(180, Number(body?.windowMinutes)
|| 30));
+ endMs = Date.now();
+ startMs = endMs - minutes * 60_000;
+ }
// Match the network-topology route's UTC formatting so the edge
// metrics window lines up with the rendered graph window.
- const fmt = (d: Date) => {
+ const fmt = (ms: number) => {
+ const d = new Date(ms);
const z = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${z(d.getUTCMonth() +
1)}-${z(d.getUTCDate())} ${z(d.getUTCHours())}${z(d.getUTCMinutes())}`;
};
- const w = { start: fmt(start), end: fmt(end) };
+ const w = { start: fmt(startMs), end: fmt(endMs) };
// Build one aliased execExpression per metric across both sides.
const aliasMap = new Map<string, { side: 'client' | 'server'; metric:
TopologyMetricDef }>();
diff --git a/apps/ui/src/api/scopes/network-profile.ts
b/apps/ui/src/api/scopes/network-profile.ts
index 96aa235..8900bf1 100644
--- a/apps/ui/src/api/scopes/network-profile.ts
+++ b/apps/ui/src/api/scopes/network-profile.ts
@@ -75,6 +75,9 @@ export class NetworkProfileApi {
body: {
source: ProcessRelationEndpointRef;
dest: ProcessRelationEndpointRef;
+ /** Profiling-task window (ms epoch); preferred over windowMinutes. */
+ startTime?: number;
+ endTime?: number;
windowMinutes?: number;
},
): Promise<ProcessRelationMetricsResponse> {
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index 91b4998..1652309 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -173,10 +173,21 @@ watch(selectedCall, async (call) => {
}
relationLoading.value = true;
try {
+ // Query over the profiling task's own run window (the data only
+ // exists for that span), falling back to the topology window when
+ // no task is selected.
+ const task = currentTask.value;
+ const taskWindow =
+ task?.taskStartTime && task.fixedTriggerDuration
+ ? {
+ startTime: task.taskStartTime,
+ endTime: task.taskStartTime + task.fixedTriggerDuration * 1000,
+ }
+ : { windowMinutes: windowMinutes.value };
relationMetrics.value = await
bffClient.networkProfile.relationMetrics(layerKey.value, {
source: endpointRef(src),
dest: endpointRef(dst),
- windowMinutes: windowMinutes.value,
+ ...taskWindow,
});
if (!relationMetrics.value.reachable && relationMetrics.value.error) {
relationError.value = relationMetrics.value.error;
@@ -407,16 +418,18 @@ function fmtTime(ms: number): string {
>
<h5 class="edge-col-head" :class="side">{{ side === 'client' ?
'Client side' : 'Server side' }}</h5>
<div v-if="!relationMetrics[side].length" class="muted sm">No {{
side }} metrics configured.</div>
- <div v-for="m in relationMetrics[side]" :key="m.id"
class="edge-widget sw-card">
- <div class="ew-head">
- <span class="ew-label">{{ m.label }}</span>
- <span class="ew-val mono">{{ fmtMetric(latestValue(m.values),
m.unit) }}</span>
+ <div v-else class="edge-col-grid">
+ <div v-for="m in relationMetrics[side]" :key="m.id"
class="edge-widget sw-card">
+ <div class="ew-head">
+ <span class="ew-label">{{ m.label }}</span>
+ <span class="ew-val mono">{{
fmtMetric(latestValue(m.values), m.unit) }}</span>
+ </div>
+ <TimeChart
+ :series="[{ label: m.label, data: m.values, unit: m.unit }]"
+ :height="120"
+ :unit="m.unit"
+ />
</div>
- <TimeChart
- :series="[{ label: m.label, data: m.values, unit: m.unit }]"
- :height="130"
- :unit="m.unit"
- />
</div>
</section>
</div>
@@ -746,8 +759,9 @@ function fmtTime(ms: number): string {
font-size: 10.5px;
color: var(--sw-fg-1);
}
-/* Edge dashboard modal — wide, client | server side-by-side. */
-.edge-dlg { width: 1320px; max-width: 96vw; max-height: 90vh; }
+/* Edge dashboard modal — wide; client | server side-by-side, each side
+ a 2-up widget grid → 4 widgets per row (2 client + 2 server). */
+.edge-dlg { width: 1600px; max-width: 96vw; max-height: 90vh; }
.edge-dlg-title {
display: flex;
align-items: center;
@@ -773,6 +787,11 @@ function fmtTime(ms: number): string {
align-items: start;
}
.edge-col { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
+.edge-col-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
.edge-col-head {
margin: 0;
padding-bottom: 6px;
diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index 158f56a..60d2fb9 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -37,10 +37,7 @@ import * as d3 from 'd3';
import type { ProcessCall, ProcessNode } from '@/api/client';
interface Pt { x: number; y: number }
-// `_ox/_oy` = offset from the pod-boundary centre, stored for external
-// peers so they rigidly follow the boundary when it's recomputed during
-// an inside-node drag (they orbit the pod, never end up inside it).
-type PositionedNode = ProcessNode & Pt & { _ox?: number; _oy?: number };
+type PositionedNode = ProcessNode & Pt;
interface PositionedCall {
id: string;
source: PositionedNode;
@@ -56,38 +53,6 @@ const host = ref<HTMLDivElement | null>(null);
const selectedCallId = ref<string | null>(null);
// ── Hex geometry (flat-top) ─────────────────────────────────────────
-const SQRT3 = Math.sqrt(3);
-function axialToPixel(ax: number, ay: number, r: number, o: Pt): Pt {
- return { x: (1.5 * ax) * r + o.x, y: ((SQRT3 / 2) * ax + SQRT3 * ay) * r +
o.y };
-}
-/** Axial coords in spiral order from the centre — fills a honeycomb. */
-function spiralHex(n: number): Array<{ x: number; y: number }> {
- const dirs = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
- const out = [{ x: 0, y: 0 }];
- let k = 1;
- while (out.length < n) {
- let cx = dirs[4][0] * k;
- let cy = dirs[4][1] * k;
- for (let side = 0; side < 6 && out.length < n + 6; side++) {
- for (let step = 0; step < k; step++) {
- out.push({ x: cx, y: cy });
- cx += dirs[side][0];
- cy += dirs[side][1];
- }
- }
- k++;
- }
- return out.slice(0, n);
-}
-function circlePoints(r: number, stepDeg: number, o: Pt): Pt[] {
- const out: Pt[] = [];
- for (let deg = 0; deg < 360; deg += stepDeg) {
- if (deg >= 230 && deg <= 310) continue;
- const rad = (Math.PI * 2 * deg) / 360;
- out.push({ x: Math.cos(rad) * r + o.x, y: Math.sin(rad) * r + o.y });
- }
- return out;
-}
function hexCellPath(cx: number, cy: number, R: number): string {
const v: [number, number][] = [];
for (let i = 0; i < 6; i++) {
@@ -111,54 +76,46 @@ function protocolOf(c: ProcessCall): string {
let cellRadius = 26;
let positioned: PositionedNode[] = [];
-function layout(o: Pt): PositionedNode[] {
+function layout(o: Pt, w: number, h: number): PositionedNode[] {
const inside = props.nodes.filter(isInside).map((n) => ({ ...n }) as
PositionedNode);
const outside = props.nodes.filter((n) => !isInside(n)).map((n) => ({ ...n
}) as PositionedNode);
- // Inside honeycomb. Cell size scales down as the spiral grows so the
- // packed cluster stays compact.
- const rings = Math.max(1, Math.ceil((-3 + Math.sqrt(9 + 12 * Math.max(1,
inside.length - 1))) / 6));
- cellRadius = Math.max(16, Math.min(34, 150 / (rings + 0.6)));
- const cells = spiralHex(inside.length);
- inside.forEach((n, i) => {
- const c = cells[i] ?? { x: 0, y: 0 };
- const p = axialToPixel(c.x, c.y, cellRadius, o);
- n.x = p.x;
- n.y = p.y;
- });
-
- // Outside peers ring the (tight) inside cluster.
- const startR = insideExtent(inside, o) + 90;
- let r = startR;
- let ring = circlePoints(r, 26, o);
- outside.forEach((n, i) => {
- if (!ring[i]) {
- r += 80;
- ring = [...ring, ...circlePoints(r, 26, o)];
- }
- const p = ring[i] ?? { x: o.x, y: o.y - r };
- n.x = p.x;
- n.y = p.y;
+ // Inside processes: centred LEFT-RIGHT rows (not an arc). Columns bias
+ // slightly wide so the block reads horizontally; the last row is
+ // centred on its own count.
+ const n = inside.length;
+ const cols = Math.max(1, Math.min(n, Math.ceil(Math.sqrt(n * 1.7))));
+ const rows = Math.max(1, Math.ceil(n / cols));
+ cellRadius = Math.max(16, Math.min(34, 240 / (Math.max(cols, rows) + 1)));
+ const dx = cellRadius * 1.95;
+ const dy = cellRadius * 2.0;
+ inside.forEach((node, i) => {
+ const r = Math.floor(i / cols);
+ const c = i % cols;
+ const rowCount = r < rows - 1 ? cols : n - cols * (rows - 1);
+ node.x = o.x + (c - (rowCount - 1) / 2) * dx;
+ node.y = o.y + (r - (rows - 1) / 2) * dy;
});
- // Freeze each external peer's offset from the initial boundary centre
- // so it follows the boundary when that's recomputed mid-drag.
+ // External peers spread on a canvas-filling ellipse (skipping the
+ // bottom arc so the pod label stays clear), so the graph uses the
+ // whole widget instead of clustering near the centre.
positioned = [...inside, ...outside];
const b = insideBoundary();
- for (const n of outside) {
- n._ox = n.x - b.cx;
- n._oy = n.y - b.cy;
- }
+ const rx = Math.max(w / 2 - 64, b.r + 130);
+ const ry = Math.max(h / 2 - 52, b.r + 96);
+ const k = outside.length;
+ const START_DEG = 305;
+ const SPAN_DEG = 290; // leaves ~70° gap at the bottom
+ outside.forEach((node, i) => {
+ const t = k <= 1 ? 0.5 : i / (k - 1);
+ const rad = (((START_DEG + t * SPAN_DEG) % 360) * Math.PI) / 180;
+ node.x = o.x + rx * Math.cos(rad);
+ node.y = o.y + ry * Math.sin(rad);
+ });
return positioned;
}
-/** Max distance from `o` to any inside cell centre. */
-function insideExtent(inside: PositionedNode[], o: Pt): number {
- let r = 0;
- for (const n of inside) r = Math.max(r, Math.hypot(n.x - o.x, n.y - o.y));
- return r;
-}
-
/** Smallest flat-top hexagon (centre + circumradius) that wraps the
* inside cells. Circumradius is inflated past the cell extent because a
* flat-top hexagon's inradius (along its flat edges) is only √3/2 of
@@ -245,13 +202,6 @@ function redrawBoundary(): void {
const b = insideBoundary();
boundarySel?.attr('d', hexCellPath(b.cx, b.cy, b.r));
boundaryLabelSel?.attr('x', b.cx).attr('y', b.cy + b.r + 16);
- // External peers ride the boundary centre so they keep orbiting the
- // pod as inside cells are dragged around.
- for (const n of positioned) {
- if (isInside(n) || n._ox === undefined || n._oy === undefined) continue;
- n.x = b.cx + n._ox;
- n.y = b.cy + n._oy;
- }
}
function refreshPositions(): void {
edgeSel?.attr('d', edgePath);
@@ -271,7 +221,7 @@ function render(): void {
const h = rect.height || 520;
const o: Pt = { x: 0, y: 0 };
- positioned = layout(o);
+ positioned = layout(o, w, h);
const byId = new Map(positioned.map((n) => [n.id, n]));
const calls = buildCalls(byId);
@@ -401,11 +351,10 @@ function render(): void {
.on('drag', (ev, d) => {
d.x = ev.x;
d.y = ev.y;
- // Dragging an inside cell reshapes the boundary, which in turn
- // repositions the external peers — recompute that first, then
- // flush all transforms in one pass.
- if (isInside(d)) redrawBoundary();
refreshPositions();
+ // Dragging an inside cell reshapes the pod boundary; external
+ // peers stay put (anchored to the canvas).
+ if (isInside(d)) redrawBoundary();
}) as never,
);
nodeSel
@@ -422,7 +371,8 @@ function render(): void {
.attr('text-anchor', 'middle')
.attr('fill', 'var(--sw-fg-1, #d4d6dd)')
.style('font-family', 'var(--sw-mono, monospace)')
- .style('font-size', '13px')
+ .style('font-size', '16px')
+ .style('font-weight', '600')
.text((d) => {
const base = d.name.split('.')[0];
return base.length > 14 ? `${base.slice(0, 14)}…` : base;