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 &amp; 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 &amp; 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. */


Reply via email to