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

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

commit a5754069b0f81c3b7c918299a49e707654c45d45
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 9 16:40:38 2026 +0800

    feat(layer): pod/sibling + role model for Service Internal Topology (+ 
preview mock)
    
    Instances now render as hexagons and bundle into pods. Three independent
    rules on serviceInternalTopology config:
    - clusterBy  — dashed boxes (separates pod-groups)
    - siblingBy  — instances of one pod bundle as a hex group: a MAIN (full hex)
      + siblings (50% hexes) attached at the main's 6 edge midpoints (order
      lower-right, lower-left, upper-right, upper-left, bottom, top; extras 
hidden)
    - roleBy + roles[] — per-container-type MQE + which role is the pod's main
    
    Edges resolve to each instance's actual hex (main OR sibling), so cross-pod
    sidecar links connect the small hexes. Per-node metric defs come from the
    node's role (BFF sets node.role), falling back to nodeMetrics.
    
    Preview: an isolated, droppable mock 
(apps/bff/.../mock-internal-topology.ts)
    fabricates a BanyanDB-shaped cluster — 2 liaison pods + hot/warm/cold data
    pods (each: data[main] + lifecycle + fodc siblings), liaison<->liaison,
    liaison->data, and cross-tier lifecycle edges. Wired onto the General layer
    via serviceInternalTopology.mock so it's previewable with no OAP data. The
    route short-circuits to the mock when config.mock is set. Drop the mock +
    flag once real data exists; the component stays for the BanyanDB template.
    
    Validated end-to-end through the BFF (13 nodes / 9 calls, correct roles +
    pods). type-check + lint + 80 BFF tests green.
---
 CHANGELOG.md                                       |  10 +
 apps/bff/src/bundled_templates/layers/general.json |  28 ++-
 apps/bff/src/http/query/internal-topology.ts       |  30 +++
 .../bff/src/logic/layers/mock-internal-topology.ts | 152 ++++++++++++
 apps/ui/src/api/client.ts                          |   1 +
 .../LayerServiceInternalTopologyView.vue           | 254 +++++++++++++--------
 packages/api-client/src/index.ts                   |   1 +
 packages/api-client/src/topology.ts                |  43 +++-
 8 files changed, 417 insertions(+), 102 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a9d382..b813692 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -117,6 +117,16 @@ packages) plus the BFF's `HORIZON_VERSION` default.
   layer's zone renders its **instances** and their intra-service relations
   instead of service cubes — selecting an instance cube opens the **instance**
   dashboard. Toggle it off to return to the service view.
+- **Pod / sibling model.** Instances render as **hexagons** and can bundle into
+  pods: a pod's **main** container is a full hex with its **sibling** 
containers
+  attached as smaller hexes around its edges. Three independent rules drive it 
—
+  **cluster** (the dashed boxes), **sibling** (which containers form one pod),
+  and **role** (per-container-type metrics + which container is the main).
+  Edges resolve to the exact container, so cross-pod sidecar links (e.g. a
+  lifecycle agent calling its peer in another pod) connect the small hexes.
+  A self-contained **preview mock** (a BanyanDB-shaped liaison + hot/warm/cold
+  data cluster) is wired onto the **General** layer so the model is previewable
+  before real data exists.
 
 ### API dependency
 
diff --git a/apps/bff/src/bundled_templates/layers/general.json 
b/apps/bff/src/bundled_templates/layers/general.json
index 756ea43..f87f272 100644
--- a/apps/bff/src/bundled_templates/layers/general.json
+++ b/apps/bff/src/bundled_templates/layers/general.json
@@ -20,7 +20,8 @@
     "traceProfiling": true,
     "ebpfProfiling": true,
     "asyncProfiling": true,
-    "pprofProfiling": true
+    "pprofProfiling": true,
+    "serviceInternalTopology": true
   },
   "layer-header": {
     "orderBy": "cpm",
@@ -956,5 +957,30 @@
       { "id": "p95", "label": "p95", "mqe": 
"endpoint_relation_percentile{p='95'}", "unit": "ms", "aggregation": "avg" },
       { "id": "sla", "label": "SLA", "mqe": "endpoint_relation_sla/100", 
"unit": "%", "aggregation": "avg" }
     ]
+  },
+  "serviceInternalTopology": {
+    "mock": "banyandb-cluster",
+    "nodeMetrics": [
+      { "id": "cpm", "label": "Load", "mqe": "service_instance_cpm", "unit": 
"cpm", "role": "center", "aggregation": "avg" }
+    ],
+    "clusterBy": { "kind": "attribute", "attribute": "node_role", "alias": 
"role" },
+    "siblingBy": { "kind": "attribute", "attribute": "pod", "alias": "pod" },
+    "roleBy": { "kind": "attribute", "attribute": "container", "alias": 
"container" },
+    "roles": [
+      { "key": "liaison", "label": "Liaison", "main": true, "nodeMetrics": [
+        { "id": "cpm", "label": "gRPC q/s", "mqe": "service_instance_cpm", 
"unit": "q/s", "role": "center", "aggregation": "avg" },
+        { "id": "err", "label": "Errors", "mqe": "service_instance_sla", 
"unit": "%", "role": "ring", "aggregation": "avg", "thresholds": { "ok": 0.5, 
"warn": 1, "danger": 3 } }
+      ] },
+      { "key": "data", "label": "Data", "main": true, "nodeMetrics": [
+        { "id": "write", "label": "Write/s", "mqe": "service_instance_cpm", 
"unit": "w/s", "role": "center", "aggregation": "avg" },
+        { "id": "disk", "label": "Disk used", "mqe": "service_instance_sla", 
"unit": "%", "role": "ring", "aggregation": "avg", "thresholds": { "ok": 50, 
"warn": 75, "danger": 85 } }
+      ] },
+      { "key": "lifecycle", "label": "Lifecycle agent", "nodeMetrics": [
+        { "id": "sync", "label": "Sync/s", "mqe": "service_instance_cpm", 
"unit": "s", "role": "center", "aggregation": "avg" }
+      ] },
+      { "key": "fodc", "label": "FODC agent", "nodeMetrics": [
+        { "id": "scrape", "label": "Scrape", "mqe": "service_instance_cpm", 
"unit": "ms", "role": "center", "aggregation": "avg" }
+      ] }
+    ]
   }
 }
