This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/service-internal-topology
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 7cbcfe550779dd8a400e1ffa96c1f0a2fc7f9862
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 9 20:33:43 2026 +0800

    feat(layer): rank-based vertical-flow layout + live cluster boxes
    
    Default layout no longer puts every pod on one line. Pods are now ranked by
    longest path from a source over the intra-cluster pod call graph; rank maps
    to VERTICAL position (rank 0 on top) and same-rank pods spread horizontally,
    centred — so a chain flows straight down (hot→warm→cold) and multiple pods
    per tier sit side-by-side on one row (e.g. a hot row of 4). Clusters remain
    columns ordered upstream→downstream (liaison left, data right). Cycle/orphan
    pods fall to rank 0. This is the service-map's BFS, transposed.
    
    Cluster boxes are now derived from the LIVE node positions (including drag
    deltas) instead of the base grid, so a box always wraps its content and
    grows/moves when a pod inside it is dragged.
    
    Adds a 'banyandb-cluster-large' mock variant (4 hot / 2 warm / 2 cold + 2
    liaison) to preview the multi-pod-per-tier layout.
---
 .../bff/src/logic/layers/mock-internal-topology.ts |  40 ++++++++
 .../LayerServiceInternalTopologyView.vue           | 114 +++++++++++++++------
 2 files changed, 121 insertions(+), 33 deletions(-)

diff --git a/apps/bff/src/logic/layers/mock-internal-topology.ts 
b/apps/bff/src/logic/layers/mock-internal-topology.ts
index 4143969..2a35542 100644
--- a/apps/bff/src/logic/layers/mock-internal-topology.ts
+++ b/apps/bff/src/logic/layers/mock-internal-topology.ts
@@ -142,6 +142,45 @@ export function buildMockInternalTopology(serviceId: 
string, serviceName: string
   return { nodes, calls };
 }
 
+/** One data pod for a given tier index — pod name `data-<tier>-<idx>`. */
+function dataPodN(tier: 'hot' | 'warm' | 'cold', idx: number, diskPct: number, 
writeRps: number): ServiceInternalTopologyNode[] {
+  const pod = `data-${tier}-${idx}`;
+  return [
+    inst(pod, 'data', { node_role: 'data', node_type: tier }, { disk: diskPct, 
write: writeRps, series: Math.round(writeRps * 12) }),
+    inst(pod, 'lifecycle', { node_role: 'data', node_type: tier }, { sync: 
tier === 'hot' ? 8 : tier === 'warm' ? 3 : 1 }),
+    inst(pod, 'fodc', { node_role: 'data', node_type: tier }, { scrape: 14 + 
(tier === 'cold' ? 9 : 0) }),
+  ];
+}
+
+/** Larger variant — 2 liaison + 4 hot + 2 warm + 2 cold data pods — to show
+ *  the layout with multiple pods per tier (a hot row of 4, warm/cold rows of
+ *  2). Lifecycle tree: hot[i] → warm[i mod 2] → cold[j mod 2]. */
+export function buildMockInternalTopologyLarge(serviceId: string, serviceName: 
string | null): {
+  nodes: ServiceInternalTopologyNode[];
+  calls: ServiceInternalTopologyCall[];
+} {
+  const counts: Record<'hot' | 'warm' | 'cold', number> = { hot: 4, warm: 2, 
cold: 2 };
+  const disk = { hot: 71, warm: 48, cold: 86 } as const;
+  const write = { hot: 940, warm: 120, cold: 12 } as const;
+  const nodes: ServiceInternalTopologyNode[] = [...liaisonPod(0, 1820, 0.2), 
...liaisonPod(1, 1640, 0.9)];
+  (['hot', 'warm', 'cold'] as const).forEach((tier) => {
+    for (let i = 0; i < counts[tier]; i++) nodes.push(...dataPodN(tier, i, 
disk[tier] - i * 4, write[tier] - i * 30));
+  });
+  const SC = ['CLIENT', 'SERVER'];
+  const calls: ServiceInternalTopologyCall[] = [
+    call('liaison-0-liaison', 'liaison-1-liaison', SC),
+    call('liaison-1-liaison', 'liaison-0-liaison', SC),
+  ];
+  // liaison → hot data (spread the two liaisons across the four hot pods)
+  for (let i = 0; i < counts.hot; i++) calls.push(call(`liaison-${i % 
2}-liaison`, `data-hot-${i}-data`, SC));
+  // lifecycle tree: hot[i] -> warm[i mod warm] -> cold[j mod cold]
+  for (let i = 0; i < counts.hot; i++) 
calls.push(call(`data-hot-${i}-lifecycle`, `data-warm-${i % counts.warm}-data`, 
['SERVER']));
+  for (let j = 0; j < counts.warm; j++) 
calls.push(call(`data-warm-${j}-lifecycle`, `data-cold-${j % 
counts.cold}-data`, ['SERVER']));
+  void serviceId;
+  void serviceName;
+  return { nodes, calls };
+}
+
 /** Registry of named mocks the route can serve (config `mock` value). */
 export const MOCK_INTERNAL_TOPOLOGIES: Record<
   string,
