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 bbcfe31 ui: network-profiling topology — drop namespace grouping,
auto-fit pod hexagon
bbcfe31 is described below
commit bbcfe3180e8acd27790cfd59edaa9f020465647a
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 10:47:17 2026 +0800
ui: network-profiling topology — drop namespace grouping, auto-fit pod
hexagon
Per design feedback:
- Removed the namespace group concept (tinted group regions, legend,
per-namespace node tinting + labels) and its config plumbing
(ProcessTopologyConfig.groupExpression, LayerDef.processTopology,
the admin group-expression field). The edge-metric config
(edgeClient/edgeServer) stays — only grouping is gone.
- Inside cells are accent-tinted, external cells grey (uniform again).
- The dashed pod-boundary hexagon now auto-fits the inside processes
as tightly as possible: centre = inside-cell centroid, circumradius
= max cell distance × 1.16 (the flat-top inradius factor) so cells
near a flat edge still fit. Outside peer rings start past the fitted
boundary instead of a fixed radius.
- The boundary is recomputed live while an inside node is dragged
(redrawBoundary on each drag tick) so it keeps wrapping the cluster.
Node popover + animated directional edges + edge dashboard modal are
unchanged.
---
apps/bff/src/http/query/menu.ts | 3 -
.../admin/layer-templates/LayerDashboardsAdmin.vue | 22 ---
.../layer/profiling/LayerNetworkProfilingView.vue | 7 -
.../src/layer/profiling/ProcessTopologyGraph.vue | 204 +++++++--------------
packages/api-client/src/menu.ts | 5 -
packages/api-client/src/topology.ts | 6 -
6 files changed, 66 insertions(+), 181 deletions(-)
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index d9db665..2eae4d0 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -182,9 +182,6 @@ function deriveLayer(
overview: tpl.overview,
log: tpl.log,
traces: tpl.traces,
- processTopology: tpl.processTopology
- ? { groupExpression: tpl.processTopology.groupExpression }
- : undefined,
naming: tpl.naming,
};
}
diff --git
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index a59973d..874842c 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -636,16 +636,6 @@ const processEdgeClientMetrics = computed(() =>
const processEdgeServerMetrics = computed(() =>
activeScope.value === 'networkProfiling' ? getMetricList('edgeServer') : [],
);
-const processGroupExpression = computed<string>({
- get: () =>
- activeScope.value === 'networkProfiling'
- ? (draft.template?.processTopology?.groupExpression ?? '')
- : '',
- set: (v: string) => {
- const t = ensureProcessTopology();
- t.groupExpression = v.trim() || undefined;
- },
-});
/* Trace backend selector. `traces.source` decides which trace store the
* per-layer Trace tab dispatches to: `native` (SkyWalking query-protocol),
@@ -1589,18 +1579,6 @@ const namingTest = computed<NamingTestResult>(() => {
<h4>Network profiling — process-relation config</h4>
<span class="sub">edge MQE for the process-topology detail panel.
Queried under ProcessRelation when an operator clicks a process→process
call.</span>
</div>
- <div class="naming-prefix-row">
- <label class="mf mf-wide">
- <span>Group expression</span>
- <input
- v-model="processGroupExpression"
- type="text"
- class="mf-input mono"
- placeholder="(optional regex — 1st capture = namespace;
default: text after last '.')"
- />
- </label>
- <span class="naming-prefix-hint">Groups the topology honeycomb by
namespace (k8s <code>name.namespace</code> convention).</span>
- </div>
<div class="topo-cfg-body">
<div class="topo-cfg-section">
<header class="topo-cfg-head">
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index 44a7f12..f47cdc9 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -35,11 +35,9 @@ import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useLayerInstances } from '@/layer/useLayerInstances';
import { useSelectedService } from '@/layer/useSelectedService';
-import { useLayers } from '@/shell/useLayers';
import { bffClient } from '@/api/client';
import type {
EBPFTask,
- LayerDef,
NetworkProfilingSampling,
ProcessCall,
ProcessNode,
@@ -53,10 +51,6 @@ import Icon from '@/components/icons/Icon.vue';
const route = useRoute();
const layerKey = computed(() => String(route.params.layerKey ?? ''));
const { selectedId: serviceId } = useSelectedService();
-const { layers } = useLayers();
-const layer = computed<LayerDef | null>(
- () => layers.value.find((l) => l.key === layerKey.value) ?? null,
-);
// Instance picker (binds to ?serviceInstance= via plain ref state — the
// network view needs an *instance* to be useful, so we don't reuse the
@@ -375,7 +369,6 @@ function fmtTime(ms: number): string {
v-if="nodes.length"
:nodes="nodes"
:calls="calls"
- :group-expression="layer?.processTopology?.groupExpression"
@select-call="onSelectCall"
/>
<div v-else-if="!topologyLoading" class="topology-empty">
diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index cdb0213..2e8e22a 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -15,18 +15,14 @@
limitations under the License.
-->
<!--
- Process-level topology for network profiling — honeycomb + namespace
- grouping, the same vocabulary as the k8s service topology.
+ Process-level topology for network profiling.
- Inside-pod processes (`isReal` / synthetic `UNKNOWN_LOCAL`) and
- external peers are each rendered as a flat-top hexagon CELL packed
- in a honeycomb (inside) or on rings (outside) within / around the
- pod-boundary hexagon.
- - Nodes are grouped by NAMESPACE: by default the text after the last
- `.` in the name (`demo-oap-xxx.skywalking-showcase` → `skywalking-
- showcase`); an optional `groupExpression` regex overrides it (first
- capture group wins). Each namespace gets a tinted region backdrop +
- label + a colour in the legend.
+ external peers are each rendered as a flat-top hexagon CELL. Inside
+ cells pack a honeycomb around the centre; external peers ring it.
+ - A dashed hexagon boundary wraps the inside processes as tightly as
+ possible (auto-fit to the inside cells) and is recomputed live while
+ a node is dragged.
- Edges are directed, animated (dashes flow source→target to show
traffic direction) and clickable.
- Clicking a node shows a floating info popover; clicking an edge
@@ -41,7 +37,7 @@ import * as d3 from 'd3';
import type { ProcessCall, ProcessNode } from '@/api/client';
interface Pt { x: number; y: number }
-type PositionedNode = ProcessNode & Pt & { ns: string };
+type PositionedNode = ProcessNode & Pt;
interface PositionedCall {
id: string;
source: PositionedNode;
@@ -50,43 +46,14 @@ interface PositionedCall {
lowerArc: boolean;
}
-const props = defineProps<{
- nodes: ProcessNode[];
- calls: ProcessCall[];
- groupExpression?: string;
-}>();
+const props = defineProps<{ nodes: ProcessNode[]; calls: ProcessCall[] }>();
const emit = defineEmits<{ (e: 'select-call', c: ProcessCall | null): void
}>();
const host = ref<HTMLDivElement | null>(null);
const selectedCallId = ref<string | null>(null);
-// ── Namespace grouping ──────────────────────────────────────────────
-const NS_LOCAL = '·local';
-function namespaceOf(name: string): string {
- const expr = props.groupExpression?.trim();
- if (expr) {
- try {
- const m = new RegExp(expr).exec(name);
- if (m) return m[1] || m[0] || NS_LOCAL;
- } catch {
- /* invalid regex — fall through to the dot heuristic */
- }
- }
- const i = name.lastIndexOf('.');
- return i > 0 ? name.slice(i + 1) : NS_LOCAL;
-}
-function hueOf(ns: string): number {
- let h = 0;
- for (let i = 0; i < ns.length; i++) h = (h * 31 + ns.charCodeAt(i)) | 0;
- return Math.abs(h) % 360;
-}
-function groupColor(ns: string): string {
- return ns === NS_LOCAL ? 'var(--sw-accent, #f97316)' : `hsl(${hueOf(ns)},
55%, 56%)`;
-}
-
// ── Hex geometry (flat-top) ─────────────────────────────────────────
const SQRT3 = Math.sqrt(3);
-const HEX_RADIUS = 210;
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 };
}
@@ -124,10 +91,7 @@ function hexCellPath(cx: number, cy: number, R: number):
string {
const a = Math.PI * 2 * (i / 6);
v.push([cx + Math.cos(a) * R, cy + Math.sin(a) * R]);
}
- return (d3.line().curve(d3.curveLinearClosed)(v) ?? '');
-}
-function hexBoundaryPath(r: number, o: Pt): string {
- return hexCellPath(o.x, o.y, r);
+ return d3.line().curve(d3.curveLinearClosed)(v) ?? '';
}
function isInside(n: ProcessNode): boolean {
@@ -141,23 +105,17 @@ function protocolOf(c: ProcessCall): string {
return 'TCP';
}
-// Live legend entries (namespace → colour) for the overlay.
-const legend = ref<Array<{ ns: string; color: string; count: number }>>([]);
let cellRadius = 26;
+let positioned: PositionedNode[] = [];
function layout(o: Pt): PositionedNode[] {
- const withNs = (n: ProcessNode): PositionedNode =>
- ({ ...n, ns: namespaceOf(n.name) }) as PositionedNode;
- // Sort by namespace so the spiral / ring fill keeps a group contiguous.
- const byNs = (a: PositionedNode, b: PositionedNode) =>
a.ns.localeCompare(b.ns) || a.name.localeCompare(b.name);
-
- const inside = props.nodes.filter(isInside).map(withNs).sort(byNs);
- const outside = props.nodes.filter((n) =>
!isInside(n)).map(withNs).sort(byNs);
-
- // Inside honeycomb. Pick a cell size that fits the spiral inside the
- // pod boundary: the outermost ring index ~ sqrt(count).
- const rings = Math.max(1, Math.ceil((-3 + Math.sqrt(9 + 12 * (inside.length
- 1))) / 6));
- cellRadius = Math.max(14, Math.min(34, (HEX_RADIUS * 0.82) / ((rings + 0.6)
* SQRT3)));
+ 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 };
@@ -166,8 +124,9 @@ function layout(o: Pt): PositionedNode[] {
n.y = p.y;
});
- // Outside peers on expanding rings.
- let r = HEX_RADIUS + 60;
+ // 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]) {
@@ -179,16 +138,30 @@ function layout(o: Pt): PositionedNode[] {
n.y = p.y;
});
- // Build legend (sorted, local group last).
- const counts = new Map<string, number>();
- for (const n of [...inside, ...outside]) counts.set(n.ns, (counts.get(n.ns)
?? 0) + 1);
- legend.value = [...counts.entries()]
- .sort((a, b) => (a[0] === NS_LOCAL ? 1 : b[0] === NS_LOCAL ? -1 :
a[0].localeCompare(b[0])))
- .map(([ns, count]) => ({ ns, color: groupColor(ns), count }));
-
return [...inside, ...outside];
}
+/** 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
+ * the circumradius — so cells sitting near a flat edge still fit. */
+function insideBoundary(): { cx: number; cy: number; r: number } {
+ const inside = positioned.filter(isInside);
+ if (inside.length === 0) return { cx: 0, cy: 0, r: cellRadius * 1.4 };
+ const cx = d3.mean(inside, (n) => n.x) ?? 0;
+ const cy = d3.mean(inside, (n) => n.y) ?? 0;
+ let maxD = 0;
+ for (const n of inside) maxD = Math.max(maxD, Math.hypot(n.x - cx, n.y -
cy));
+ return { cx, cy, r: (maxD + cellRadius) * 1.16 + 4 };
+}
+
function buildCalls(byId: Map<string, PositionedNode>): PositionedCall[] {
const seen = new Set<string>();
const out: PositionedCall[] = [];
@@ -244,6 +217,8 @@ const popStyle = computed(() => {
let edgeSel: d3.Selection<SVGPathElement, PositionedCall, SVGGElement,
unknown> | null = null;
let pillSel: d3.Selection<SVGGElement, PositionedCall, SVGGElement, unknown> |
null = null;
let nodeSel: d3.Selection<SVGGElement, PositionedNode, SVGGElement, unknown> |
null = null;
+let boundarySel: d3.Selection<SVGPathElement, unknown, null, undefined> | null
= null;
+let boundaryLabelSel: d3.Selection<SVGTextElement, unknown, null, undefined> |
null = null;
function instanceLabel(): string {
return props.nodes.find(isInside)?.serviceInstanceName ?? '';
@@ -255,6 +230,11 @@ function restyleEdges(): void {
)
.attr('stroke-width', (d) => (d.id === selectedCallId.value ? 2.6 : 1.5));
}
+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);
+}
function refreshPositions(): void {
edgeSel?.attr('d', edgePath);
pillSel?.attr('transform', (d) => {
@@ -273,7 +253,7 @@ function render(): void {
const h = rect.height || 520;
const o: Pt = { x: 0, y: 0 };
- const positioned = layout(o);
+ positioned = layout(o);
const byId = new Map(positioned.map((n) => [n.id, n]));
const calls = buildCalls(byId);
@@ -307,55 +287,26 @@ function render(): void {
.attr('d', 'M0,-5 L10,0 L0,5')
.attr('fill', 'var(--sw-fg-3, #6c7080)');
- // Pod boundary.
- g.append('path')
- .attr('d', hexBoundaryPath(HEX_RADIUS, o))
+ // Pod boundary — auto-fit to the inside cells, redrawn on drag.
+ const b0 = insideBoundary();
+ boundarySel = g
+ .append('path')
+ .attr('d', hexCellPath(b0.cx, b0.cy, b0.r))
.attr('fill', 'var(--sw-bg-1, #15171c)')
.attr('fill-opacity', 0.3)
.attr('stroke', 'var(--sw-line, #2a2d36)')
.attr('stroke-dasharray', '4 4')
.attr('stroke-width', 1.5);
- g.append('text')
- .attr('x', o.x)
- .attr('y', o.y + HEX_RADIUS + 18)
+ boundaryLabelSel = g
+ .append('text')
+ .attr('x', b0.cx)
+ .attr('y', b0.cy + b0.r + 16)
.attr('text-anchor', 'middle')
.attr('fill', 'var(--sw-fg-2, #b4b7c2)')
.style('font-family', 'var(--sw-mono, monospace)')
.style('font-size', '11px')
.text(instanceLabel());
- // Namespace group regions (bounding circle behind each group's cells)
- // + label, drawn beneath nodes — the k8s-topology grouping idiom.
- const groups = d3.group(positioned, (n) => n.ns);
- const groupG = g.append('g').attr('class', 'groups');
- for (const [ns, members] of groups) {
- if (ns === NS_LOCAL) continue; // pod-local processes already sit in the
boundary
- const cx = d3.mean(members, (m) => m.x) ?? 0;
- const cy = d3.mean(members, (m) => m.y) ?? 0;
- const rad = Math.max(...members.map((m) => Math.hypot(m.x - cx, m.y -
cy))) + cellRadius + 8;
- groupG
- .append('circle')
- .attr('cx', cx)
- .attr('cy', cy)
- .attr('r', rad)
- .attr('fill', groupColor(ns))
- .attr('fill-opacity', 0.07)
- .attr('stroke', groupColor(ns))
- .attr('stroke-opacity', 0.35)
- .attr('stroke-dasharray', '3 3')
- .attr('stroke-width', 1);
- groupG
- .append('text')
- .attr('x', cx)
- .attr('y', cy - rad - 4)
- .attr('text-anchor', 'middle')
- .attr('fill', groupColor(ns))
- .style('font-family', 'var(--sw-mono, monospace)')
- .style('font-size', '10px')
- .style('font-weight', '600')
- .text(ns);
- }
-
// Edges (animated flow).
const linkG = g.append('g').attr('class', 'links');
edgeSel = linkG
@@ -407,7 +358,7 @@ function render(): void {
.style('font-size', '9px')
.text((d) => d.protocol);
- // Nodes — hex cells tinted by namespace.
+ // Nodes — hex cells (inside = accent, external = grey).
nodeSel = g
.append('g')
.attr('class', 'nodes')
@@ -426,18 +377,21 @@ function render(): void {
.call(
d3
.drag<SVGGElement, PositionedNode>()
- .on('start', () => { nodePop.node = null; })
+ .on('start', () => {
+ nodePop.node = null;
+ })
.on('drag', (ev, d) => {
d.x = ev.x;
d.y = ev.y;
refreshPositions();
+ if (isInside(d)) redrawBoundary();
}) as never,
);
nodeSel
.append('path')
.attr('d', (d) => hexCellPath(0, 0, isInside(d) ? cellRadius * 0.92 : 18))
- .attr('fill', (d) => groupColor(d.ns))
- .attr('fill-opacity', (d) => (isInside(d) ? 0.85 : 0.65))
+ .attr('fill', (d) => (isInside(d) ? 'var(--sw-accent, #f97316)' :
'var(--sw-bg-3, #2a2d36)'))
+ .attr('fill-opacity', (d) => (isInside(d) ? 0.85 : 0.75))
.attr('stroke', 'var(--sw-bg-0, #0d0f14)')
.attr('stroke-width', 1.5);
nodeSel
@@ -458,7 +412,7 @@ function render(): void {
}
onMounted(render);
-watch(() => [props.nodes, props.calls, props.groupExpression], render);
+watch(() => [props.nodes, props.calls], render);
onBeforeUnmount(() => {
if (host.value) host.value.innerHTML = '';
});
@@ -467,21 +421,12 @@ onBeforeUnmount(() => {
<template>
<div class="topo-wrap">
<div ref="host" class="topo-host"></div>
- <!-- Namespace legend -->
- <div v-if="legend.length" class="topo-legend">
- <div v-for="l in legend" :key="l.ns" class="lg-row">
- <span class="lg-swatch" :style="{ background: l.color }"></span>
- <span class="lg-ns">{{ l.ns }}</span>
- <span class="lg-count">{{ l.count }}</span>
- </div>
- </div>
<!-- Node info floating popover -->
<Teleport to="body">
<div v-if="nodePop.node" class="topo-nodepop" :style="popStyle"
role="tooltip">
<div class="np-name">{{ nodePop.node.name }}</div>
<dl class="np-rows">
<div class="np-row"><dt>Kind</dt><dd>{{ nodePop.node.isReal ?
'process' : 'virtual peer' }}</dd></div>
- <div class="np-row"><dt>Namespace</dt><dd>{{ nodePop.node.ns
}}</dd></div>
<div class="np-row"><dt>Service</dt><dd>{{ nodePop.node.serviceName
}}</dd></div>
<div class="np-row"><dt>Instance</dt><dd>{{
nodePop.node.serviceInstanceName }}</dd></div>
</dl>
@@ -507,23 +452,6 @@ onBeforeUnmount(() => {
@keyframes topo-flow {
to { stroke-dashoffset: -11; }
}
-.topo-legend {
- position: absolute;
- top: 8px;
- right: 8px;
- max-height: calc(100% - 16px);
- overflow-y: auto;
- background: color-mix(in srgb, var(--sw-bg-1) 88%, transparent);
- border: 1px solid var(--sw-line);
- border-radius: 6px;
- padding: 6px 8px;
- font-size: 10.5px;
- pointer-events: none;
-}
-.lg-row { display: grid; grid-template-columns: 12px 1fr auto; gap: 6px;
align-items: center; padding: 1px 0; }
-.lg-swatch { width: 10px; height: 10px; border-radius: 2px; }
-.lg-ns { color: var(--sw-fg-1); font-family: var(--sw-mono); }
-.lg-count { color: var(--sw-fg-3); font-variant-numeric: tabular-nums; }
</style>
<style>
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 75d32b0..97759dc 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -258,11 +258,6 @@ export interface LayerDef {
* spans (Envoy ALS, rover) so they set `source: 'zipkin'`; agent-
* traced layers default to `native`. */
traces?: { source?: 'native' | 'zipkin' | 'both' };
- /** Network-profiling process-topology hints the UI needs client-side.
- * Only `groupExpression` rides along (the MQE metric lists are
- * resolved server-side by the BFF). Drives the namespace grouping in
- * the process-topology honeycomb. */
- processTopology?: { groupExpression?: string };
/** Per-layer service-name parsing rule. When present, the UI runs
* every service name through this regex to derive `{ display, cluster }`
* and clusters topology nodes by cluster. Absent ⇒ no clustering. */
diff --git a/packages/api-client/src/topology.ts
b/packages/api-client/src/topology.ts
index 4c6e969..4cfd970 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -119,12 +119,6 @@ export interface ProcessTopologyConfig {
/** Per-edge MQE under ProcessRelation, server side
* (`process_relation_server_*`). */
edgeServerMetrics: TopologyMetricDef[];
- /** Optional regex used to derive the namespace each process node is
- * grouped under in the topology honeycomb. The first capture group
- * wins; when absent (or no match) the renderer falls back to the
- * text after the last `.` in the process name (the k8s
- * `name.namespace` convention). */
- groupExpression?: string;
}
/** One resolved process-relation metric series for the edge panel. */