diff --git a/apps/bff/src/http/query/internal-topology.ts 
b/apps/bff/src/http/query/internal-topology.ts
index f286c71..ad8c443 100644
--- a/apps/bff/src/http/query/internal-topology.ts
+++ b/apps/bff/src/http/query/internal-topology.ts
@@ -63,6 +63,7 @@ import {
 import { serviceInternalTopologyConfigFor } from 
'../../logic/layers/loader.js';
 import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { parsePreviewServiceInternalTopology } from 
'../../logic/layers/preview.js';
+import { MOCK_INTERNAL_TOPOLOGIES } from 
'../../logic/layers/mock-internal-topology.js';
 import { aggregateMqe, seriesFromMqe, type MqeShape } from './topology.js';
 
 export interface InternalTopologyRouteDeps {
@@ -257,6 +258,35 @@ export function registerInternalTopologyRoute(
         return reply.code(404).send({ error: 
'service_internal_topology_not_supported' });
       }
 
+      // Preview mock — serve an isolated fabricated cluster instead of OAP,
+      // for layers with no real instance-relation data yet. Best-effort
+      // service-name resolve so the header reads right, then short-circuit.
+      const mockBuild = cfg.mock ? MOCK_INTERNAL_TOPOLOGIES[cfg.mock] : 
undefined;
+      if (mockBuild) {
+        let svcName: string | null = null;
+        try {
+          const data = await graphqlPost<{ services: Array<{ id: string; name: 
string }> }>(
+            buildOapOpts(deps.config.current, deps.fetch),
+            LIST_SERVICES_FOR_RESOLVE,
+            { layer: layerKey.toUpperCase() },
+          );
+          svcName = data.services.find((s) => s.id === serviceId)?.name ?? 
null;
+        } catch {
+          /* best-effort — the mock renders regardless of the header name */
+        }
+        const { nodes, calls } = mockBuild(serviceId, svcName);
+        return reply.send({
+          layer: layerKey,
+          serviceId,
+          serviceName: svcName,
+          generatedAt: Date.now(),
+          config: cfg,
+          nodes,
+          calls,
+          reachable: true,
+        } satisfies ServiceInternalTopologyResponse);
+      }
+
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
       const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
diff --git a/apps/bff/src/logic/layers/mock-internal-topology.ts 
b/apps/bff/src/logic/layers/mock-internal-topology.ts
new file mode 100644
index 0000000..44c4d0a
--- /dev/null
+++ b/apps/bff/src/logic/layers/mock-internal-topology.ts
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * ISOLATED, DROPPABLE preview mock for the Service Internal Topology
+ * component. OAP does not yet expose intra-service instance relations for a
+ * clustered store, so this fabricates a BanyanDB-shaped cluster so the
+ * sibling/pod + role rendering can be exercised locally (wired onto the
+ * GENERAL layer via `serviceInternalTopology.mock: "banyandb-cluster"`).
+ *
+ * Delete this file + the `mock` flag once real data exists; the component
+ * itself stays for the BanyanDB template.
+ *
+ * Shape it models:
+ *   - 2 liaison PODs (each: liaison container [main] + fodc sidecar) that
+ *     call each other.
+ *   - 3 data PODs — hot / warm / cold tiers — each a pod of 3 containers:
+ *     data [main] + lifecycle-agent + fodc-agent (bundled siblings).
+ *   - liaison → data calls (main → main).
+ *   - lifecycle-agent calls ACROSS tiers: hot → warm → cold (sibling → 
sibling,
+ *     so the edge connects the small attached hexes, not the mains).
+ *
+ * Attributes carried per instance (the 3 rules key off these):
+ *   - `node_role`  liaison | data        → clusterBy (the dashed boxes)
+ *   - `pod`        liaison-0 | data-hot… → siblingBy (bundle a pod's 
containers)
+ *   - `container`  liaison|data|lifecycle|fodc → roleBy (per-role MQE + main)
+ *   - `node_type`  hot | warm | cold     (data pods only)
+ */
+
+import type {
+  ServiceInternalTopologyCall,
+  ServiceInternalTopologyNode,
+} from '@skywalking-horizon-ui/api-client';
+
+type Attr = Array<{ name: string; value: string }>;
+const A = (o: Record<string, string>): Attr =>
+  Object.entries(o).map(([name, value]) => ({ name, value }));
+
+/** Build one container instance node. `metrics` keys must match the role's
+ *  `nodeMetrics[].id` in the layer template's `roles` config. */
+function inst(
+  pod: string,
+  container: string,
+  attrs: Record<string, string>,
+  metrics: Record<string, number>,
+): ServiceInternalTopologyNode {
+  const name = `${pod}-${container}`;
+  return {
+    id: `mock::${name}`,
+    name,
+    serviceId: 'mock-service',
+    serviceName: 'banyandb-cluster',
+    isReal: true,
+    metrics,
+    attributes: A({ pod, container, ...attrs }),
+    role: container,
+  };
+}
+
+function call(
+  source: string,
+  target: string,
+  detectPoints: string[],
+  server: Record<string, number> = {},
+  client: Record<string, number> = {},
+): ServiceInternalTopologyCall {
+  return {
+    id: `mock::${source}->${target}`,
+    source: `mock::${source}`,
+    target: `mock::${target}`,
+    detectPoints,
+    serverMetrics: server,
+    clientMetrics: client,
+    serverMetricSeries: {},
+    clientMetricSeries: {},
+  };
+}
+
+function dataPod(tier: 'hot' | 'warm' | 'cold', diskPct: number, writeRps: 
number): ServiceInternalTopologyNode[] {
+  const pod = `data-${tier}`;
+  return [
+    inst(pod, 'data', { node_role: 'data', node_type: tier }, { disk: diskPct, 
write: writeRps, series: Math.round(writeRps * 12) }),
+    inst(pod, 'lifecycle', { node_role: 'data', node_type: tier }, { sync: 
tier === 'hot' ? 8 : tier === 'warm' ? 3 : 1 }),
+    inst(pod, 'fodc', { node_role: 'data', node_type: tier }, { scrape: 14 + 
(tier === 'cold' ? 9 : 0) }),
+  ];
+}
+
+function liaisonPod(idx: number, qps: number, errPct: number): 
ServiceInternalTopologyNode[] {
+  const pod = `liaison-${idx}`;
+  return [
+    inst(pod, 'liaison', { node_role: 'liaison' }, { cpm: qps, err: errPct }),
+    inst(pod, 'fodc', { node_role: 'liaison' }, { scrape: 11 }),
+  ];
+}
+
+export function buildMockInternalTopology(serviceId: string, serviceName: 
string | null): {
+  nodes: ServiceInternalTopologyNode[];
+  calls: ServiceInternalTopologyCall[];
+} {
+  const nodes: ServiceInternalTopologyNode[] = [
+    ...liaisonPod(0, 1820, 0.2),
+    ...liaisonPod(1, 1640, 0.9),
+    ...dataPod('hot', 71, 940),
+    ...dataPod('warm', 48, 120),
+    ...dataPod('cold', 86, 12),
+  ];
+  const SC = ['CLIENT', 'SERVER'];
+  const calls: ServiceInternalTopologyCall[] = [
+    // liaison ↔ liaison (main ↔ main)
+    call('liaison-0-liaison', 'liaison-1-liaison', SC),
+    call('liaison-1-liaison', 'liaison-0-liaison', SC),
+    // liaison → data (main → main)
+    call('liaison-0-liaison', 'data-hot-data', SC),
+    call('liaison-0-liaison', 'data-warm-data', SC),
+    call('liaison-0-liaison', 'data-cold-data', SC),
+    call('liaison-1-liaison', 'data-hot-data', SC),
+    call('liaison-1-liaison', 'data-warm-data', SC),
+    // lifecycle agent across tiers (sibling → sibling)
+    call('data-hot-lifecycle', 'data-warm-lifecycle', ['SERVER']),
+    call('data-warm-lifecycle', 'data-cold-lifecycle', ['SERVER']),
+  ];
+  // serviceId/serviceName ride through on the response wrapper; nodes keep
+  // their own mock service identity so the graph reads as one cluster.
+  void serviceId;
+  void serviceName;
+  return { nodes, calls };
+}
+
+/** Registry of named mocks the route can serve (config `mock` value). */
+export const MOCK_INTERNAL_TOPOLOGIES: Record<
+  string,
+  (serviceId: string, serviceName: string | null) => {
+    nodes: ServiceInternalTopologyNode[];
+    calls: ServiceInternalTopologyCall[];
+  }
+> = {
+  'banyandb-cluster': buildMockInternalTopology,
+};
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 684d696..c371a8c 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -131,6 +131,7 @@ export type {
   InstanceTopologyCall,
   InstanceTopologyResponse,
   ClusterByRule,
+  NodeRoleConfig,
   ServiceInternalTopologyConfig,
   ServiceInternalTopologyNode,
   ServiceInternalTopologyCall,
diff --git a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue 
b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
index 511cc10..6ed0755 100644
--- a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
@@ -78,8 +78,24 @@ const cfg = computed(
 function pickByRole(defs: TopologyMetricDef[], role: 
TopologyMetricDef['role']): TopologyMetricDef | null {
   return defs.find((d) => d.role === role) ?? null;
 }
-const centerDef = computed(() => pickByRole(cfg.value.nodeMetrics, 'center'));
-const ringDef = computed(() => pickByRole(cfg.value.nodeMetrics, 'ring'));
+// Per-node metric defs: a node's role (from `roleBy`, set by the BFF/mock)
+// selects its metric set; falls back to the default `nodeMetrics`.
+function roleConfigFor(n: ServiceInternalTopologyNode) {
+  const want = (n.role ?? '').toLowerCase();
+  if (!want) return null;
+  return cfg.value.roles?.find((r) => r.key.toLowerCase() === want) ?? null;
+}
+function metricDefsFor(n: ServiceInternalTopologyNode): TopologyMetricDef[] {
+  return roleConfigFor(n)?.nodeMetrics ?? cfg.value.nodeMetrics ?? [];
+}
+function centerDefFor(n: ServiceInternalTopologyNode): TopologyMetricDef | 
null {
+  return pickByRole(metricDefsFor(n), 'center');
+}
+function ringDefFor(n: ServiceInternalTopologyNode): TopologyMetricDef | null {
+  return pickByRole(metricDefsFor(n), 'ring');
+}
+// Default defs (no-role / legend baseline).
+const defaultRingDef = computed(() => pickByRole(cfg.value.nodeMetrics, 
'ring'));
 
 function nodeVal(n: ServiceInternalTopologyNode, def: TopologyMetricDef | 
null): number | null {
   return def ? (n.metrics?.[def.id] ?? null) : null;
@@ -97,7 +113,7 @@ function bandColor(value: number, th: 
NonNullable<TopologyMetricDef['thresholds'
   return 'var(--sw-ok)';
 }
 function ringColor(n: ServiceInternalTopologyNode): string {
-  const def = ringDef.value;
+  const def = ringDefFor(n);
   if (!def) return 'var(--sw-line-2)';
   const v = nodeVal(n, def);
   if (v === null) return 'var(--sw-fg-3)';
@@ -109,10 +125,16 @@ function ringColor(n: ServiceInternalTopologyNode): 
string {
   if (errPct > 0.1) return '#fbbf24';
   return 'var(--sw-ok)';
 }
+/** Flat-top hexagon `points=` for circumradius `r`, centred at (0,0). */
+function hexPoints(r: number): string {
+  const h = r * 0.5;
+  const s = r * 0.8660254037844386;
+  return `${r},0 ${h},${s} ${-h},${s} ${-r},0 ${-h},${-s} ${h},${-s}`;
+}
 
 // ── Ring-colour legend (same break-point derivation as the instance map). */
 const ringScaleLabels = computed<string[]>(() => {
-  const def = ringDef.value;
+  const def = defaultRingDef.value;
   if (!def) return [];
   const th = def.thresholds ?? { ok: 0.1, warn: 1, danger: 5 };
   const heuristicInvert = /sla|success|apdex/i.test(def.id) || 
/sla|apdex|success/i.test(def.label);
@@ -132,103 +154,116 @@ const ringScaleLabels = computed<string[]>(() => {
   return out;
 });
 const ringDirectionHint = computed<string>(() => {
-  const def = ringDef.value;
+  const def = defaultRingDef.value;
   if (!def) return '';
   if (def.thresholds?.invertHealth) return t('higher = better');
   if (/sla|success|apdex/i.test(def.id) || 
/sla|apdex|success/i.test(def.label)) return t('higher = better');
   return t('lower = better');
 });
 
-// ── Clustering. Resolve each node's cluster key + the dimension alias from
-// the layer's `clusterBy` rule. `attribute` matches the instance attribute
-// bag case-insensitively; `nameRegex` reuses the service-naming resolver on
-// the INSTANCE name (every node shares one service name, so the service
-// rule would be useless here).
+// ── Three independent rules, each keyed off an instance's name + attributes:
+//   clusterBy → which dashed box (separates pod-groups)
+//   siblingBy → which POD (bundles a pod's containers into one hex group)
+//   roleBy    → set by the BFF as node.role (drives main-hex + per-role MQE)
+// `attribute` matches the instance attribute bag case-insensitively;
+// `nameRegex` reuses the service-naming resolver on the INSTANCE name.
 const clusterBy = computed<ClusterByRule | null>(() => cfg.value.clusterBy ?? 
null);
-const clusterAlias = computed<string>(() => {
-  const cb = clusterBy.value;
-  if (!cb) return '';
-  if (cb.kind === 'attribute') return cb.alias || cb.attribute;
-  return cb.alias;
-});
-function clusterKeyOf(n: ServiceInternalTopologyNode): string | null {
-  const cb = clusterBy.value;
-  if (!cb) return null;
-  if (cb.kind === 'attribute') {
-    const want = cb.attribute.toLowerCase();
-    const hit = n.attributes.find((a) => a.name.toLowerCase() === want);
-    return hit?.value || null;
+const siblingBy = computed<ClusterByRule | null>(() => cfg.value.siblingBy ?? 
null);
+function ruleAlias(r: ClusterByRule | null): string {
+  if (!r) return '';
+  return r.kind === 'attribute' ? r.alias || r.attribute : r.alias;
+}
+const clusterAlias = computed<string>(() => ruleAlias(clusterBy.value));
+function keyFromRule(rule: ClusterByRule | null, n: 
ServiceInternalTopologyNode): string | null {
+  if (!rule) return null;
+  if (rule.kind === 'attribute') {
+    const want = rule.attribute.toLowerCase();
+    return n.attributes.find((a) => a.name.toLowerCase() === want)?.value || 
null;
   }
-  // nameRegex — same field names as ServiceNamingRule, run on the pod name.
-  const id = resolveServiceIdentity(n.name, {
-    pattern: cb.pattern,
-    flags: cb.flags,
-    displayGroup: cb.displayGroup,
-    valueGroup: cb.valueGroup,
-    alias: cb.alias,
-  });
-  return id.cluster;
+  return resolveServiceIdentity(n.name, {
+    pattern: rule.pattern, flags: rule.flags,
+    displayGroup: rule.displayGroup, valueGroup: rule.valueGroup, alias: 
rule.alias,
+  }).cluster;
 }
+const clusterKeyOf = (n: ServiceInternalTopologyNode): string | null => 
keyFromRule(clusterBy.value, n);
+// A pod = instances sharing the sibling key. No siblingBy ⇒ each instance is
+// its own single-hex pod.
+const siblingKeyOf = (n: ServiceInternalTopologyNode): string => 
keyFromRule(siblingBy.value, n) ?? n.id;
+const isMainRole = (n: ServiceInternalTopologyNode): boolean => 
!!roleConfigFor(n)?.main;
 
-interface ClusterBucket {
-  key: string | null;
-  label: string;
-  nodes: ServiceInternalTopologyNode[];
+interface Pod {
+  clusterKey: string | null;
+  siblingKey: string;
+  main: ServiceInternalTopologyNode;
+  siblings: ServiceInternalTopologyNode[];
 }
+interface ClusterBucket { key: string | null; label: string; pods: Pod[] }
 const clusters = computed<ClusterBucket[]>(() => {
-  const byKey = new Map<string, ClusterBucket>();
-  const UNGROUPED = '\u0000__ungrouped__';
+  const UNGROUPED = '__ungrouped__';
+  const byCluster = new Map<string, Map<string, 
ServiceInternalTopologyNode[]>>();
   for (const n of nodes.value) {
-    const key = clusterKeyOf(n);
-    const mapKey = key ?? UNGROUPED;
-    let b = byKey.get(mapKey);
-    if (!b) {
-      b = { key, label: key ?? t('ungrouped'), nodes: [] };
-      byKey.set(mapKey, b);
+    const cmk = clusterKeyOf(n) ?? UNGROUPED;
+    if (!byCluster.has(cmk)) byCluster.set(cmk, new Map());
+    const pods = byCluster.get(cmk)!;
+    const sk = siblingKeyOf(n);
+    if (!pods.has(sk)) pods.set(sk, []);
+    pods.get(sk)!.push(n);
+  }
+  const out: ClusterBucket[] = [];
+  for (const [cmk, podMap] of byCluster) {
+    const ck = cmk === UNGROUPED ? null : cmk;
+    const pods: Pod[] = [];
+    for (const [sk, members] of podMap) {
+      const sorted = [...members].sort((a, b) => a.name.localeCompare(b.name));
+      const mainIdx = sorted.findIndex(isMainRole);
+      const main = sorted[mainIdx >= 0 ? mainIdx : 0];
+      pods.push({ clusterKey: ck, siblingKey: sk, main, siblings: 
sorted.filter((m) => m !== main) });
     }
-    b.nodes.push(n);
+    pods.sort((a, b) => a.main.name.localeCompare(b.main.name));
+    out.push({ key: ck, label: ck ?? t('ungrouped'), pods });
   }
-  // Named clusters first (alpha), the ungrouped bucket last.
-  return [...byKey.values()].sort((a, b) => {
+  return out.sort((a, b) => {
     if (a.key === null) return 1;
     if (b.key === null) return -1;
     return a.key.localeCompare(b.key);
-  }).map((b) => ({ ...b, nodes: [...b.nodes].sort((x, y) => 
x.name.localeCompare(y.name)) }));
+  });
 });
 
-// ── Deterministic cluster-grid layout. Each cluster is a box; nodes tile in
-// a near-square grid inside it; clusters flow left→right, wrapping past
-// MAX_ROW_W. Drawn inside the zoom layer so it pans / zooms with the nodes.
-const NODE_R = 24;
-const NODE_DX = 104;
-const NODE_DY = 94;
-const CLUSTER_GAP_X = 64;
+// ── Layout: pods tile in a near-square grid per cluster box; each pod = a
+// main hex with up to 6 siblings attached at its edge midpoints (order:
+// lower-right, lower-left, upper-right, upper-left, bottom, top — extras
+// hidden). `pos` carries a per-node radius so edges trim correctly and the
+// renderer sizes each hex.
+const MAIN_R = 28;
+const SIB_R = 14;
+const SIB_DIST = 42; // main centre -> sibling centre
+// Edge-midpoint directions (SVG y-down), in attach order.
+const SIB_ANGLES = [30, 150, 330, 210, 90, 270].map((d) => (d * Math.PI) / 
180);
+const POD_DX = 150;
+const POD_DY = 158;
+const CLUSTER_GAP_X = 60;
 const CLUSTER_GAP_Y = 56;
-const CLUSTER_PAD = 22;
+const CLUSTER_PAD = 24;
 const HEAD_H = 30;
-const MAX_ROW_W = 1180;
-interface Pos { cx: number; cy: number }
+const MAX_ROW_W = 1280;
+interface Pos { cx: number; cy: number; r: number }
 interface ClusterRect { key: string | null; label: string; x: number; y: 
number; w: number; h: number; boxed: boolean }
 interface Layout { pos: Map<string, Pos>; rects: ClusterRect[]; w: number; h: 
number }
 const layout = computed<Layout>(() => {
   const pos = new Map<string, Pos>();
   const rects: ClusterRect[] = [];
-  // A box is drawn only when a clustering rule is active (an ungrouped
-  // single bucket on a layer with no clusterBy should read as a plain map).
   const showBoxes = !!clusterBy.value;
   let cursorX = 0;
   let cursorY = 0;
   let rowMaxH = 0;
   let maxW = 0;
   for (const cl of clusters.value) {
-    const n = cl.nodes.length;
-    const cols = Math.max(1, Math.min(6, Math.ceil(Math.sqrt(n))));
-    const rows = Math.max(1, Math.ceil(n / cols));
-    const innerW = cols * NODE_DX;
-    const innerH = rows * NODE_DY;
-    const boxW = innerW + CLUSTER_PAD * 2;
+    const count = cl.pods.length;
+    const cols = Math.max(1, Math.min(5, Math.ceil(Math.sqrt(count))));
+    const rows = Math.max(1, Math.ceil(count / cols));
+    const boxW = cols * POD_DX + CLUSTER_PAD * 2;
     const headH = showBoxes ? HEAD_H : 0;
-    const boxH = innerH + CLUSTER_PAD * 2 + headH;
+    const boxH = rows * POD_DY + CLUSTER_PAD * 2 + headH;
     if (cursorX > 0 && cursorX + boxW > MAX_ROW_W) {
       cursorX = 0;
       cursorY += rowMaxH + CLUSTER_GAP_Y;
@@ -236,12 +271,16 @@ const layout = computed<Layout>(() => {
     }
     const boxX = cursorX;
     const boxY = cursorY;
-    cl.nodes.forEach((node, i) => {
+    cl.pods.forEach((pod, i) => {
       const col = i % cols;
       const row = Math.floor(i / cols);
-      const cx = boxX + CLUSTER_PAD + col * NODE_DX + NODE_DX / 2;
-      const cy = boxY + headH + CLUSTER_PAD + row * NODE_DY + NODE_R + 2;
-      pos.set(node.id, { cx, cy });
+      const cx = boxX + CLUSTER_PAD + col * POD_DX + POD_DX / 2;
+      const cy = boxY + headH + CLUSTER_PAD + row * POD_DY + POD_DY / 2 - 10;
+      pos.set(pod.main.id, { cx, cy, r: MAIN_R });
+      pod.siblings.slice(0, SIB_ANGLES.length).forEach((sib, j) => {
+        const a = SIB_ANGLES[j];
+        pos.set(sib.id, { cx: cx + Math.cos(a) * SIB_DIST, cy: cy + 
Math.sin(a) * SIB_DIST, r: SIB_R });
+      });
     });
     rects.push({ key: cl.key, label: cl.label, x: boxX, y: boxY, w: boxW, h: 
boxH, boxed: showBoxes });
     cursorX += boxW + CLUSTER_GAP_X;
@@ -253,11 +292,25 @@ const layout = computed<Layout>(() => {
 const pos = computed(() => layout.value.pos);
 const W = computed(() => layout.value.w);
 const H = computed(() => layout.value.h);
+function posR(id: string): number {
+  return pos.value.get(id)?.r ?? MAIN_R;
+}
+function isSiblingNode(n: ServiceInternalTopologyNode): boolean {
+  return posR(n.id) < MAIN_R - 6;
+}
+/** Single-letter glyph for a small sibling hex (role / last name segment). */
+function siblingGlyph(n: ServiceInternalTopologyNode): string {
+  return (n.role || n.name).charAt(0).toUpperCase();
+}
+/** Label under a hex: the pod identity for a main, the role for a sibling. */
+function nodeLabel(n: ServiceInternalTopologyNode): string {
+  if (isSiblingNode(n)) return n.role || n.name.split('-').slice(-1)[0] || 
n.name;
+  return siblingBy.value ? siblingKeyOf(n) : n.name;
+}
 
-// ── Edges. Self-loops (source === target) draw a small loop; bidirectional
-// pairs bow apart so the two directions don't overlap. The bow side is
-// keyed on a stable id comparison so each direction always picks the same
-// side across renders.
+// ── Edges. Resolve to each instance's actual hex (main OR sibling) so
+// cross-tier sibling edges connect the small attached hexes. Self-loops draw
+// a teardrop; bidirectional pairs bow apart.
 const callKeys = computed<Set<string>>(() => {
   const s = new Set<string>();
   for (const c of calls.value) s.add(`${c.source}|${c.target}`);
@@ -271,29 +324,24 @@ function edgePathD(c: ServiceInternalTopologyCall): 
string {
   const b = pos.value.get(c.target);
   if (!a || !b) return '';
   if (c.source === c.target) {
-    // Self-loop: a teardrop above the node.
-    const r = NODE_R;
     const x = a.cx;
-    const y = a.cy - r;
-    return `M ${x - 7} ${y} C ${x - 26} ${y - 34} ${x + 26} ${y - 34} ${x + 7} 
${y}`;
+    const y = a.cy - a.r;
+    return `M ${x - 6} ${y} C ${x - 22} ${y - 30} ${x + 22} ${y - 30} ${x + 6} 
${y}`;
   }
-  const dx = b.cx - a.cx;
-  const dy = b.cy - a.cy;
-  const len = Math.hypot(dx, dy) || 1;
-  const nx = -dy / len;
-  const ny = dx / len;
+  const len = Math.hypot(b.cx - a.cx, b.cy - a.cy) || 1;
+  const nx = -(b.cy - a.cy) / len;
+  const ny = (b.cx - a.cx) / len;
   const bidirectional = callKeys.value.has(`${c.target}|${c.source}`);
   const sign = c.source < c.target ? 1 : -1;
   const bow = bidirectional ? 16 : 0;
   const mx = (a.cx + b.cx) / 2 + nx * bow * sign;
   const my = (a.cy + b.cy) / 2 + ny * bow * sign;
-  // Trim endpoints to the circle edge, aimed at the control point.
   const sa = Math.hypot(mx - a.cx, my - a.cy) || 1;
   const sb = Math.hypot(mx - b.cx, my - b.cy) || 1;
-  const x1 = a.cx + ((mx - a.cx) / sa) * NODE_R;
-  const y1 = a.cy + ((my - a.cy) / sa) * NODE_R;
-  const x2 = b.cx + ((mx - b.cx) / sb) * NODE_R;
-  const y2 = b.cy + ((my - b.cy) / sb) * NODE_R;
+  const x1 = a.cx + ((mx - a.cx) / sa) * a.r;
+  const y1 = a.cy + ((my - a.cy) / sa) * a.r;
+  const x2 = b.cx + ((mx - b.cx) / sb) * b.r;
+  const y2 = b.cy + ((my - b.cy) / sb) * b.r;
   return `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`;
 }
 
@@ -393,7 +441,7 @@ const popoverStyle = computed<Record<string, string>>(() => 
{
   const ch = el.clientHeight;
   const nx = z.x + p.cx * z.k;
   const ny = z.y + p.cy * z.k;
-  const r = NODE_R * z.k;
+  const r = p.r * z.k;
   const openRight = nx < cw / 2;
   let left = openRight ? nx + r + 10 : nx - r - 10 - POP_W;
   left = Math.max(8, Math.min(left, cw - POP_W - 8));
@@ -531,14 +579,21 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onKeyDown, true));
               </g>
               <g
                 v-for="n in nodes" :key="n.id" class="sit-node"
-                :class="{ sel: popoverNodeId === n.id }" :data-node-id="n.id"
+                :class="{ sel: popoverNodeId === n.id, sibling: 
isSiblingNode(n) }" :data-node-id="n.id"
                 :transform="`translate(${pos.get(n.id)?.cx ?? 0}, 
${pos.get(n.id)?.cy ?? 0})`"
                 @click.stop="selectNode(n.id)"
               >
-                <circle :r="NODE_R" class="node-bg" :stroke="ringColor(n)" 
:stroke-width="popoverNodeId === n.id ? 4 : 3" />
-                <text class="node-center" text-anchor="middle" 
:dy="centerDef?.unit ? '-1' : '0.36em'">{{ fmtVal(nodeVal(n, centerDef)) 
}}</text>
-                <text v-if="centerDef?.unit" class="node-unit" 
text-anchor="middle" dy="12">{{ centerDef.unit }}</text>
-                <text class="node-label mono" text-anchor="middle" :y="NODE_R 
+ 15">{{ n.name }}</text>
+                <polygon
+                  :points="hexPoints(posR(n.id))" class="node-bg"
+                  :stroke="ringColor(n)" :stroke-width="popoverNodeId === n.id 
? 4 : 2.5"
+                  stroke-linejoin="round"
+                />
+                <template v-if="!isSiblingNode(n)">
+                  <text class="node-center" text-anchor="middle" 
:dy="centerDefFor(n)?.unit ? '-1' : '0.36em'">{{ fmtVal(nodeVal(n, 
centerDefFor(n))) }}</text>
+                  <text v-if="centerDefFor(n)?.unit" class="node-unit" 
text-anchor="middle" dy="12">{{ centerDefFor(n)?.unit }}</text>
+                </template>
+                <text v-else class="node-sib-glyph" text-anchor="middle" 
dy="0.34em">{{ siblingGlyph(n) }}</text>
+                <text class="node-label mono" :class="{ sib: isSiblingNode(n) 
}" text-anchor="middle" :y="posR(n.id) + 13">{{ nodeLabel(n) }}</text>
               </g>
             </g>
           </svg>
@@ -555,7 +610,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKeyDown, true));
               </template>
             </dl>
             <dl class="np-kv">
-              <template v-for="def in cfg.nodeMetrics" :key="def.id">
+              <template v-for="def in metricDefsFor(popoverNode)" 
:key="def.id">
                 <dt>{{ def.label }}</dt>
                 <dd class="mono">{{ fmtVal(nodeVal(popoverNode, def), 
def.unit) }}</dd>
               </template>
@@ -571,9 +626,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKeyDown, true));
             <button class="sw-btn small" type="button" :title="t('Fit to 
screen')" @click="fitToScreen(true)">{{ t('Fit') }}</button>
           </div>
 
-          <div v-if="ringDef" class="sit-ring-legend">
+          <div v-if="defaultRingDef" class="sit-ring-legend">
             <div class="lg-label">
-              {{ t('Node ring') }} · {{ ringDef.label }}
+              {{ t('Node ring') }} · {{ defaultRingDef.label }}
               <span class="lg-direction">{{ ringDirectionHint }}</span>
             </div>
             <div class="lg-ramp">
@@ -691,6 +746,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKeyDown, true));
 .node-center { fill: var(--sw-fg-0); font-size: 12px; font-weight: 700; 
font-family: var(--sw-mono); }
 .node-unit { fill: var(--sw-fg-3); font-size: 8px; font-family: 
var(--sw-mono); text-transform: uppercase; letter-spacing: 0.04em; }
 .node-label { fill: var(--sw-fg-2); font-size: 10.5px; }
+.node-label.sib { fill: var(--sw-fg-3); font-size: 8.5px; }
+.node-sib-glyph { fill: var(--sw-fg-1); font-size: 11px; font-weight: 700; 
font-family: var(--sw-mono); }
+.sit-node.sibling .node-bg { fill: var(--sw-bg-1); }
 .has-pop .sit-edge { opacity: 0.16; transition: opacity 0.12s ease; }
 .has-pop .sit-node:not(.sel) { opacity: 0.3; transition: opacity 0.12s ease; }
 
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 80cfa0b..d21e82d 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -67,6 +67,7 @@ export type {
   InstanceTopologyCall,
   InstanceTopologyResponse,
   ClusterByRule,
+  NodeRoleConfig,
   ServiceInternalTopologyConfig,
   ServiceInternalTopologyNode,
   ServiceInternalTopologyCall,
diff --git a/packages/api-client/src/topology.ts 
b/packages/api-client/src/topology.ts
index f9e7fb9..f471e08 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -180,15 +180,48 @@ export type ClusterByRule =
  * ServiceInstanceRelation server / client families), plus the optional
  * {@link ClusterByRule} for grouping nodes.
  */
+/** One container-role config for the sibling/pod model. `roleBy` yields a
+ *  role key per instance; this entry configures that key. */
+export interface NodeRoleConfig {
+  /** Role key — the value `roleBy` extracts (regex `valueGroup` capture or
+   *  attribute value), matched case-insensitively. */
+  key: string;
+  /** Display label for the role (tooltip / legend). */
+  label?: string;
+  /** This role's instance is the pod's MAIN container — rendered as the
+   *  full-size hex; the other siblings attach to it at 50% size. At most one
+   *  role per pod should be main; if none is, the first instance wins. */
+  main?: boolean;
+  /** Role-specific per-instance MQE (ServiceInstance scope). Falls back to
+   *  {@link ServiceInternalTopologyConfig.nodeMetrics} when absent. */
+  nodeMetrics?: TopologyMetricDef[];
+}
+
 export interface ServiceInternalTopologyConfig {
-  /** Per-instance MQE under `{ scope: ServiceInstance }`. */
+  /** Per-instance MQE under `{ scope: ServiceInstance }`. Default for any
+   *  instance whose role defines no `nodeMetrics`. */
   nodeMetrics: TopologyMetricDef[];
   /** Per-edge MQE under ServiceInstanceRelation, server side. */
   linkServerMetrics?: TopologyMetricDef[];
   /** Per-edge MQE under ServiceInstanceRelation, client side. */
   linkClientMetrics?: TopologyMetricDef[];
-  /** Optional node-clustering rule. Absent ⇒ no clustering. */
+  /** Node-clustering rule — separates pod-groups into dashed boxes. Absent ⇒
+   *  no clustering. Independent of `siblingBy` / `roleBy`. */
   clusterBy?: ClusterByRule;
+  /** Sibling rule — instances sharing this key belong to ONE pod and render
+   *  as a bundled hex group (main + attached siblings). Absent ⇒ every
+   *  instance is its own single-hex pod. */
+  siblingBy?: ClusterByRule;
+  /** Role rule — classifies each instance by container type; pairs with
+   *  `roles`. Drives main-hex selection + per-role MQE. */
+  roleBy?: ClusterByRule;
+  /** Per-role config (main flag + role-specific MQE), keyed by the value
+   *  `roleBy` yields. */
+  roles?: NodeRoleConfig[];
+  /** Preview-only: serve a named, isolated MOCK topology instead of querying
+   *  OAP (for layers with no real instance-relation data yet). Dropped once
+   *  real data exists; the component stays. */
+  mock?: string;
 }
 
 /** One instance node in the service-internal topology. Same shape as
@@ -202,11 +235,15 @@ export interface ServiceInternalTopologyNode {
   serviceId: string;
   serviceName: string;
   isReal: boolean;
-  /** Keyed by `ServiceInternalTopologyConfig.nodeMetrics[].id`. */
+  /** Keyed by the metric ids of this node's role (or `nodeMetrics` when the
+   *  node has no role). */
   metrics: Record<string, number | null>;
   /** Instance attributes (`node_role`, `node_type`, …) from
    *  `listInstances`. Empty when OAP exposes none. */
   attributes: Array<{ name: string; value: string }>;
+  /** Container role (from `roleBy`), when configured — drives main-hex
+   *  selection + which role's metric defs render. Absent ⇒ unroled. */
+  role?: string;
 }
 
 /** One instance-to-instance call within the selected service. Same


Reply via email to