@@ -151,4 +190,5 @@ export const MOCK_INTERNAL_TOPOLOGIES: Record<
   }
 > = {
   'banyandb-cluster': buildMockInternalTopology,
+  'banyandb-cluster-large': buildMockInternalTopologyLarge,
 };
diff --git a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue 
b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
index 66058d0..fcf9f36 100644
--- a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
@@ -246,18 +246,19 @@ const CLUSTER_GAP_Y = 56;
 const CLUSTER_PAD = 24;
 const HEAD_H = 30;
 const MAX_ROW_W = 1280;
-const MAX_PODS_PER_ROW = 6;
 interface Pos { cx: number; cy: number; r: number }
 interface ClusterRect { key: string | null; label: string; x: number; y: 
number; w: number; h: number; boxed: boolean }
 interface Layout { pos: Map<string, Pos>; rects: ClusterRect[]; w: number; h: 
number; nodeToPod: Map<string, string> }
 
 const podIdOf = (clusterKey: string | null, siblingKey: string): string => 
`${clusterKey ?? ''}␟${siblingKey}`;
 
-/** Order a cluster's pods upstream→downstream (Kahn topological sort over the
- *  intra-cluster pod call graph; cycle/leftover pods appended by name). This
- *  is the service-map's flow ordering applied at the pod level — chains like
- *  hot→warm→cold lay out left-to-right so their edges don't cross hexes. */
-function topoOrderPods(podIds: string[], edges: Array<[string, string]>, 
nameOf: (id: string) => string): string[] {
+/** Rank each pod by its longest path from a source (a pod with no incoming
+ *  intra-cluster edge), over the intra-cluster pod call graph. Rank maps to
+ *  VERTICAL position (rank 0 = top), so a chain like hot→warm→cold flows
+ *  straight down; same-rank pods spread horizontally. Cycle-only / unreached
+ *  pods (e.g. a liaison↔liaison pair) fall to rank 0. Ranks are densified so
+ *  there are no empty rows. Mirrors the service-map's BFS, transposed. */
+function rankPods(podIds: string[], edges: Array<[string, string]>): 
Map<string, number> {
   const idSet = new Set(podIds);
   const succ = new Map<string, string[]>();
   const inDeg = new Map<string, number>();
@@ -267,24 +268,26 @@ function topoOrderPods(podIds: string[], edges: 
Array<[string, string]>, nameOf:
     succ.get(s)!.push(t);
     inDeg.set(t, (inDeg.get(t) ?? 0) + 1);
   }
-  const byName = (a: string, b: string): number => 
nameOf(a).localeCompare(nameOf(b));
-  const queue = podIds.filter((id) => (inDeg.get(id) ?? 0) === 0).sort(byName);
-  const out: string[] = [];
+  const rank = new Map<string, number>(podIds.map((id) => [id, 0]));
+  // Longest-path relaxation seeded from sources; bounded so cycles can't loop.
+  const remaining = new Map(inDeg);
+  const queue = podIds.filter((id) => (inDeg.get(id) ?? 0) === 0);
   const seen = new Set<string>();
   while (queue.length) {
     const id = queue.shift()!;
     if (seen.has(id)) continue;
     seen.add(id);
-    out.push(id);
-    const next = succ.get(id)!.filter((t) => !seen.has(t)).sort(byName);
-    for (const t of next) {
-      inDeg.set(t, (inDeg.get(t) ?? 0) - 1);
-      if ((inDeg.get(t) ?? 0) <= 0) queue.push(t);
+    for (const t of succ.get(id)!) {
+      rank.set(t, Math.max(rank.get(t) ?? 0, (rank.get(id) ?? 0) + 1));
+      remaining.set(t, (remaining.get(t) ?? 0) - 1);
+      if ((remaining.get(t) ?? 0) <= 0) queue.push(t);
     }
   }
-  // Cycle-only / leftover pods (never reached) — append deterministically.
-  for (const id of [...podIds].sort(byName)) if (!seen.has(id)) out.push(id);
-  return out;
+  // Densify ranks → no empty rows.
+  const used = [...new Set(rank.values())].sort((a, b) => a - b);
+  const dense = new Map(used.map((r, i) => [r, i]));
+  for (const [id, r] of rank) rank.set(id, dense.get(r) ?? 0);
+  return rank;
 }
 
 const layout = computed<Layout>(() => {
@@ -341,12 +344,18 @@ const layout = computed<Layout>(() => {
   for (const cl of orderedClusters) {
     const podById = new Map(cl.pods.map((p) => [podIdOf(cl.key, p.siblingKey), 
p]));
     const ids = [...podById.keys()];
-    const order = topoOrderPods(ids, intraByCluster.get(cl.key ?? '') ?? [], 
(id) => podById.get(id)!.main.name);
-    const cols = Math.max(1, Math.min(MAX_PODS_PER_ROW, order.length));
-    const rows = Math.max(1, Math.ceil(order.length / cols));
-    const boxW = cols * POD_DX + CLUSTER_PAD * 2;
+    const rank = rankPods(ids, intraByCluster.get(cl.key ?? '') ?? []);
+    // Group pods into rows by rank (rank 0 = top); within a rank order by main
+    // name and spread horizontally, centred — so a chain flows straight down
+    // and same-tier pods (e.g. 4 hot) sit side-by-side on one row.
+    const maxRank = Math.max(0, ...ids.map((id) => rank.get(id) ?? 0));
+    const rankRows: string[][] = Array.from({ length: maxRank + 1 }, () => []);
+    for (const id of ids) rankRows[rank.get(id) ?? 0].push(id);
+    for (const row of rankRows) row.sort((a, b) => 
podById.get(a)!.main.name.localeCompare(podById.get(b)!.main.name));
+    const maxRowLen = Math.max(1, ...rankRows.map((r) => r.length));
     const headH = showBoxes ? HEAD_H : 0;
-    const boxH = rows * POD_DY + CLUSTER_PAD * 2 + headH;
+    const boxW = maxRowLen * POD_DX + CLUSTER_PAD * 2;
+    const boxH = (maxRank + 1) * POD_DY + CLUSTER_PAD * 2 + headH;
     if (cursorX > 0 && cursorX + boxW > MAX_ROW_W) {
       cursorX = 0;
       cursorY += rowMaxH + CLUSTER_GAP_Y;
@@ -354,16 +363,18 @@ const layout = computed<Layout>(() => {
     }
     const boxX = cursorX;
     const boxY = cursorY;
-    order.forEach((pid, i) => {
-      const pod = podById.get(pid)!;
-      const col = i % cols;
-      const row = Math.floor(i / cols);
-      const cx = boxX + CLUSTER_PAD + col * POD_DX + POD_DX / 2;
-      const cy = boxY + headH + CLUSTER_PAD + row * POD_DY + POD_DY / 2 - 10;
-      pos.set(pod.main.id, { cx, cy, r: MAIN_R });
-      pod.siblings.slice(0, SIB_ANGLES.length).forEach((sib, j) => {
-        const a = SIB_ANGLES[j];
-        pos.set(sib.id, { cx: cx + Math.cos(a) * SIB_DIST, cy: cy + 
Math.sin(a) * SIB_DIST, r: SIB_R });
+    const innerCx = boxX + boxW / 2;
+    rankRows.forEach((row, r) => {
+      const n = row.length;
+      row.forEach((pid, k) => {
+        const pod = podById.get(pid)!;
+        const cx = innerCx + (k - (n - 1) / 2) * POD_DX;
+        const cy = boxY + headH + CLUSTER_PAD + r * POD_DY + POD_DY / 2 - 10;
+        pos.set(pod.main.id, { cx, cy, r: MAIN_R });
+        pod.siblings.slice(0, SIB_ANGLES.length).forEach((sib, j) => {
+          const a = SIB_ANGLES[j];
+          pos.set(sib.id, { cx: cx + Math.cos(a) * SIB_DIST, cy: cy + 
Math.sin(a) * SIB_DIST, r: SIB_R });
+        });
       });
     });
     rects.push({ key: cl.key, label: cl.label, x: boxX, y: boxY, w: boxW, h: 
boxH, boxed: showBoxes });
@@ -392,6 +403,43 @@ const H = computed(() => layout.value.h);
 function posR(id: string): number {
   return pos.value.get(id)?.r ?? MAIN_R;
 }
+// Cluster boxes are derived from the LIVE node positions (which include drag
+// deltas), not the base grid — so a box always wraps its content and grows /
+// moves when a pod inside it is dragged. Padding leaves room for the header
+// band (top) and the sibling labels (bottom).
+const BOX_HEAD_BAND = 34;
+const BOX_PAD_X = 22;
+const BOX_PAD_TOP = 10;
+const BOX_PAD_BOTTOM = 28;
+const clusterRects = computed<ClusterRect[]>(() => {
+  if (!clusterBy.value) return [];
+  const out: ClusterRect[] = [];
+  for (const cl of clusters.value) {
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, 
any = false;
+    for (const pod of cl.pods) {
+      for (const node of [pod.main, ...pod.siblings]) {
+        const p = pos.value.get(node.id);
+        if (!p) continue;
+        any = true;
+        minX = Math.min(minX, p.cx - p.r);
+        maxX = Math.max(maxX, p.cx + p.r);
+        minY = Math.min(minY, p.cy - p.r);
+        maxY = Math.max(maxY, p.cy + p.r);
+      }
+    }
+    if (!any) continue;
+    out.push({
+      key: cl.key,
+      label: cl.label,
+      x: minX - BOX_PAD_X,
+      y: minY - BOX_HEAD_BAND - BOX_PAD_TOP,
+      w: maxX - minX + BOX_PAD_X * 2,
+      h: maxY - minY + BOX_HEAD_BAND + BOX_PAD_TOP + BOX_PAD_BOTTOM,
+      boxed: true,
+    });
+  }
+  return out;
+});
 function isSiblingNode(n: ServiceInternalTopologyNode): boolean {
   return posR(n.id) < MAIN_R - 6;
 }
@@ -672,7 +720,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKeyDown, true));
           <svg ref="svgEl" class="sit-svg" width="100%" height="100%">
             <g ref="zoomLayerEl" :class="{ 'has-pop': !!popoverNodeId }">
               <g class="sit-groups">
-                <g v-for="(g, gi) in layout.rects" :key="g.key ?? `__${gi}`" 
:transform="`translate(${g.x}, ${g.y})`">
+                <g v-for="(g, gi) in clusterRects" :key="g.key ?? `__${gi}`" 
:transform="`translate(${g.x}, ${g.y})`">
                   <template v-if="g.boxed">
                     <rect :width="g.w" :height="g.h" rx="14" ry="14" 
class="sit-grp-rect" />
                     <text x="16" y="20" class="sit-grp-head">

Reply via email to