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;

Reply via email to