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 205bc4f ui: network-profiling topology — honeycomb + namespace
groups, legend, animated edges, node popover, edge dashboard modal; OTel/Zipkin
labels
205bc4f is described below
commit 205bc4f387122492f7ade10ca7ed85112b70ddae
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 10:37:35 2026 +0800
ui: network-profiling topology — honeycomb + namespace groups, legend,
animated edges, node popover, edge dashboard modal; OTel/Zipkin labels
Process topology (ProcessTopologyGraph), bringing it in line with the
k8s service-topology vocabulary the user asked for:
- Nodes render as flat-top hexagon CELLS. Inside-pod processes pack a
honeycomb (spiral hex fill, sorted by namespace so groups stay
contiguous); external peers ring the pod boundary.
- Namespace grouping: default = text after the last '.' in the process
name (k8s `name.namespace`); an optional `groupExpression` regex
overrides it (first capture wins). Each namespace gets a tinted
region backdrop + label and a colour in a new legend overlay.
- Edges are animated (dashes flow source→target to show traffic
direction) + directed + clickable.
- Node click opens a floating info popover (teleported, cursor-anchored).
- Edge click opens a LARGE dashboard modal in the view: full
process-relation widget grid (client + server) rendered as TimeChart
line charts, replacing the old inline sparkline panel.
Config plumbing:
- ProcessTopologyConfig.groupExpression (api-client); LayerDef carries
`processTopology.groupExpression` (BFF deriveLayer threads it); admin
network-profiling editor gets a Group-expression field.
Trace source labels:
- Rename the Zipkin trace surface to "OpenTelemetry & Zipkin" (admin
selector) / "OTel & Zipkin Trace(s)" (sidebar tabs) — the Zipkin tab
ingests the OTel + Zipkin span ecosystem, not Zipkin alone.
---
apps/bff/src/http/query/menu.ts | 3 +
.../admin/layer-templates/LayerDashboardsAdmin.vue | 26 +-
.../layer/profiling/LayerNetworkProfilingView.vue | 151 ++++---
.../src/layer/profiling/ProcessTopologyGraph.vue | 468 +++++++++++++--------
apps/ui/src/shell/AppSidebar.vue | 4 +-
packages/api-client/src/menu.ts | 5 +
packages/api-client/src/topology.ts | 6 +
7 files changed, 410 insertions(+), 253 deletions(-)
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 2eae4d0..d9db665 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -182,6 +182,9 @@ 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 1321082..a59973d 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -636,6 +636,16 @@ 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),
@@ -653,8 +663,8 @@ const traceSource = computed<TraceSource>({
});
const TRACE_SOURCE_OPTIONS: Array<{ value: TraceSource; label: string; hint:
string }> = [
{ value: 'native', label: 'Native', hint: 'SkyWalking query-protocol traces
(agent-instrumented).' },
- { value: 'zipkin', label: 'Zipkin', hint: 'Traces emitted from the Zipkin &
OpenTelemetry ecosystem.' },
- { value: 'both', label: 'Both', hint: 'Layer carries both native and Zipkin
traces — their span formats and query conditions differ, so each gets its own
trace tab.' },
+ { value: 'zipkin', label: 'OpenTelemetry & Zipkin', hint: 'Traces emitted
from the OpenTelemetry & Zipkin ecosystem.' },
+ { value: 'both', label: 'Both', hint: 'Layer carries both native and
OpenTelemetry/Zipkin traces — their span formats and query conditions differ,
so each gets its own trace tab.' },
];
/* Logs has no per-layer config beyond the enable/disable Components
@@ -1579,6 +1589,18 @@ 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 28baf96..44a7f12 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -35,9 +35,11 @@ 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,
@@ -45,12 +47,16 @@ import type {
ProcessRelationMetricsResponse,
} from '@/api/client';
import ProcessTopologyGraph from '@/layer/profiling/ProcessTopologyGraph.vue';
-import Sparkline from '@/components/charts/Sparkline.vue';
+import TimeChart from '@/components/charts/TimeChart.vue';
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
@@ -130,8 +136,15 @@ async function loadTopology(): Promise<void> {
}
}
-const selectedNode = ref<ProcessNode | null>(null);
+// Edge selection drives the dashboard modal. Node info is shown by the
+// graph's own floating popover, so the view only tracks the edge.
const selectedCall = ref<ProcessCall | null>(null);
+function onSelectCall(c: ProcessCall | null): void {
+ selectedCall.value = c;
+}
+function closeEdge(): void {
+ selectedCall.value = null;
+}
// ── Edge (process-relation) metrics ────────────────────────────────
const relationMetrics = ref<ProcessRelationMetricsResponse | null>(null);
@@ -362,8 +375,8 @@ function fmtTime(ms: number): string {
v-if="nodes.length"
:nodes="nodes"
:calls="calls"
- @select-node="selectedNode = $event; selectedCall = null"
- @select-call="selectedCall = $event; selectedNode = null"
+ :group-expression="layer?.processTopology?.groupExpression"
+ @select-call="onSelectCall"
/>
<div v-else-if="!topologyLoading" class="topology-empty">
{{ selectedInstanceId
@@ -371,54 +384,47 @@ function fmtTime(ms: number): string {
: 'Pick an instance to view its process topology.' }}
</div>
</div>
+ </div>
+ </div>
- <div v-if="selectedNode || selectedCall" class="detail">
- <div v-if="selectedNode">
- <h5>Process</h5>
- <dl class="kv">
- <dt>Name</dt><dd>{{ selectedNode.name }}</dd>
- <dt>Real?</dt><dd>{{ selectedNode.isReal ? 'yes' : 'virtual peer'
}}</dd>
- <dt>Service</dt><dd>{{ selectedNode.serviceName }}</dd>
- <dt>Instance</dt><dd>{{ selectedNode.serviceInstanceName }}</dd>
- <dt>ID</dt><dd class="mono">{{ selectedNode.id }}</dd>
- </dl>
+ <!-- Edge dashboard — a large modal showing the full process-relation
+ metric dashboard (client + server) for the clicked conversation. -->
+ <div v-if="selectedCall" class="dlg-mask" @click.self="closeEdge">
+ <div class="dlg edge-dlg">
+ <div class="dlg-head">
+ <div class="edge-dlg-title">
+ <span class="mono">{{ sourceProcessName }}</span>
+ <span class="muted">→</span>
+ <span class="mono">{{ targetProcessName }}</span>
+ <span v-if="selectedCall.detectPoints?.length" class="dp-chip">{{
selectedCall.detectPoints.join(' · ') }}</span>
</div>
- <div v-else-if="selectedCall">
- <h5>Conversation</h5>
- <div class="edge-pair">
- <span class="mono">{{ sourceProcessName }}</span>
- <span class="muted">→</span>
- <span class="mono">{{ targetProcessName }}</span>
- </div>
- <dl class="kv">
- <dt>Detect points</dt><dd>{{ selectedCall.detectPoints.join(', ')
}}</dd>
- <dt>Source comp.</dt><dd>{{ (selectedCall.sourceComponents ??
[]).join(', ') || '—' }}</dd>
- <dt>Target comp.</dt><dd>{{ (selectedCall.targetComponents ??
[]).join(', ') || '—' }}</dd>
- </dl>
-
- <div class="edge-metrics">
- <div v-if="relationLoading" class="muted sm">Reading
process-relation metrics…</div>
- <div v-else-if="relationError" class="banner err sm">{{
relationError }}</div>
- <template v-else-if="relationMetrics">
- <div
- v-for="side in (['client', 'server'] as const)"
- :key="side"
- class="metric-side"
- >
- <div class="side-label">{{ side }} side</div>
- <div
- v-for="m in relationMetrics[side]"
- :key="m.id"
- class="metric-row"
- >
- <span class="m-label">{{ m.label }}</span>
- <Sparkline :values="m.values" :width="72" :height="16" />
- <span class="m-val mono">{{ fmtMetric(latestValue(m.values),
m.unit) }}</span>
+ <button class="x" @click="closeEdge">×</button>
+ </div>
+ <div class="dlg-body edge-dlg-body">
+ <div v-if="relationLoading" class="muted">Reading process-relation
metrics…</div>
+ <div v-else-if="relationError" class="banner err">{{ relationError
}}</div>
+ <template v-else-if="relationMetrics">
+ <section
+ v-for="side in (['client', 'server'] as const)"
+ :key="side"
+ class="edge-side"
+ >
+ <h5 class="edge-side-head">{{ side }} side</h5>
+ <div class="edge-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>
- </template>
- </div>
- </div>
+ </div>
+ </section>
+ </template>
</div>
</div>
</div>
@@ -745,41 +751,50 @@ function fmtTime(ms: number): string {
font-size: 10.5px;
color: var(--sw-fg-1);
}
-.edge-pair {
+/* Edge dashboard modal — full process-relation widget grid. */
+.edge-dlg { width: 980px; max-width: 94vw; max-height: 88vh; }
+.edge-dlg-title {
display: flex;
align-items: center;
gap: 8px;
- margin-bottom: 8px;
font-size: 12px;
color: var(--sw-fg-0);
}
-.edge-pair .mono { font-family: var(--sw-mono); }
-.edge-metrics {
- margin-top: 10px;
- border-top: 1px dashed var(--sw-line);
- padding-top: 8px;
+.edge-dlg-title .mono { font-family: var(--sw-mono); }
+.dp-chip {
+ font-size: 9.5px;
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ border-radius: 10px;
+ padding: 1px 8px;
}
-.sm { font-size: 11px; }
-.metric-side { margin-bottom: 8px; }
-.side-label {
+.edge-dlg-body { overflow-y: auto; padding: 12px 14px; }
+.edge-side { margin-bottom: 14px; }
+.edge-side-head {
+ margin: 0 0 8px;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--sw-fg-3);
- margin-bottom: 4px;
}
-.metric-row {
+.edge-grid {
display: grid;
- grid-template-columns: 1fr 72px 96px;
- align-items: center;
- gap: 8px;
- padding: 2px 0;
- font-size: 11px;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 10px;
+}
+.edge-widget { padding: 8px 10px; }
+.ew-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 4px;
}
-.metric-row .m-label { color: var(--sw-fg-2); }
-.metric-row .m-val {
- text-align: right;
+.ew-label { font-size: 11px; color: var(--sw-fg-2); }
+.ew-val {
+ font-size: 11px;
color: var(--sw-fg-0);
font-family: var(--sw-mono);
font-variant-numeric: tabular-nums;
diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index a03c505..cdb0213 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -15,152 +15,177 @@
limitations under the License.
-->
<!--
- Process-level topology for network profiling — booster-ui hexagon
- layout. The hexagon boundary represents the profiled service instance
- (pod): processes that live INSIDE the pod (`isReal` or the synthetic
- `UNKNOWN_LOCAL`) are laid out on a hex grid inside the boundary;
- external peers (other pods / services the eBPF probe saw traffic to)
- sit on concentric rings OUTSIDE it. Each node is an isometric "cube"
- glyph standing in for a container/process. Positions are computed
- (not force-simulated) so the map is stable; nodes are draggable.
- Edges are directed quadratic-bezier curves with a protocol pill and
- are clickable (drives the edge detail panel).
+ Process-level topology for network profiling — honeycomb + namespace
+ grouping, the same vocabulary as the k8s service topology.
+
+ - 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.
+ - 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
+ emits `select-call` so the parent opens the metric dashboard.
Emits:
- select-node — full ProcessNode object (or null)
- select-call — full ProcessCall object (or null)
+ select-call — full ProcessCall (or null)
-->
<script setup lang="ts">
-import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from
'vue';
import * as d3 from 'd3';
import type { ProcessCall, ProcessNode } from '@/api/client';
interface Pt { x: number; y: number }
-type PositionedNode = ProcessNode & Pt;
+type PositionedNode = ProcessNode & Pt & { ns: string };
interface PositionedCall {
id: string;
source: PositionedNode;
target: PositionedNode;
- detectPoints: string[];
protocol: string;
lowerArc: boolean;
}
-const props = defineProps<{ nodes: ProcessNode[]; calls: ProcessCall[] }>();
-const emit = defineEmits<{
- (e: 'select-node', n: ProcessNode | null): void;
- (e: 'select-call', c: ProcessCall | null): void;
+const props = defineProps<{
+ nodes: ProcessNode[];
+ calls: ProcessCall[];
+ groupExpression?: string;
}>();
+const emit = defineEmits<{ (e: 'select-call', c: ProcessCall | null): void
}>();
const host = ref<HTMLDivElement | null>(null);
-const selectedNodeId = ref<string | null>(null);
const selectedCallId = ref<string | null>(null);
-// ── Hexagon layout primitives (flat-top orientation), ported from
-// booster-ui's network-profiling Graph/layout.ts. ──────────────────
-const SQRT3 = Math.sqrt(3);
-const HEX_RADIUS = 210; // pod boundary radius
+// ── 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%)`;
+}
-/** Flat-top axial → pixel. */
-function axialToPixel(ax: number, ay: number, r: number, origin: Pt): Pt {
- return {
- x: ((3 / 2) * ax) * r + origin.x,
- y: ((SQRT3 / 2) * ax + SQRT3 * ay) * r + origin.y,
- };
+// ── 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 };
}
-/** Hex grid of ring-count `n` at the given spacing radius. n=1 ⇒ 7 cells. */
-function hexGrid(n: number, r: number, origin: Pt): Pt[] {
- const pos: Pt[] = [];
- for (let x = -n; x <= n; x++) {
- const yLo = Math.max(-n, -x - n);
- const yHi = Math.min(n, -x + n);
- for (let y = yLo; y <= yHi; y++) pos.push(axialToPixel(x, y, r, origin));
+/** 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 pos;
+ return out.slice(0, n);
}
-/** Points around a circle, skipping the 230°–310° arc so the bottom
- * stays clear for the instance label (matches booster). */
-function circlePoints(r: number, stepDeg: number, origin: Pt): Pt[] {
+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 + origin.x, y: Math.sin(rad) * r +
origin.y });
+ out.push({ x: Math.cos(rad) * r + o.x, y: Math.sin(rad) * r + o.y });
}
return out;
}
-/** Closed hexagon boundary path string. */
-function hexBoundaryPath(r: number, origin: Pt): string {
- const verts: [number, number][] = [];
+function hexCellPath(cx: number, cy: number, R: number): string {
+ const v: [number, number][] = [];
for (let i = 0; i < 6; i++) {
- const rad = Math.PI * 2 * (i / 6);
- verts.push([Math.cos(rad) * r + origin.x, Math.sin(rad) * r + origin.y]);
+ const a = Math.PI * 2 * (i / 6);
+ v.push([cx + Math.cos(a) * R, cy + Math.sin(a) * R]);
}
- const line = d3.line().curve(d3.curveLinearClosed);
- return line(verts) ?? '';
+ return (d3.line().curve(d3.curveLinearClosed)(v) ?? '');
}
-function shuffle<T>(a: T[]): T[] {
- for (let i = a.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [a[i], a[j]] = [a[j], a[i]];
- }
- return a;
+function hexBoundaryPath(r: number, o: Pt): string {
+ return hexCellPath(o.x, o.y, r);
}
function isInside(n: ProcessNode): boolean {
return n.isReal || n.name === 'UNKNOWN_LOCAL';
}
function protocolOf(c: ProcessCall): string {
- const types = [...(c.sourceComponents ?? []), ...(c.targetComponents ??
[])].map((t) =>
- t.toLowerCase(),
- );
- if (types.includes('https')) return 'HTTPS';
- if (types.includes('tls')) return 'TLS';
- if (types.includes('http')) return 'HTTP';
+ const t = [...(c.sourceComponents ?? []), ...(c.targetComponents ??
[])].map((x) => x.toLowerCase());
+ if (t.includes('https')) return 'HTTPS';
+ if (t.includes('tls')) return 'TLS';
+ if (t.includes('http')) return 'HTTP';
return 'TCP';
}
-/** Compute fixed positions for inside (hex grid) + outside (rings). */
-function layout(origin: Pt): 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);
-
- const count = inside.length;
- let cells: Pt[];
- if (count > 7) {
- // Sub-divide each of the 7 macro-cells into a small cluster, then
- // thin the cluster by total count so dense pods stay readable.
- const macro = hexGrid(1, 68, origin);
- const cubes: Pt[] = [];
- for (const c of macro) {
- let sub = hexGrid(1, 20, c);
- if (count < 15) sub = [sub[0], sub[5]];
- else if (count < 22) sub = [sub[0], sub[2], sub[5]];
- cubes.push(...sub);
- }
- cells = shuffle(cubes);
- } else {
- cells = hexGrid(1, 68, origin);
- }
+// Live legend entries (namespace → colour) for the overlay.
+const legend = ref<Array<{ ns: string; color: string; count: number }>>([]);
+let cellRadius = 26;
+
+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 cells = spiralHex(inside.length);
inside.forEach((n, i) => {
- const p = cells[i] ?? origin;
+ 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 on expanding rings.
- let r = 250;
- let ring = circlePoints(r, 30, origin);
+ let r = HEX_RADIUS + 60;
+ let ring = circlePoints(r, 26, o);
outside.forEach((n, i) => {
if (!ring[i]) {
r += 80;
- ring = [...ring, ...circlePoints(r, 30, origin)];
+ ring = [...ring, ...circlePoints(r, 26, o)];
}
- const p = ring[i] ?? { x: origin.x, y: origin.y - r };
+ const p = ring[i] ?? { x: o.x, y: o.y - r };
n.x = p.x;
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];
}
@@ -171,25 +196,12 @@ function buildCalls(byId: Map<string, PositionedNode>):
PositionedCall[] {
const s = byId.get(c.source);
const t = byId.get(c.target);
if (!s || !t) continue;
- // Reverse direction of an already-seen pair arcs the opposite way
- // so the two directed edges don't overlap.
- const fwd = `${c.source}|${c.target}`;
- const rev = `${c.target}|${c.source}`;
- const lowerArc = seen.has(rev);
- seen.add(fwd);
- out.push({
- id: c.id,
- source: s,
- target: t,
- detectPoints: c.detectPoints ?? [],
- protocol: protocolOf(c),
- lowerArc,
- });
+ const lowerArc = seen.has(`${c.target}|${c.source}`);
+ seen.add(`${c.source}|${c.target}`);
+ out.push({ id: c.id, source: s, target: t, protocol: protocolOf(c),
lowerArc });
}
return out;
}
-
-// Quadratic-bezier control point perpendicular to the s→t chord.
function controlPoint(s: Pt, t: Pt, lowerArc: boolean): Pt {
const dx = t.x - s.x;
const dy = t.y - s.y;
@@ -197,73 +209,52 @@ function controlPoint(s: Pt, t: Pt, lowerArc: boolean):
Pt {
const len = (Math.sqrt(dx * dx + dy * dy) / 2) * 0.5;
const cx = (s.x + t.x) / 2 + len * Math.cos(theta);
let cy = (s.y + t.y) / 2 + len * Math.sin(theta);
- if (lowerArc) cy = (s.y + t.y) - cy;
+ if (lowerArc) cy = s.y + t.y - cy;
return { x: cx, y: cy };
}
function edgePath(c: PositionedCall): string {
- const s = { x: c.source.x, y: c.source.y };
- const t = { x: c.target.x, y: c.target.y };
- const cp = controlPoint(s, t, c.lowerArc);
- return `M ${s.x} ${s.y} Q ${cp.x} ${cp.y} ${t.x} ${t.y}`;
+ const cp = controlPoint(c.source, c.target, c.lowerArc);
+ return `M ${c.source.x} ${c.source.y} Q ${cp.x} ${cp.y} ${c.target.x}
${c.target.y}`;
}
function edgeMid(c: PositionedCall): Pt {
- const s = { x: c.source.x, y: c.source.y };
- const t = { x: c.target.x, y: c.target.y };
- const cp = controlPoint(s, t, c.lowerArc);
- // midpoint of the quadratic bezier at t=0.5
+ const cp = controlPoint(c.source, c.target, c.lowerArc);
return {
- x: 0.25 * s.x + 0.5 * cp.x + 0.25 * t.x,
- y: 0.25 * s.y + 0.5 * cp.y + 0.25 * t.y,
+ x: 0.25 * c.source.x + 0.5 * cp.x + 0.25 * c.target.x,
+ y: 0.25 * c.source.y + 0.5 * cp.y + 0.25 * c.target.y,
};
}
-// ── Cube glyph — isometric box drawn around (0,0). ──────────────────
-function appendCube(g: d3.Selection<SVGGElement, unknown, null, undefined>,
inside: boolean): void {
- const w = 13; // half width
- const h = 7; // top-face half height
- const d = 14; // body depth
- const topFill = inside ? 'var(--sw-accent, #f97316)' : 'var(--sw-bg-3,
#2a2d36)';
- const leftFill = inside ? 'var(--sw-accent-2, #c2570f)' : 'var(--sw-bg-2,
#1f2129)';
- const rightFill = inside ? '#8a3d0a' : 'var(--sw-bg-1, #15171c)';
- const stroke = 'var(--sw-line-2, #3a3d47)';
- // top rhombus
- g.append('path')
- .attr('d', `M 0 ${-h - d / 2} L ${w} ${-d / 2} L 0 ${h - d / 2} L ${-w}
${-d / 2} Z`)
- .attr('fill', topFill)
- .attr('stroke', stroke)
- .attr('stroke-width', 1);
- // left face
- g.append('path')
- .attr('d', `M ${-w} ${-d / 2} L 0 ${h - d / 2} L 0 ${h + d / 2} L ${-w}
${d / 2} Z`)
- .attr('fill', leftFill)
- .attr('stroke', stroke)
- .attr('stroke-width', 1);
- // right face
- g.append('path')
- .attr('d', `M ${w} ${-d / 2} L 0 ${h - d / 2} L 0 ${h + d / 2} L ${w} ${d
/ 2} Z`)
- .attr('fill', rightFill)
- .attr('stroke', stroke)
- .attr('stroke-width', 1);
-}
+// ── Node info popover (floating window) ─────────────────────────────
+const nodePop = reactive<{ node: PositionedNode | null; x: number; y: number
}>({
+ node: null,
+ x: 0,
+ y: 0,
+});
+const popStyle = computed(() => {
+ if (typeof window === 'undefined') return {};
+ const W = 240;
+ const H = 150;
+ let x = nodePop.x + 14;
+ let y = nodePop.y + 14;
+ if (x + W > window.innerWidth - 8) x = nodePop.x - W - 14;
+ if (y + H > window.innerHeight - 8) y = nodePop.y - H - 14;
+ return { transform: `translate(${Math.max(8, x)}px, ${Math.max(8, y)}px)` };
+});
-let positioned: PositionedNode[] = [];
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;
function instanceLabel(): string {
- const inside = props.nodes.find(isInside);
- return inside?.serviceInstanceName ?? '';
+ return props.nodes.find(isInside)?.serviceInstanceName ?? '';
}
-
function restyleEdges(): void {
edgeSel
?.attr('stroke', (d) =>
d.id === selectedCallId.value ? 'var(--sw-accent, #f97316)' :
'var(--sw-line-2, #3a3d47)',
)
- .attr('stroke-width', (d) => (d.id === selectedCallId.value ? 2.4 : 1.4));
+ .attr('stroke-width', (d) => (d.id === selectedCallId.value ? 2.6 : 1.5));
}
-
function refreshPositions(): void {
edgeSel?.attr('d', edgePath);
pillSel?.attr('transform', (d) => {
@@ -276,17 +267,17 @@ function refreshPositions(): void {
function render(): void {
if (!host.value) return;
host.value.innerHTML = '';
+ nodePop.node = null;
const rect = host.value.getBoundingClientRect();
const w = rect.width || 720;
const h = rect.height || 520;
- const origin: Pt = { x: 0, y: 0 };
+ const o: Pt = { x: 0, y: 0 };
- positioned = layout(origin);
+ const positioned = layout(o);
const byId = new Map(positioned.map((n) => [n.id, n]));
const calls = buildCalls(byId);
const svg = d3.select(host.value).append('svg').attr('width',
w).attr('height', h);
- // Root group is centred; zoom/pan rides on top.
const root = svg.append('g').attr('transform', `translate(${w / 2},${h /
2})`);
const g = root.append('g');
svg.call(
@@ -295,12 +286,10 @@ function render(): void {
.scaleExtent([0.3, 3])
.on('zoom', (ev) => g.attr('transform', ev.transform.toString())) as
never,
);
- // Click empty canvas → clear selection.
svg.on('click', () => {
- selectedNodeId.value = null;
selectedCallId.value = null;
+ nodePop.node = null;
restyleEdges();
- emit('select-node', null);
emit('select-call', null);
});
@@ -309,7 +298,7 @@ function render(): void {
.append('marker')
.attr('id', 'arrow-pn')
.attr('viewBox', '0 -5 10 10')
- .attr('refX', 18)
+ .attr('refX', 16)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
@@ -318,43 +307,74 @@ function render(): void {
.attr('d', 'M0,-5 L10,0 L0,5')
.attr('fill', 'var(--sw-fg-3, #6c7080)');
- // Hexagon boundary + instance label.
+ // Pod boundary.
g.append('path')
- .attr('d', hexBoundaryPath(HEX_RADIUS, origin))
+ .attr('d', hexBoundaryPath(HEX_RADIUS, o))
.attr('fill', 'var(--sw-bg-1, #15171c)')
- .attr('fill-opacity', 0.35)
+ .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', origin.x)
- .attr('y', origin.y + HEX_RADIUS + 18)
+ .attr('x', o.x)
+ .attr('y', o.y + HEX_RADIUS + 18)
.attr('text-anchor', 'middle')
.attr('fill', 'var(--sw-fg-2, #b4b7c2)')
.style('font-family', 'var(--sw-mono, monospace)')
.style('font-size', '11px')
.text(instanceLabel());
- // Edges.
+ // 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
.selectAll<SVGPathElement, PositionedCall>('path.edge')
.data(calls)
.enter()
.append('path')
- .attr('class', 'edge')
+ .attr('class', 'edge flow')
.attr('fill', 'none')
- .attr('stroke-dasharray', '5 4')
.attr('marker-end', 'url(#arrow-pn)')
.style('cursor', 'pointer')
.on('click', (ev, d) => {
ev.stopPropagation();
selectedCallId.value = d.id;
- selectedNodeId.value = null;
+ nodePop.node = null;
restyleEdges();
emit('select-call', props.calls.find((c) => c.id === d.id) ?? null);
});
- // Protocol pills at edge midpoints.
pillSel = linkG
.selectAll<SVGGElement, PositionedCall>('g.pill')
.data(calls)
@@ -365,7 +385,7 @@ function render(): void {
.on('click', (ev, d) => {
ev.stopPropagation();
selectedCallId.value = d.id;
- selectedNodeId.value = null;
+ nodePop.node = null;
restyleEdges();
emit('select-call', props.calls.find((c) => c.id === d.id) ?? null);
});
@@ -387,7 +407,7 @@ function render(): void {
.style('font-size', '9px')
.text((d) => d.protocol);
- // Nodes.
+ // Nodes — hex cells tinted by namespace.
nodeSel = g
.append('g')
.attr('class', 'nodes')
@@ -396,52 +416,82 @@ function render(): void {
.enter()
.append('g')
.attr('class', 'node')
- .style('cursor', 'grab')
+ .style('cursor', 'pointer')
.on('click', (ev, d) => {
ev.stopPropagation();
- selectedNodeId.value = d.id;
- selectedCallId.value = null;
- restyleEdges();
- emit('select-node', props.nodes.find((n) => n.id === d.id) ?? null);
+ nodePop.node = d;
+ nodePop.x = (ev as MouseEvent).clientX;
+ nodePop.y = (ev as MouseEvent).clientY;
})
.call(
d3
.drag<SVGGElement, PositionedNode>()
+ .on('start', () => { nodePop.node = null; })
.on('drag', (ev, d) => {
d.x = ev.x;
d.y = ev.y;
refreshPositions();
}) as never,
);
- nodeSel.each(function (d) {
- appendCube(d3.select(this), isInside(d));
- });
+ 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('stroke', 'var(--sw-bg-0, #0d0f14)')
+ .attr('stroke-width', 1.5);
nodeSel
.append('text')
.attr('x', 0)
- .attr('y', 22)
+ .attr('y', (d) => (isInside(d) ? cellRadius + 11 : 30))
.attr('text-anchor', 'middle')
.attr('fill', 'var(--sw-fg-1, #d4d6dd)')
.style('font-family', 'var(--sw-mono, monospace)')
- .style('font-size', '10.5px')
- .text((d) => (d.name.length > 16 ? `${d.name.slice(0, 16)}…` : d.name));
+ .style('font-size', '10px')
+ .text((d) => {
+ const base = d.name.split('.')[0];
+ return base.length > 14 ? `${base.slice(0, 14)}…` : base;
+ });
refreshPositions();
restyleEdges();
}
onMounted(render);
-watch(() => [props.nodes, props.calls], render);
+watch(() => [props.nodes, props.calls, props.groupExpression], render);
onBeforeUnmount(() => {
if (host.value) host.value.innerHTML = '';
});
</script>
<template>
- <div ref="host" class="topo-host"></div>
+ <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>
+ </div>
+ </Teleport>
+ </div>
</template>
<style scoped>
+.topo-wrap { position: relative; width: 100%; height: 100%; min-height: 360px;
}
.topo-host {
width: 100%;
height: 100%;
@@ -449,4 +499,60 @@ onBeforeUnmount(() => {
overflow: hidden;
background: var(--sw-bg-0, #0d0f14);
}
+/* Animated traffic-flow dashes on edges (source → target). */
+.topo-host :deep(path.flow) {
+ stroke-dasharray: 6 5;
+ animation: topo-flow 0.9s linear infinite;
+}
+@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>
+/* Node popover lives in <body> via Teleport. */
+.topo-nodepop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 9000;
+ width: max-content;
+ max-width: 240px;
+ pointer-events: none;
+ background: var(--sw-bg-1, #1b1d24);
+ border: 1px solid var(--sw-line, #2a2d36);
+ border-radius: 6px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+ padding: 9px 11px;
+ font-size: 11px;
+ color: var(--sw-fg-1, #d4d6de);
+}
+.topo-nodepop .np-name {
+ font-family: var(--sw-mono, monospace);
+ font-weight: 600;
+ color: var(--sw-fg-0, #f5f7fb);
+ margin-bottom: 6px;
+ overflow-wrap: anywhere;
+}
+.topo-nodepop .np-rows { margin: 0; display: flex; flex-direction: column;
gap: 3px; }
+.topo-nodepop .np-row { display: grid; grid-template-columns: 80px 1fr; gap:
8px; }
+.topo-nodepop .np-row dt { margin: 0; color: var(--sw-fg-3, #6b6f7a); }
+.topo-nodepop .np-row dd { margin: 0; color: var(--sw-fg-0, #f5f7fb);
overflow-wrap: anywhere; }
</style>
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index fb7b08e..c412944 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -410,7 +410,7 @@ watch(
class="sw-nav-item"
:class="{ 'is-active':
isActive(`/layer/${L.key}/zipkin-trace`) }"
>
- <Icon name="trace" /><span>Zipkin Trace</span>
+ <Icon name="trace" /><span>OTel & Zipkin Trace</span>
</RouterLink>
<RouterLink
v-if="L.caps.logs"
@@ -549,7 +549,7 @@ watch(
class="sw-nav-item"
:class="{ 'is-active':
isActive(`/layer/${E.layer.key}/zipkin-trace`) }"
>
- <Icon name="trace" /><span>Zipkin Traces</span>
+ <Icon name="trace" /><span>OTel & Zipkin Traces</span>
</RouterLink>
<RouterLink
v-if="E.layer.caps.logs"
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 97759dc..75d32b0 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -258,6 +258,11 @@ 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 4cfd970..4c6e969 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -119,6 +119,12 @@ 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. */