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">
