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 531e80d8cf52321e1df17ec8e494533526a2a552
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 9 10:30:30 2026 +0800

    feat(layer): Service Internal Topology — instance graph within one service
    
    New optional, config-driven per-layer "Internal Topology" tab: the
    instance-to-instance call graph WITHIN a single service, via OAP
    getServiceInstanceTopology(svc, svc) (same id both sides → intra-service
    instance relations). Net-new and additive — independent of the existing
    service-map instance-topology drill-down.
    
    - Top-level `serviceInternalTopology` layer-template block: node +
      per-side edge MQE (ServiceInstance / ServiceInstanceRelation scope)
      plus a `clusterBy` rule — group instance nodes by an instance
      attribute (node_role / node_type) or by a name regex on the instance
      name. New `serviceInternalTopology` cap, gated on the component flag
      AND the config block (independent of serviceMap).
    - BFF route GET /api/layer/:key/internal-topology?service= — joins
      listInstances attributes onto nodes (for attribute clustering),
      supports self-loops, 404s when the layer doesn't configure it.
    - Service-scoped view (shell Service header + cluster-grid D3 topology,
      bidirectional/self-loop edges, node popover with attributes + Open
      instance dashboard, per-call client/server metric sidebar, ring legend).
    - Admin gains a Service Internal Topology config scope: node/server/client
      metric editors + the clusterBy editor. English i18n + sidebar tab +
      router route + cap gating.
    
    Ships disabled everywhere; a layer opts in via the config block.
---
 CHANGELOG.md                                       |  23 +
 apps/bff/src/http/query/internal-topology.ts       | 516 ++++++++++++++
 apps/bff/src/http/query/menu.ts                    |   8 +
 apps/bff/src/logic/layers/loader.ts                |  26 +-
 apps/bff/src/logic/layers/preview.ts               |  14 +
 apps/bff/src/rbac/route-policy.ts                  |   1 +
 apps/bff/src/server.ts                             |   6 +
 apps/ui/src/api/client.ts                          |  10 +-
 apps/ui/src/api/scopes/layer.ts                    |  25 +
 apps/ui/src/controls/previewConfig.ts              |   1 +
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 258 ++++++-
 apps/ui/src/i18n/locales/en.json                   |   7 +-
 apps/ui/src/layer/LayerShell.vue                   |   1 +
 .../LayerServiceInternalTopologyView.vue           | 752 +++++++++++++++++++++
 .../service-map/useServiceInternalTopology.ts      |  78 +++
 apps/ui/src/shell/AppSidebar.vue                   |  16 +
 apps/ui/src/shell/router/index.ts                  |   5 +
 apps/ui/src/shell/useLayers.ts                     |   1 +
 packages/api-client/src/index.ts                   |   5 +
 packages/api-client/src/menu.ts                    |   7 +
 packages/api-client/src/topology.ts                | 109 +++
 21 files changed, 1860 insertions(+), 9 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f135c51..dc574bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -69,6 +69,29 @@ packages) plus the BFF's `HORIZON_VERSION` default.
   stays the source) — no feature renders English-only for non-English
   operators.
 
+### Service internal topology
+
+- New per-layer **Internal Topology** tab — the instance-to-instance call
+  graph **within a single service**. Where the instance map drills into the
+  instances *between* two services, this shows how one service's own
+  instances talk to each other (e.g. a clustered store's nodes calling each
+  other). Pick a service from the layer's Service header and the tab draws
+  its instances as health-ring nodes with the intra-service calls between
+  them — pan/zoom, animated edge flow, the per-call client/server metric
+  sidebar, and a node popover that shows the instance's attributes and an
+  **Open instance dashboard** link. Self-calls and back-and-forth pairs are
+  drawn distinctly.
+- **Node clustering.** Instances can group into labelled boxes either by an
+  **instance attribute** (e.g. role / tier) or by a **name regex** run on
+  the instance name — so a fleet of mixed-role nodes reads as one box per
+  role instead of a flat cloud.
+- **Optional + configurable.** Off by default for every layer; a layer opts
+  in from the Layer-dashboards admin → **Internal Topology** scope, which
+  has its own node / server-edge / client-edge metric editors (instance
+  scope) plus the clustering-rule picker. The config is a self-contained
+  block on the layer template, so it travels with template export/import and
+  is independent of the service-map topology config.
+
 ### API dependency
 
 - The per-layer **API dependency** tab renders an endpoint's caller → callee
diff --git a/apps/bff/src/http/query/internal-topology.ts 
b/apps/bff/src/http/query/internal-topology.ts
new file mode 100644
index 0000000..f286c71
--- /dev/null
+++ b/apps/bff/src/http/query/internal-topology.ts
@@ -0,0 +1,516 @@
+/*
+ * 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.
+ */
+
+/**
+ * `GET /api/layer/:key/internal-topology?service=<svcId>`
+ *
+ * Service Internal Topology — the instance-to-instance call graph WITHIN
+ * one service. Unlike the service-map's instance drill-down (which spans
+ * two services), this asks OAP for `getServiceInstanceTopology(svc, svc)`:
+ * with the same id on both sides, OAP's relation filter collapses to
+ * `sourceServiceId == destServiceId == svc`, returning exactly the
+ * intra-service instance relations (e.g. a clustered store's nodes calling
+ * each other). It is a pure consumer — when no such relations exist the
+ * graph is empty, by design.
+ *
+ *  - Per-node MQE evaluates under `{ scope: ServiceInstance }`.
+ *  - Per-edge MQE evaluates under ServiceInstanceRelation (server + client
+ *    families, same per-side gate as the service map).
+ *  - Each node also carries its instance `attributes` (from listInstances)
+ *    so the UI can cluster nodes by an attribute (node_role / node_type).
+ *
+ * The metric + cluster config is the layer template's top-level
+ * `serviceInternalTopology` block. Absent ⇒ 404 (the tab only appears for
+ * layers that configure it).
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import type {
+  FetchLike,
+  ServiceInternalTopologyCall,
+  ServiceInternalTopologyConfig,
+  ServiceInternalTopologyNode,
+  ServiceInternalTopologyResponse,
+  TopologyMetricDef,
+  UITemplateClient,
+} from '@skywalking-horizon-ui/api-client';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
+import {
+  defaultMinuteWindow,
+  getServerOffsetMinutes,
+  windowFromRange,
+  type TimeStep,
+  type Window,
+} from '../../util/window.js';
+import { serviceInternalTopologyConfigFor } from 
'../../logic/layers/loader.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { parsePreviewServiceInternalTopology } from 
'../../logic/layers/preview.js';
+import { aggregateMqe, seriesFromMqe, type MqeShape } from './topology.js';
+
+export interface InternalTopologyRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+  /** OAP UI-template client — serves the in-use (remote-or-bundled)
+   *  config, matching the admin + sidebar. */
+  uiTemplateClient?: () => UITemplateClient;
+}
+
+interface OapInstNode {
+  id: string;
+  name: string;
+  serviceName: string;
+  serviceId: string;
+  isReal: boolean;
+}
+interface OapInstCall {
+  id: string;
+  source: string;
+  target: string;
+  detectPoints: string[];
+}
+interface InstanceTopologyResp {
+  topology: { nodes: OapInstNode[]; calls: OapInstCall[] };
+}
+interface OapInstanceMeta {
+  id: string;
+  name: string;
+  attributes?: Array<{ name: string; value: string }> | null;
+}
+
+const INSTANCE_TOPOLOGY = /* GraphQL */ `
+  query InternalInstanceTopology($clientServiceId: ID!, $serverServiceId: ID!, 
$duration: Duration!) {
+    topology: getServiceInstanceTopology(
+      clientServiceId: $clientServiceId
+      serverServiceId: $serverServiceId
+      duration: $duration
+    ) {
+      nodes { id name serviceName serviceId isReal }
+      calls { id source target detectPoints }
+    }
+  }
+`;
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+  query ListServicesForInternalTopology($layer: String!) {
+    services: listServices(layer: $layer) {
+      id
+      name
+      normal
+    }
+  }
+`;
+
+const LIST_INSTANCES = /* GraphQL */ `
+  query InternalTopologyInstances($serviceId: ID!, $duration: Duration!) {
+    instances: listInstances(serviceId: $serviceId, duration: $duration) {
+      id
+      name
+      attributes {
+        name
+        value
+      }
+    }
+  }
+`;
+
+const DEFAULT_WINDOW_MIN = 60;
+
+/** Per-instance fragment under `{ scope: ServiceInstance }`. */
+function nodeFragment(
+  alias: string,
+  m: TopologyMetricDef,
+  serviceName: string,
+  instanceName: string,
+  normal: boolean,
+  w: Window,
+  coldStage: boolean,
+): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
+  return (
+    `${alias}: execExpression(\n` +
+    `      expression: ${JSON.stringify(m.mqe)},\n` +
+    `      entity: { scope: ServiceInstance, serviceName: 
${JSON.stringify(serviceName)},` +
+    ` normal: ${normal ? 'true' : 'false'}, serviceInstanceName: 
${JSON.stringify(instanceName)} },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: 
${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
+    `    ) { type error results { values { value } } }`
+  );
+}
+
+/**
+ * Per-edge fragment for ServiceInstanceRelation. As with the service-map
+ * relation fragment we do NOT set `scope` — OAP infers it from the metric
+ * name. Both endpoints share the selected service (intra-service graph),
+ * so the same service name + normal flag rides both sides.
+ */
+function relationFragment(
+  alias: string,
+  m: TopologyMetricDef,
+  serviceName: string,
+  srcInstanceName: string,
+  dstInstanceName: string,
+  normal: boolean,
+  w: Window,
+  coldStage: boolean,
+): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
+  return (
+    `${alias}: execExpression(\n` +
+    `      expression: ${JSON.stringify(m.mqe)},\n` +
+    `      entity: {` +
+    ` serviceName: ${JSON.stringify(serviceName)},` +
+    ` normal: ${normal ? 'true' : 'false'},` +
+    ` serviceInstanceName: ${JSON.stringify(srcInstanceName)},` +
+    ` destServiceName: ${JSON.stringify(serviceName)},` +
+    ` destNormal: ${normal ? 'true' : 'false'},` +
+    ` destServiceInstanceName: ${JSON.stringify(dstInstanceName)} },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: 
${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
+    `    ) { type error results { values { value } } }`
+  );
+}
+
+function emptyResponse(
+  layerKey: string,
+  serviceId: string,
+  cfg: ServiceInternalTopologyConfig,
+  reachable: boolean,
+  err?: string,
+): ServiceInternalTopologyResponse {
+  return {
+    layer: layerKey,
+    serviceId,
+    serviceName: null,
+    generatedAt: Date.now(),
+    config: cfg,
+    nodes: [],
+    calls: [],
+    reachable,
+    ...(err ? { error: err } : {}),
+  };
+}
+
+export function registerInternalTopologyRoute(
+  app: FastifyInstance,
+  deps: InternalTopologyRouteDeps,
+): void {
+  const auth = requireAuth(deps);
+  app.get(
+    '/api/layer/:key/internal-topology',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
+        return reply.code(400).send({ error: 'invalid_layer_key' });
+      }
+      const q = req.query as {
+        service?: string;
+        step?: string;
+        startMs?: string;
+        endMs?: string;
+        previewConfig?: string;
+      };
+      const serviceId = (q.service ?? '').trim();
+      if (!serviceId) {
+        return reply.code(400).send({ error: 'missing_service' });
+      }
+
+      // Admin Preview: the page forwards the draft `serviceInternalTopology`
+      // block; when previewing, that draft decides support (404 if it has
+      // no metrics), bypassing the remote template entirely.
+      const previewCfg = parsePreviewServiceInternalTopology(q.previewConfig);
+      let cfg: ServiceInternalTopologyConfig | null;
+      if (previewCfg) {
+        cfg = previewCfg;
+      } else {
+        const eff = await resolveEffectiveLayer(deps.uiTemplateClient, 
layerKey);
+        if (eff.blocked) {
+          // Template store unreachable (or this layer's template disabled)
+          // — block (like the service-topology route) instead of a
+          // misleading "not supported" 404. The SPA's connectivity banner
+          // explains the empty state.
+          return reply.send(
+            emptyResponse(layerKey, serviceId, { nodeMetrics: [] }, false),
+          );
+        }
+        cfg = serviceInternalTopologyConfigFor(eff.template);
+      }
+      if (!cfg) {
+        return reply.code(404).send({ error: 
'service_internal_topology_not_supported' });
+      }
+
+      const cfgCurrent = deps.config.current;
+      const opts = buildOapOpts(cfgCurrent, deps.fetch);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      // Honor the SPA's topbar picker triplet; else fall back to the
+      // last-hour MINUTE window (dashboards family — minute precision).
+      const stepArg = (q.step ?? '').toUpperCase() as TimeStep;
+      const startMs = Number(q.startMs);
+      const endMs = Number(q.endMs);
+      const window: Window =
+        (stepArg === 'MINUTE' || stepArg === 'HOUR' || stepArg === 'DAY') &&
+        Number.isFinite(startMs) &&
+        Number.isFinite(endMs)
+          ? windowFromRange(stepArg, startMs, endMs, offset) ??
+            defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN)
+          : defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN);
+      const oapLayer = layerKey.toUpperCase();
+      const durationVar = withColdStage(req, { start: window.start, end: 
window.end, step: window.step });
+      const coldStage = !!req.coldStage;
+
+      // ── Resolve the selected service's name + normal flag (the node
+      // entity needs the SERVICE's normal flag). Booster resolves
+      // `normal = service.normal || isReal`.
+      let serviceName: string | null = null;
+      let serviceNormal = true;
+      try {
+        const data = await graphqlPost<{
+          services: Array<{ id: string; name: string; normal?: boolean | null 
}>;
+        }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: oapLayer });
+        const svc = data.services.find((s) => s.id === serviceId) ?? null;
+        if (svc) {
+          serviceName = svc.name;
+          serviceNormal = svc.normal !== false;
+        }
+      } catch (err) {
+        return reply.send(
+          emptyResponse(layerKey, serviceId, cfg, false,
+            err instanceof Error ? err.message : String(err)),
+        );
+      }
+
+      // ── Fetch the intra-service instance topology (same id both sides).
+      let topo: { nodes: OapInstNode[]; calls: OapInstCall[] };
+      try {
+        const data = await graphqlPost<InstanceTopologyResp>(opts, 
INSTANCE_TOPOLOGY, {
+          clientServiceId: serviceId,
+          serverServiceId: serviceId,
+          duration: durationVar,
+        });
+        topo = data.topology;
+      } catch (err) {
+        return reply.send(
+          emptyResponse(layerKey, serviceId, cfg, false,
+            err instanceof Error ? err.message : String(err)),
+        );
+      }
+
+      const nodes = topo.nodes ?? [];
+      const calls = topo.calls ?? [];
+      const nodeById = new Map<string, OapInstNode>();
+      for (const n of nodes) nodeById.set(n.id, n);
+      // OAP hands the decoded service name on each instance node; prefer the
+      // roster name but fall back to it for services missing from the
+      // roster snapshot.
+      if (!serviceName) serviceName = nodes.find((n) => n.serviceId === 
serviceId)?.serviceName ?? null;
+      const entityServiceName = serviceName ?? '';
+
+      // ── Per-instance attributes (node_role / node_type / …) so the UI can
+      // cluster by attribute. Soft-fail: the graph still renders without
+      // attributes, only attribute-clustering degrades to ungrouped.
+      const attrsById = new Map<string, Array<{ name: string; value: string 
}>>();
+      const attrsByName = new Map<string, Array<{ name: string; value: string 
}>>();
+      try {
+        const data = await graphqlPost<{ instances: OapInstanceMeta[] }>(opts, 
LIST_INSTANCES, {
+          serviceId,
+          duration: durationVar,
+        });
+        for (const inst of data.instances ?? []) {
+          const a = inst.attributes ?? [];
+          attrsById.set(inst.id, a);
+          attrsByName.set(inst.name, a);
+        }
+      } catch {
+        // keep going with empty attribute maps
+      }
+      function attrsFor(n: OapInstNode): Array<{ name: string; value: string 
}> {
+        return attrsById.get(n.id) ?? attrsByName.get(n.name) ?? [];
+      }
+
+      // ── Per-node MQE.
+      const nodeMetricVals = new Map<string, Record<string, number | null>>();
+      const realNodes = nodes.filter((n) => n.isReal);
+      if (realNodes.length > 0 && cfg.nodeMetrics.length > 0) {
+        const fragments: string[] = [];
+        const aliasMap = new Map<string, { nodeId: string; metric: 
TopologyMetricDef }>();
+        realNodes.forEach((n, i) => {
+          cfg.nodeMetrics.forEach((m, j) => {
+            const alias = `n${i}_${j}`;
+            aliasMap.set(alias, { nodeId: n.id, metric: m });
+            fragments.push(nodeFragment(alias, m, n.serviceName, n.name, 
serviceNormal, window, coldStage));
+          });
+        });
+        const CHUNK = 150;
+        for (let i = 0; i < fragments.length; i += CHUNK) {
+          const slice = fragments.slice(i, i + CHUNK);
+          const query = `query InternalNodeMetrics {\n  ${slice.join('\n  
')}\n}`;
+          let env: Record<string, MqeShape>;
+          try {
+            env = await graphqlPost<Record<string, MqeShape>>(opts, query);
+          } catch {
+            break; // soft-fail: keep the graph with null node metrics
+          }
+          for (const [alias, shape] of Object.entries(env)) {
+            const info = aliasMap.get(alias);
+            if (!info) continue;
+            const v = aggregateMqe(shape, info.metric.aggregation ?? 'avg');
+            const rec = nodeMetricVals.get(info.nodeId) ?? {};
+            rec[info.metric.id] = v;
+            nodeMetricVals.set(info.nodeId, rec);
+          }
+        }
+      }
+
+      // ── Per-edge MQE (server + client families, per-side gate). Self-loop
+      // edges (source === target) are allowed — a node may call itself.
+      const serverMetricVals = new Map<string, Record<string, number | 
null>>();
+      const clientMetricVals = new Map<string, Record<string, number | 
null>>();
+      const serverMetricSeries = new Map<string, Record<string, Array<number | 
null> | null>>();
+      const clientMetricSeries = new Map<string, Record<string, Array<number | 
null> | null>>();
+      const linkSrv = cfg.linkServerMetrics ?? [];
+      const linkCli = cfg.linkClientMetrics ?? [];
+      const candidateEdges = calls.filter((c) => {
+        const a = nodeById.get(c.source);
+        const b = nodeById.get(c.target);
+        return !!a && !!b && !!a.name && !!b.name;
+      });
+      if (candidateEdges.length > 0 && (linkSrv.length > 0 || linkCli.length > 
0)) {
+        const fragments: string[] = [];
+        const aliasMap = new Map<
+          string,
+          { callId: string; metric: TopologyMetricDef; side: 'server' | 
'client' }
+        >();
+        candidateEdges.forEach((c, i) => {
+          const src = nodeById.get(c.source)!;
+          const dst = nodeById.get(c.target)!;
+          if (dst.isReal) {
+            linkSrv.forEach((m, j) => {
+              const alias = `s${i}_${j}`;
+              aliasMap.set(alias, { callId: c.id, metric: m, side: 'server' });
+              fragments.push(
+                relationFragment(alias, m, entityServiceName, src.name, 
dst.name, serviceNormal, window, coldStage),
+              );
+            });
+          }
+          if (src.isReal) {
+            linkCli.forEach((m, j) => {
+              const alias = `c${i}_${j}`;
+              aliasMap.set(alias, { callId: c.id, metric: m, side: 'client' });
+              fragments.push(
+                relationFragment(alias, m, entityServiceName, src.name, 
dst.name, serviceNormal, window, coldStage),
+              );
+            });
+          }
+        });
+        const CHUNK = 200;
+        for (let i = 0; i < fragments.length; i += CHUNK) {
+          const slice = fragments.slice(i, i + CHUNK);
+          const query = `query InternalEdgeMetrics {\n  ${slice.join('\n  
')}\n}`;
+          let env: Record<string, MqeShape>;
+          try {
+            env = await graphqlPost<Record<string, MqeShape>>(opts, query);
+          } catch {
+            break;
+          }
+          for (const [alias, shape] of Object.entries(env)) {
+            const info = aliasMap.get(alias);
+            if (!info) continue;
+            const v = aggregateMqe(shape, info.metric.aggregation ?? 'avg');
+            const valBucket = info.side === 'server' ? serverMetricVals : 
clientMetricVals;
+            const seriesBucket = info.side === 'server' ? serverMetricSeries : 
clientMetricSeries;
+            const valRec = valBucket.get(info.callId) ?? {};
+            valRec[info.metric.id] = v;
+            valBucket.set(info.callId, valRec);
+            const sRec = seriesBucket.get(info.callId) ?? {};
+            sRec[info.metric.id] = seriesFromMqe(shape);
+            seriesBucket.set(info.callId, sRec);
+          }
+        }
+      }
+
+      // ── Build response. Connected instances only — an instance with no
+      // edge in the window doesn't belong on the graph.
+      const connectedNodeIds = new Set<string>();
+      for (const c of calls) {
+        connectedNodeIds.add(c.source);
+        connectedNodeIds.add(c.target);
+      }
+      const liveNodes: ServiceInternalTopologyNode[] = [];
+      for (const n of nodes) {
+        if (!connectedNodeIds.has(n.id)) continue;
+        const m = nodeMetricVals.get(n.id) ?? {};
+        const filled: Record<string, number | null> = {};
+        for (const def of cfg.nodeMetrics) filled[def.id] = m[def.id] ?? null;
+        liveNodes.push({
+          id: n.id,
+          name: n.name,
+          serviceId: n.serviceId,
+          serviceName: n.serviceName,
+          isReal: n.isReal,
+          metrics: filled,
+          attributes: attrsFor(n),
+        });
+      }
+      const liveNodeIds = new Set(liveNodes.map((n) => n.id));
+      const liveCalls: ServiceInternalTopologyCall[] = [];
+      for (const c of calls) {
+        if (!liveNodeIds.has(c.source) || !liveNodeIds.has(c.target)) continue;
+        const sm = serverMetricVals.get(c.id) ?? {};
+        const cm = clientMetricVals.get(c.id) ?? {};
+        const ss = serverMetricSeries.get(c.id) ?? {};
+        const cs = clientMetricSeries.get(c.id) ?? {};
+        const filledSrv: Record<string, number | null> = {};
+        const filledSrvSeries: Record<string, Array<number | null> | null> = 
{};
+        for (const def of linkSrv) {
+          filledSrv[def.id] = sm[def.id] ?? null;
+          filledSrvSeries[def.id] = ss[def.id] ?? null;
+        }
+        const filledCli: Record<string, number | null> = {};
+        const filledCliSeries: Record<string, Array<number | null> | null> = 
{};
+        for (const def of linkCli) {
+          filledCli[def.id] = cm[def.id] ?? null;
+          filledCliSeries[def.id] = cs[def.id] ?? null;
+        }
+        liveCalls.push({
+          id: c.id,
+          source: c.source,
+          target: c.target,
+          detectPoints: c.detectPoints ?? [],
+          serverMetrics: filledSrv,
+          clientMetrics: filledCli,
+          serverMetricSeries: filledSrvSeries,
+          clientMetricSeries: filledCliSeries,
+        });
+      }
+
+      return reply.send({
+        layer: layerKey,
+        serviceId,
+        serviceName,
+        generatedAt: Date.now(),
+        config: cfg,
+        nodes: liveNodes,
+        calls: liveCalls,
+        reachable: true,
+      } satisfies ServiceInternalTopologyResponse);
+    },
+  );
+}
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 8846059..086d6bd 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -56,6 +56,9 @@ function componentsToCaps(components: LayerComponentFlags): 
LayerCaps {
     // topology.instanceTopology config block, not the component flag —
     // overridden per-layer at the call site (see resolveLayerDef).
     instanceTopology: false,
+    // serviceInternalTopology rides the component flag here; the call site
+    // ANDs it with the presence of the top-level config block.
+    serviceInternalTopology: !!components.serviceInternalTopology,
     processTopology: !!components.topology,
     traces: !!components.traces,
     logs: !!components.logs,
@@ -299,6 +302,11 @@ function deriveLayer(
         // topology map, so disabling the Topology component must hide it
         // too — even if a stale `topology.instanceTopology` block lingers.
         c.instanceTopology = c.serviceMap && 
!!rawTpl?.topology?.instanceTopology;
+        // Service Internal Topology is its own tab (not a drill-down of the
+        // service map), so it's gated only on its own config block presence
+        // AND its component flag — independent of `serviceMap`.
+        c.serviceInternalTopology =
+          c.serviceInternalTopology && !!rawTpl?.serviceInternalTopology;
         return c;
       })(),
       header: tpl.header,
diff --git a/apps/bff/src/logic/layers/loader.ts 
b/apps/bff/src/logic/layers/loader.ts
index 768cf93..8f24d2f 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -41,6 +41,7 @@ import type {
   EndpointDependencyConfig,
   InstanceTopologyConfig,
   ProcessTopologyConfig,
+  ServiceInternalTopologyConfig,
   ServiceNamingRule,
   TopologyConfig,
   TopologyMetricDef,
@@ -48,7 +49,7 @@ import type {
 } from '@skywalking-horizon-ui/api-client';
 import { isOverlayFilename, reloadI18nStore } from '../../i18n/store.js';
 
-export type { TopologyConfig, InstanceTopologyConfig, 
EndpointDependencyConfig, ProcessTopologyConfig, TopologyMetricDef, 
TracesConfig, ServiceNamingRule };
+export type { TopologyConfig, InstanceTopologyConfig, 
EndpointDependencyConfig, ProcessTopologyConfig, ServiceInternalTopologyConfig, 
TopologyMetricDef, TracesConfig, ServiceNamingRule };
 
 export interface LayerComponentFlags {
   service?: boolean;
@@ -72,6 +73,10 @@ export interface LayerComponentFlags {
    *  fetched on demand from the K8s API (never persisted). Only K8s-
    *  deployed layers (k8s_service, mesh) carry pods that resolve. */
   podLogs?: boolean;
+  /** Service-internal-topology tab — instance-to-instance call graph
+   *  within one service. Opt-in; the tab also requires a
+   *  `serviceInternalTopology` config block. */
+  serviceInternalTopology?: boolean;
 }
 
 export interface LayerSlotsConfig {
@@ -83,6 +88,8 @@ export interface LayerSlotsConfig {
   topology?: string;
   /** Instance-topology sub-tab label (default "Instance map"). */
   instanceTopology?: string;
+  /** Service-internal-topology tab label (default "Internal Topology"). */
+  serviceInternalTopology?: string;
 }
 
 export interface LayerMetricColumn {
@@ -215,6 +222,12 @@ export interface LayerTemplate {
    *  editable ProcessRelation MQE. When absent the loader fills it from
    *  {@link BOOSTER_PROCESS_TOPOLOGY_DEFAULTS}. */
   processTopology?: ProcessTopologyConfig;
+  /** Service-internal-topology config — operator-editable node + per-side
+   *  edge MQE (ServiceInstance / ServiceInstanceRelation scope) plus an
+   *  optional node-clustering rule. Top-level + independent of `topology`;
+   *  its presence opts the layer into the "Service Internal Topology" tab.
+   *  No defaults — absent ⇒ the tab is off. */
+  serviceInternalTopology?: ServiceInternalTopologyConfig;
   /** Traces tab config. The `source` field picks which trace backend
    *  the UI's filter selector defaults to (`both` shows two parallel
    *  tables; `native` / `zipkin` pin to one). Default `both` when
@@ -571,6 +584,17 @@ export function instanceTopologyConfigFor(
   return template?.topology?.instanceTopology ?? null;
 }
 
+/** Resolve the service-internal-topology config, or `null` when the layer
+ *  doesn't opt in. A top-level `serviceInternalTopology` block (independent
+ *  of `topology`); only layers that ship it return non-null. No
+ *  booster-style defaults — the metric set is layer-specific (instance
+ *  scope), so an unconfigured layer simply has no Internal Topology tab. */
+export function serviceInternalTopologyConfigFor(
+  template: LayerTemplate | null,
+): ServiceInternalTopologyConfig | null {
+  return template?.serviceInternalTopology ?? null;
+}
+
 /** Resolve the endpoint-dependency config — same fallback rule. */
 export function endpointDependencyConfigFor(
   template: LayerTemplate | null,
diff --git a/apps/bff/src/logic/layers/preview.ts 
b/apps/bff/src/logic/layers/preview.ts
index 62d581a..87d1c04 100644
--- a/apps/bff/src/logic/layers/preview.ts
+++ b/apps/bff/src/logic/layers/preview.ts
@@ -38,6 +38,7 @@ import type {
   EndpointDependencyConfig,
   TracesConfig,
   ProcessTopologyConfig,
+  ServiceInternalTopologyConfig,
   TopologyMetricDef,
 } from './loader.js';
 
@@ -88,6 +89,19 @@ export function parsePreviewTopology(raw: string | 
undefined): TopologyConfig |
   return o as unknown as TopologyConfig;
 }
 
+/** `serviceInternalTopology` block — node + per-side link metric lists,
+ *  plus the optional `clusterBy` rule (rides through verbatim once the
+ *  metric lists pass the same bound; it carries no MQE to validate). */
+export function parsePreviewServiceInternalTopology(
+  raw: string | undefined,
+): ServiceInternalTopologyConfig | null {
+  const o = parseJson(raw);
+  if (!o || !isMetricList(o.nodeMetrics)) return null;
+  if (o.linkServerMetrics !== undefined && !isMetricList(o.linkServerMetrics)) 
return null;
+  if (o.linkClientMetrics !== undefined && !isMetricList(o.linkClientMetrics)) 
return null;
+  return o as unknown as ServiceInternalTopologyConfig;
+}
+
 /** `endpointDependency` block — node + (server-only) link metric lists. */
 export function parsePreviewEndpointDep(raw: string | undefined): 
EndpointDependencyConfig | null {
   const o = parseJson(raw);
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 6c470b9..c76c5dd 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -100,6 +100,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   // ── Topology (read) ──────────────────────────────────────────────
   'GET /api/layer/:key/topology':                  'topology:read',
   'GET /api/layer/:key/instance-topology':         'topology:read',
+  'GET /api/layer/:key/internal-topology':         'topology:read',
   'GET /api/layer/:key/endpoint-dependency':       'topology:read',
   'GET /api/layer/:key/service-hierarchy':         'topology:read',
 
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 0efb562..00aade4 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -36,6 +36,7 @@ import { registerInstanceRoute } from 
'./http/query/instance.js';
 import { registerEndpointRoute } from './http/query/endpoint.js';
 import { registerTopologyRoute } from './http/query/topology.js';
 import { registerInstanceTopologyRoute } from 
'./http/query/instance-topology.js';
+import { registerInternalTopologyRoute } from 
'./http/query/internal-topology.js';
 import { registerLayerServicesRoute } from './http/query/services.js';
 import { registerEndpointDependencyRoute } from 
'./http/query/endpoint-dependency.js';
 import { registerTraceRoutes } from './http/query/trace.js';
@@ -179,6 +180,11 @@ registerInstanceTopologyRoute(app, {
   sessions,
   uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
 });
+registerInternalTopologyRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerLayerServicesRoute(app, { config: source, sessions });
 registerEndpointDependencyRoute(app, {
   config: source,
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 7d82380..684d696 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -42,6 +42,7 @@ import type {
   MetricRow,
   ProcessTopologyConfig,
   RuleStatus,
+  ServiceInternalTopologyConfig,
   TopologyConfig,
   TracesConfig,
   Catalog,
@@ -129,6 +130,11 @@ export type {
   InstanceTopologyNode,
   InstanceTopologyCall,
   InstanceTopologyResponse,
+  ClusterByRule,
+  ServiceInternalTopologyConfig,
+  ServiceInternalTopologyNode,
+  ServiceInternalTopologyCall,
+  ServiceInternalTopologyResponse,
   EndpointDependencyNode,
   EndpointDependencyCall,
   EndpointDependencyResponse,
@@ -262,13 +268,14 @@ export interface AdminLayerTemplate {
   /** `public` (default) surfaces in the Layers section; `operate`
    *  surfaces in the Self-Observability block under Manage. */
   visibility?: 'public' | 'operate';
-  slots: { services?: string; instances?: string; endpoints?: string; 
endpointDependency?: string; topology?: string; instanceTopology?: string };
+  slots: { services?: string; instances?: string; endpoints?: string; 
endpointDependency?: string; topology?: string; instanceTopology?: string; 
serviceInternalTopology?: string };
   components: {
     service?: boolean;
     instances?: boolean;
     endpoints?: boolean;
     endpointDependency?: boolean;
     topology?: boolean;
+    serviceInternalTopology?: boolean;
     traces?: boolean;
     logs?: boolean;
     podLogs?: boolean;
@@ -295,6 +302,7 @@ export interface AdminLayerTemplate {
   overview?: LayerOverviewConfig;
   widgets: DashboardWidget[];
   topology?: TopologyConfig;
+  serviceInternalTopology?: ServiceInternalTopologyConfig;
   endpointDependency?: EndpointDependencyConfig;
   processTopology?: ProcessTopologyConfig;
   traces?: TracesConfig;
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index d97744f..96e3554 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -24,6 +24,7 @@ import type {
   LandingConfig,
   LandingResponse,
   ServiceHierarchyResponse,
+  ServiceInternalTopologyResponse,
   TopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
 import { pushEvent } from '@/controls/eventLog';
@@ -232,6 +233,30 @@ export class LayerApi {
     );
   }
 
+  /** Service Internal Topology — instance-to-instance call graph WITHIN
+   *  one service (OAP's getServiceInstanceTopology with the same id on both
+   *  sides). Only layers carrying a `serviceInternalTopology` config block
+   *  answer this (404 otherwise). */
+  serviceInternalTopology(
+    layerKey: string,
+    serviceId: string,
+    range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number 
},
+    /** Admin preview: the operator's draft `serviceInternalTopology` block. */
+    previewConfig?: string,
+  ): Promise<ServiceInternalTopologyResponse> {
+    const qs = new URLSearchParams({ service: serviceId });
+    if (range) {
+      qs.set('step', range.step);
+      qs.set('startMs', String(range.startMs));
+      qs.set('endMs', String(range.endMs));
+    }
+    if (previewConfig) qs.set('previewConfig', previewConfig);
+    return this.bff.request(
+      'GET',
+      
`/api/layer/${encodeURIComponent(layerKey)}/internal-topology?${qs.toString()}`,
+    );
+  }
+
   endpointDependency(
     layerKey: string,
     service: string,
diff --git a/apps/ui/src/controls/previewConfig.ts 
b/apps/ui/src/controls/previewConfig.ts
index f7fcde5..3c67eb4 100644
--- a/apps/ui/src/controls/previewConfig.ts
+++ b/apps/ui/src/controls/previewConfig.ts
@@ -37,6 +37,7 @@ import { layerEditName } from '@/controls/localTemplateEdits';
 
 export type PreviewBlock =
   | 'topology'
+  | 'serviceInternalTopology'
   | 'endpointDependency'
   | 'traces'
   | 'processTopology';
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 87a0fe5..8cc9f06 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -32,20 +32,21 @@ import { useI18n } from 'vue-i18n';
 import { useRoute, useRouter } from 'vue-router';
 import type { AdminLayerTemplate } from '@/api/client';
 import type {
+  ClusterByRule,
   DashboardScope,
   DashboardWidget,
   EndpointDependencyConfig,
   ProcessTopologyConfig,
+  ServiceInternalTopologyConfig,
   TopologyConfig,
   TopologyMetricDef,
 } from '@skywalking-horizon-ui/api-client';
 
-/** Admin-only scope. `networkProfiling` isn't a dashboard-widget scope
- *  (the network-profiling page is the process topology + edge panel, not
- *  a widget grid), so it lives outside `DashboardScope` — but the admin's
- *  scope-tab strip surfaces it as an editable config tab for the
- *  ProcessRelation MQE. */
-type AdminScope = DashboardScope | 'networkProfiling';
+/** Admin-only scopes that aren't dashboard-widget scopes. `networkProfiling`
+ *  is the process-topology edge editor; `serviceInternalTopology` is the
+ *  instance-internal-topology config (node + edge MQE + clusterBy). Both
+ *  live outside `DashboardScope` but surface as editable config tabs. */
+type AdminScope = DashboardScope | 'networkProfiling' | 
'serviceInternalTopology';
 import { bff, bffClient, BffApiError } from '@/api/client';
 import { useLocalTemplateEdits, layerEditName } from 
'@/controls/localTemplateEdits';
 import { useTemplateSources } from 
'@/features/admin/_shared/useTemplateSources';
@@ -75,6 +76,7 @@ const SCOPES: AdminScope[] = [
   // Topology before dependency — operator order request: service map
   // is the primary canvas; API dependency drills into one endpoint.
   'topology',
+  'serviceInternalTopology',
   'dependency',
   'trace',
   'logs',
@@ -92,6 +94,7 @@ const SCOPE_LABELS: Record<AdminScope, string> = {
   endpoint: 'endpoint',
   dependency: 'dependency',
   topology: 'topology',
+  serviceInternalTopology: 'internal topology',
   trace: 'trace',
   logs: 'logs',
   traceProfiling: 'trace profiling',
@@ -487,6 +490,7 @@ const SCOPE_COMPONENT: Record<AdminScope, ComponentKey> = {
   endpoint: 'endpoints',
   dependency: 'endpointDependency',
   topology: 'topology',
+  serviceInternalTopology: 'serviceInternalTopology' as ComponentKey,
   trace: 'traces',
   logs: 'logs',
   // Profiling scopes: each granular component flag controls one tab.
@@ -1172,10 +1176,23 @@ function ensureProcessTopology(): ProcessTopologyConfig 
{
   if (!tpl.processTopology.edgeServerMetrics) 
tpl.processTopology.edgeServerMetrics = [];
   return tpl.processTopology;
 }
+function emptyServiceInternalTopology(): ServiceInternalTopologyConfig {
+  return { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] };
+}
+function ensureServiceInternalTopology(): ServiceInternalTopologyConfig {
+  if (!draft.template) throw new Error('no template selected');
+  const tpl = draft.template;
+  if (!tpl.serviceInternalTopology) tpl.serviceInternalTopology = 
emptyServiceInternalTopology();
+  const s = tpl.serviceInternalTopology;
+  if (!s.linkServerMetrics) s.linkServerMetrics = [];
+  if (!s.linkClientMetrics) s.linkClientMetrics = [];
+  return s;
+}
 
 type MetricBucket =
   | 'node' | 'linkServer' | 'linkClient'
   | 'instNode' | 'instLinkServer' | 'instLinkClient'
+  | 'sitNode' | 'sitLinkServer' | 'sitLinkClient'
   | 'link' | 'edgeClient' | 'edgeServer';
 
 function getMetricList(bucket: MetricBucket): TopologyMetricDef[] {
@@ -1191,6 +1208,11 @@ function getMetricList(bucket: MetricBucket): 
TopologyMetricDef[] {
     if (bucket === 'instNode') return t.instanceTopology?.nodeMetrics ?? [];
     if (bucket === 'instLinkServer') return 
t.instanceTopology?.linkServerMetrics ?? [];
     if (bucket === 'instLinkClient') return 
t.instanceTopology?.linkClientMetrics ?? [];
+  } else if (activeScope.value === 'serviceInternalTopology') {
+    const t = ensureServiceInternalTopology();
+    if (bucket === 'sitNode') return t.nodeMetrics;
+    if (bucket === 'sitLinkServer') return t.linkServerMetrics ?? [];
+    if (bucket === 'sitLinkClient') return t.linkClientMetrics ?? [];
   } else if (activeScope.value === 'dependency') {
     const t = ensureEndpointDep();
     if (bucket === 'node') return t.nodeMetrics;
@@ -1254,6 +1276,84 @@ function toggleInstanceTopology(): void {
     t.instanceTopology = { nodeMetrics: [], linkServerMetrics: [], 
linkClientMetrics: [] };
   }
 }
+// Service-internal-topology config (top-level, independent of `topology`).
+const serviceInternalNodeMetrics = computed(() => getMetricList('sitNode'));
+const serviceInternalServerMetrics = computed(() => 
getMetricList('sitLinkServer'));
+const serviceInternalClientMetrics = computed(() => 
getMetricList('sitLinkClient'));
+
+// clusterBy editor — three modes: off / by instance attribute / by name
+// regex. Reads + writes `serviceInternalTopology.clusterBy`; switching mode
+// reshapes the discriminated union.
+type ClusterMode = 'none' | 'attribute' | 'nameRegex';
+const sitClusterMode = computed<ClusterMode>({
+  get: () => {
+    const cb = draft.template?.serviceInternalTopology?.clusterBy;
+    return cb?.kind ?? 'none';
+  },
+  set: (mode) => {
+    const t = ensureServiceInternalTopology();
+    if (mode === 'none') {
+      delete t.clusterBy;
+    } else if (mode === 'attribute') {
+      const prev = t.clusterBy;
+      t.clusterBy = {
+        kind: 'attribute',
+        attribute: prev?.kind === 'attribute' ? prev.attribute : 'node_role',
+        alias: prev?.alias ?? 'role',
+      };
+    } else {
+      const prev = t.clusterBy;
+      t.clusterBy = {
+        kind: 'nameRegex',
+        pattern: prev?.kind === 'nameRegex' ? prev.pattern : '',
+        flags: prev?.kind === 'nameRegex' ? prev.flags : undefined,
+        displayGroup: prev?.kind === 'nameRegex' ? prev.displayGroup : 
undefined,
+        valueGroup: prev?.kind === 'nameRegex' ? prev.valueGroup : undefined,
+        alias: prev?.alias ?? 'group',
+      };
+    }
+  },
+});
+function clusterRuleField<K extends keyof Extract<ClusterByRule, { kind: 
'nameRegex' }>>(
+  field: K,
+  kind: ClusterByRule['kind'],
+) {
+  return computed<string>({
+    get: () => {
+      const cb = draft.template?.serviceInternalTopology?.clusterBy;
+      if (!cb || cb.kind !== kind) return '';
+      return (cb as Record<string, unknown>)[field as string] as string ?? '';
+    },
+    set: (v) => {
+      const cb = ensureServiceInternalTopology().clusterBy;
+      if (cb && cb.kind === kind) {
+        (cb as Record<string, unknown>)[field as string] = v || undefined;
+      }
+    },
+  });
+}
+const sitClusterAttribute = computed<string>({
+  get: () => {
+    const cb = draft.template?.serviceInternalTopology?.clusterBy;
+    return cb?.kind === 'attribute' ? cb.attribute : '';
+  },
+  set: (v) => {
+    const cb = ensureServiceInternalTopology().clusterBy;
+    if (cb?.kind === 'attribute') cb.attribute = v;
+  },
+});
+const sitClusterAlias = computed<string>({
+  get: () => draft.template?.serviceInternalTopology?.clusterBy?.alias ?? '',
+  set: (v) => {
+    const cb = ensureServiceInternalTopology().clusterBy;
+    if (cb) cb.alias = v;
+  },
+});
+const sitClusterPattern = clusterRuleField('pattern', 'nameRegex');
+const sitClusterFlags = clusterRuleField('flags', 'nameRegex');
+const sitClusterDisplayGroup = clusterRuleField('displayGroup', 'nameRegex');
+const sitClusterValueGroup = clusterRuleField('valueGroup', 'nameRegex');
+
 const epDepNodeMetrics = computed(() => activeScope.value === 'dependency' ? 
getMetricList('node') : []);
 const epDepLinkMetrics = computed(() => getMetricList('link'));
 const processEdgeClientMetrics = computed(() =>
@@ -1443,6 +1543,7 @@ const COMPONENT_TOGGLES: Array<{ key: ComponentKey; 
label: string; hint: string
   { key: 'endpoints', label: 'Endpoints', hint: 'Per-endpoint dashboard 
(dashboards.endpoint widget set).' },
   // Order mirrors the real sidebar: Topology sits before API dependency.
   { key: 'topology', label: 'Topology', hint: 'Service topology graph for this 
layer.' },
+  { key: 'serviceInternalTopology', label: 'Internal Topology', hint: 
'Instance-to-instance call graph within one service. Needs a 
serviceInternalTopology config block to appear.' },
   { key: 'endpointDependency', label: 'API dependency', hint: 
'Endpoint-to-endpoint dependency view.' },
   { key: 'traces', label: 'Traces', hint: 'Trace explorer scoped to this 
layer.' },
   { key: 'logs', label: 'Logs', hint: 'Log explorer scoped to this layer.' },
@@ -1473,6 +1574,7 @@ const COMPONENT_SCOPE: Record<ComponentKey, AdminScope> = 
{
   endpoints: 'endpoint',
   endpointDependency: 'dependency',
   topology: 'topology',
+  serviceInternalTopology: 'serviceInternalTopology',
   traces: 'trace',
   logs: 'logs',
   // Pod Logs has no editable widget grid — filler to satisfy the
@@ -1496,6 +1598,7 @@ const COMPONENT_SLOT: Partial<Record<ComponentKey, keyof 
NonNullable<AdminLayerT
   endpoints: 'endpoints',
   endpointDependency: 'endpointDependency',
   topology: 'topology',
+  serviceInternalTopology: 'serviceInternalTopology',
 };
 /** The layer's sidebar menu as the operator would see it — only the
  *  enabled components, in COMPONENT_TOGGLES order, labelled with the
@@ -2672,6 +2775,148 @@ const namingTest = computed<NamingTestResult>(() => {
           </div>
         </section>
 
+        <!-- Service Internal Topology config — instance node + per-side
+             edge metrics (ServiceInstance / ServiceInstanceRelation scope)
+             plus the optional node-clustering rule. Independent of the
+             service-map topology block. -->
+        <section
+          v-else-if="activeScope === 'serviceInternalTopology'"
+          class="sw-card editor-card topo-cfg-card"
+        >
+          <div class="card-head">
+            <h4>Service Internal Topology config</h4>
+            <span class="sub">instance-to-instance graph within one service. 
node = {{ instanceNoun }} · edges = intra-service instance relations.</span>
+          </div>
+          <div class="topo-cfg-body">
+            <!-- Node clustering: group instance nodes into boxes either by an
+                 instance attribute (node_role / node_type) or by a name regex
+                 run on the instance name. -->
+            <div class="topo-cfg-section">
+              <header class="topo-cfg-head">
+                <h5>Node clustering</h5>
+                <span class="sub">group {{ instanceNoun.toLowerCase() }} into 
boxes — off, by attribute, or by a name regex</span>
+              </header>
+              <div class="sit-cluster-cfg">
+                <label class="mf mf-narrow">
+                  <span>mode</span>
+                  <select v-model="sitClusterMode" class="mf-input">
+                    <option value="none">none</option>
+                    <option value="attribute">by attribute</option>
+                    <option value="nameRegex">by name regex</option>
+                  </select>
+                </label>
+                <template v-if="sitClusterMode === 'attribute'">
+                  <label class="mf"><span>attribute</span><input 
v-model="sitClusterAttribute" type="text" class="mf-input mono" 
placeholder="node_role" /></label>
+                  <label class="mf"><span>alias</span><input 
v-model="sitClusterAlias" type="text" class="mf-input" placeholder="role" 
/></label>
+                </template>
+                <template v-else-if="sitClusterMode === 'nameRegex'">
+                  <label class="mf mf-wide"><span>pattern</span><input 
v-model="sitClusterPattern" type="text" class="mf-input mono" 
placeholder="^(?<service>.+?)-(?<group>data|liaison)" /></label>
+                  <label class="mf mf-narrow"><span>flags</span><input 
v-model="sitClusterFlags" type="text" class="mf-input mono" placeholder="i" 
/></label>
+                  <label class="mf mf-narrow"><span>display grp</span><input 
v-model="sitClusterDisplayGroup" type="text" class="mf-input mono" 
placeholder="service" /></label>
+                  <label class="mf mf-narrow"><span>value grp</span><input 
v-model="sitClusterValueGroup" type="text" class="mf-input mono" 
placeholder="group" /></label>
+                  <label class="mf"><span>alias</span><input 
v-model="sitClusterAlias" type="text" class="mf-input" placeholder="group" 
/></label>
+                </template>
+              </div>
+            </div>
+
+            <div class="topo-cfg-section">
+              <header class="topo-cfg-head">
+                <h5>{{ instanceNoun }} node metrics</h5>
+                <span class="sub">per-instance — queried as 
<code>service_instance_*</code></span>
+                <button class="sw-btn add" type="button" 
@click="addMetric('sitNode')">+ Add</button>
+              </header>
+              <div v-if="serviceInternalNodeMetrics.length === 0" 
class="topo-cfg-empty">No node metrics. Click "+ Add" to start.</div>
+              <div v-else class="metric-list">
+                <article v-for="(m, i) in serviceInternalNodeMetrics" :key="i" 
class="metric-row">
+                  <div class="metric-row-head">
+                    <label class="mf"><span>id</span><input v-model="m.id" 
type="text" class="mf-input mono" /></label>
+                    <label class="mf"><span>label</span><input 
v-model="m.label" type="text" class="mf-input" /></label>
+                    <label class="mf mf-wide"><span>MQE</span><input 
v-model="m.mqe" type="text" class="mf-input mono" 
placeholder="service_instance_cpm" /></label>
+                    <label class="mf mf-narrow"><span>unit</span><input 
v-model="m.unit" type="text" class="mf-input" placeholder="rpm" /></label>
+                    <label class="mf"><span>role</span>
+                      <select v-model="m.role" class="mf-input">
+                        <option v-for="o in TOPOLOGY_ROLE_OPTIONS" 
:key="String(o.value)" :value="o.value || undefined">{{ o.label }}</option>
+                      </select>
+                    </label>
+                    <label class="mf mf-narrow"><span>agg</span>
+                      <select v-model="m.aggregation" class="mf-input"><option 
value="avg">avg</option><option value="sum">sum</option></select>
+                    </label>
+                    <div class="metric-row-actions">
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === 0" title="Move up" @click="moveMetric('sitNode', i, 
-1)">↑</button>
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === serviceInternalNodeMetrics.length - 1" title="Move down" 
@click="moveMetric('sitNode', i, 1)">↓</button>
+                      <button class="sw-btn small ghost danger" type="button" 
title="Remove" @click="removeMetric('sitNode', i)">×</button>
+                    </div>
+                  </div>
+                  <div class="metric-thresholds">
+                    <button class="sw-btn small ghost" type="button" 
@click="toggleThresholds(m)">{{ m.thresholds ? '− Thresholds' : '+ Thresholds' 
}}</button>
+                    <template v-if="m.thresholds">
+                      <label class="mf mf-narrow"><span>ok ≤</span><input 
v-model.number="m.thresholds.ok" type="number" step="0.1" class="mf-input" 
/></label>
+                      <label class="mf mf-narrow"><span>warn ≤</span><input 
v-model.number="m.thresholds.warn" type="number" step="0.1" class="mf-input" 
/></label>
+                      <label class="mf mf-narrow"><span>danger ≤</span><input 
v-model.number="m.thresholds.danger" type="number" step="0.1" class="mf-input" 
/></label>
+                      <label class="mf mf-checkbox"><input 
v-model="m.thresholds.invertHealth" type="checkbox" /><span>invert (higher = 
better)</span></label>
+                      <label v-if="m.thresholds.invertHealth" class="mf 
mf-narrow"><span>base</span><input v-model.number="m.thresholds.invertBase" 
type="number" step="1" class="mf-input" placeholder="100" /></label>
+                    </template>
+                  </div>
+                </article>
+              </div>
+            </div>
+
+            <div class="topo-cfg-section">
+              <header class="topo-cfg-head">
+                <h5>Link · server-side metrics</h5>
+                <span class="sub">edge metrics queried as 
<code>service_instance_relation_server_*</code></span>
+                <button class="sw-btn add" type="button" 
@click="addMetric('sitLinkServer')">+ Add</button>
+              </header>
+              <div v-if="serviceInternalServerMetrics.length === 0" 
class="topo-cfg-empty">No server-side metrics.</div>
+              <div v-else class="metric-list">
+                <article v-for="(m, i) in serviceInternalServerMetrics" 
:key="i" class="metric-row">
+                  <div class="metric-row-head">
+                    <label class="mf"><span>id</span><input v-model="m.id" 
type="text" class="mf-input mono" /></label>
+                    <label class="mf"><span>label</span><input 
v-model="m.label" type="text" class="mf-input" /></label>
+                    <label class="mf mf-wide"><span>MQE</span><input 
v-model="m.mqe" type="text" class="mf-input mono" /></label>
+                    <label class="mf mf-narrow"><span>unit</span><input 
v-model="m.unit" type="text" class="mf-input" /></label>
+                    <label class="mf mf-narrow"><span>agg</span>
+                      <select v-model="m.aggregation" class="mf-input"><option 
value="avg">avg</option><option value="sum">sum</option></select>
+                    </label>
+                    <div class="metric-row-actions">
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === 0" @click="moveMetric('sitLinkServer', i, -1)">↑</button>
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === serviceInternalServerMetrics.length - 1" 
@click="moveMetric('sitLinkServer', i, 1)">↓</button>
+                      <button class="sw-btn small ghost danger" type="button" 
@click="removeMetric('sitLinkServer', i)">×</button>
+                    </div>
+                  </div>
+                </article>
+              </div>
+            </div>
+
+            <div class="topo-cfg-section">
+              <header class="topo-cfg-head">
+                <h5>Link · client-side metrics</h5>
+                <span class="sub">edge metrics queried as 
<code>service_instance_relation_client_*</code></span>
+                <button class="sw-btn add" type="button" 
@click="addMetric('sitLinkClient')">+ Add</button>
+              </header>
+              <div v-if="serviceInternalClientMetrics.length === 0" 
class="topo-cfg-empty">No client-side metrics.</div>
+              <div v-else class="metric-list">
+                <article v-for="(m, i) in serviceInternalClientMetrics" 
:key="i" class="metric-row">
+                  <div class="metric-row-head">
+                    <label class="mf"><span>id</span><input v-model="m.id" 
type="text" class="mf-input mono" /></label>
+                    <label class="mf"><span>label</span><input 
v-model="m.label" type="text" class="mf-input" /></label>
+                    <label class="mf mf-wide"><span>MQE</span><input 
v-model="m.mqe" type="text" class="mf-input mono" /></label>
+                    <label class="mf mf-narrow"><span>unit</span><input 
v-model="m.unit" type="text" class="mf-input" /></label>
+                    <label class="mf mf-narrow"><span>agg</span>
+                      <select v-model="m.aggregation" class="mf-input"><option 
value="avg">avg</option><option value="sum">sum</option></select>
+                    </label>
+                    <div class="metric-row-actions">
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === 0" @click="moveMetric('sitLinkClient', i, -1)">↑</button>
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === serviceInternalClientMetrics.length - 1" 
@click="moveMetric('sitLinkClient', i, 1)">↓</button>
+                      <button class="sw-btn small ghost danger" type="button" 
@click="removeMetric('sitLinkClient', i)">×</button>
+                    </div>
+                  </div>
+                </article>
+              </div>
+            </div>
+          </div>
+        </section>
+
         <section
           v-else-if="activeScope === 'dependency'"
           class="sw-card editor-card topo-cfg-card"
@@ -4369,6 +4614,7 @@ const namingTest = computed<NamingTestResult>(() => {
   border-radius: 4px;
 }
 .metric-list { display: flex; flex-direction: column; gap: 8px; }
+.sit-cluster-cfg { display: flex; gap: 10px; flex-wrap: wrap; align-items: 
flex-end; }
 .metric-row {
   background: var(--sw-bg-1);
   border: 1px solid var(--sw-line);
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 1d07d7b..ad2188b 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1354,5 +1354,10 @@
   "Pick a client and server service to see their instance topology.": "Pick a 
client and server service to see their instance topology.",
   "higher = better": "higher = better",
   "lower = better": "lower = better",
-  "Node ring": "Node ring"
+  "Node ring": "Node ring",
+  "Internal Topology": "Internal Topology",
+  "clustered by": "clustered by",
+  "ungrouped": "ungrouped",
+  "Pick a service to see its internal instance topology.": "Pick a service to 
see its internal instance topology.",
+  "No internal instance topology in this window.": "No internal instance 
topology in this window."
 }
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 135cd6f..abb28e8 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -180,6 +180,7 @@ const SCOPE_CAP_PREDICATE: Record<string, (L: LayerDef) => 
boolean> = {
   instance: (L) => Boolean(L.slots?.instances),
   endpoint: (L) => Boolean(L.slots?.endpoints),
   topology: (L) => Boolean(L.caps?.serviceMap || L.caps?.instanceTopology || 
L.caps?.processTopology),
+  'internal-topology': (L) => Boolean(L.caps?.serviceInternalTopology),
   dependency: (L) => Boolean(L.caps?.endpointDependency),
   trace: (L) => Boolean(L.caps?.traces),
   logs: (L) => Boolean(L.caps?.logs),
diff --git a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue 
b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
new file mode 100644
index 0000000..511cc10
--- /dev/null
+++ b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
@@ -0,0 +1,752 @@
+<!--
+  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.
+-->
+<!--
+  Service Internal Topology — the per-layer "Internal Topology" tab.
+  Renders the instance-to-instance call graph WITHIN one service (OAP's
+  getServiceInstanceTopology(svc, svc)). The selected service comes from the
+  shell header picker (useSelectedService) — this view owns no service
+  picker, so the shell's Service header shows above it like any service-
+  scoped tab.
+
+  Nodes are the service's instances; edges are intra-service instance
+  relations. Nodes optionally CLUSTER into dashed boxes by the layer's
+  `serviceInternalTopology.clusterBy` rule — either a name regex on the
+  instance name (service-topology style) or an instance attribute value
+  (node_role / node_type). Pan/zoom, animated edge flow, node popover (with
+  "Open instance dashboard") and the client|server edge sidebar match the
+  service map's vocabulary.
+-->
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 
'vue';
+import * as d3 from 'd3';
+import { useI18n } from 'vue-i18n';
+import { useRoute, useRouter } from 'vue-router';
+import type {
+  ClusterByRule,
+  LayerDef,
+  ServiceInternalTopologyCall,
+  ServiceInternalTopologyNode,
+  TopologyMetricDef,
+} from '@/api/client';
+import { useServiceInternalTopology } from 
'@/layer/service-map/useServiceInternalTopology';
+import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayers } from '@/shell/useLayers';
+import { fmtMetric } from '@/utils/formatters';
+import { resolveServiceIdentity } from '@/utils/serviceName';
+import Sparkline from '@/components/charts/Sparkline.vue';
+
+const route = useRoute();
+const router = useRouter();
+const { t } = useI18n({ useScope: 'global' });
+
+const { layers } = useLayers();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const layer = computed<LayerDef | null>(
+  () => layers.value.find((l) => l.key.toUpperCase() === 
layerKey.value.toUpperCase()) ?? null,
+);
+const instanceWord = computed(() => layer.value?.slots?.instances ?? 
'Instances');
+const title = computed(() => layer.value?.slots?.serviceInternalTopology || 
t('Internal Topology'));
+const namingRule = computed(() => layer.value?.naming ?? null);
+function displayServiceName(name: string | null | undefined): string {
+  return resolveServiceIdentity(name, namingRule.value).display;
+}
+
+// ── Selected service comes from the shell header (useSelectedService) —
+// this view is service-scoped, so it does NOT own a service picker.
+const { selectedId } = useSelectedService();
+const enabled = computed(() => !!selectedId.value);
+const { data, nodes, calls, isFetching } = 
useServiceInternalTopology(layerKey, selectedId, enabled);
+const serviceName = computed(() => displayServiceName(data.value?.serviceName) 
|| '');
+
+const cfg = computed(
+  () => data.value?.config ?? { nodeMetrics: [] as TopologyMetricDef[] },
+);
+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'));
+
+function nodeVal(n: ServiceInternalTopologyNode, def: TopologyMetricDef | 
null): number | null {
+  return def ? (n.metrics?.[def.id] ?? null) : null;
+}
+function fmtVal(v: number | null, unit?: string): string {
+  if (v === null) return '—';
+  return unit ? `${fmtMetric(v)}${unit === '%' ? '' : ' '}${unit}` : 
fmtMetric(v);
+}
+function bandColor(value: number, th: 
NonNullable<TopologyMetricDef['thresholds']>): string {
+  const base = th.invertBase ?? 100;
+  const v = th.invertHealth ? Math.max(0, base - value) : value;
+  if (v > (th.danger ?? 5)) return 'var(--sw-err)';
+  if (v > (th.warn ?? 1)) return 'var(--sw-warn)';
+  if (v > (th.ok ?? 0.1)) return '#fbbf24';
+  return 'var(--sw-ok)';
+}
+function ringColor(n: ServiceInternalTopologyNode): string {
+  const def = ringDef.value;
+  if (!def) return 'var(--sw-line-2)';
+  const v = nodeVal(n, def);
+  if (v === null) return 'var(--sw-fg-3)';
+  if (def.thresholds) return bandColor(v, def.thresholds);
+  const healthHigh = /sla|success|apdex/i.test(def.id) || 
/sla|apdex|success/i.test(def.label);
+  const errPct = healthHigh ? Math.max(0, 100 - v) : v;
+  if (errPct > 5) return 'var(--sw-err)';
+  if (errPct > 1) return 'var(--sw-warn)';
+  if (errPct > 0.1) return '#fbbf24';
+  return 'var(--sw-ok)';
+}
+
+// ── Ring-colour legend (same break-point derivation as the instance map). */
+const ringScaleLabels = computed<string[]>(() => {
+  const def = ringDef.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);
+  const invert = th.invertHealth === undefined ? heuristicInvert : 
Boolean(th.invertHealth);
+  const base = th.invertBase ?? 100;
+  const ok = th.ok ?? 0.1;
+  const warn = th.warn ?? 1;
+  const danger = th.danger ?? 5;
+  const unit = def.unit ?? '';
+  const breaks = invert ? [base, base - ok, base - warn, base - danger] : [0, 
ok, warn, danger];
+  const fmt = (n: number): string => {
+    const s = Number.isInteger(n) ? n.toString() : 
n.toFixed(2).replace(/\.?0+$/, '');
+    return `${s}${unit}`;
+  };
+  const out = breaks.map(fmt);
+  if (out.length > 0) out[out.length - 1] = out[out.length - 1] + (invert ? 
'-' : '+');
+  return out;
+});
+const ringDirectionHint = computed<string>(() => {
+  const def = ringDef.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).
+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;
+  }
+  // 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;
+}
+
+interface ClusterBucket {
+  key: string | null;
+  label: string;
+  nodes: ServiceInternalTopologyNode[];
+}
+const clusters = computed<ClusterBucket[]>(() => {
+  const byKey = new Map<string, ClusterBucket>();
+  const UNGROUPED = '\u0000__ungrouped__';
+  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);
+    }
+    b.nodes.push(n);
+  }
+  // Named clusters first (alpha), the ungrouped bucket last.
+  return [...byKey.values()].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;
+const CLUSTER_GAP_Y = 56;
+const CLUSTER_PAD = 22;
+const HEAD_H = 30;
+const MAX_ROW_W = 1180;
+interface Pos { cx: number; cy: 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 headH = showBoxes ? HEAD_H : 0;
+    const boxH = innerH + CLUSTER_PAD * 2 + headH;
+    if (cursorX > 0 && cursorX + boxW > MAX_ROW_W) {
+      cursorX = 0;
+      cursorY += rowMaxH + CLUSTER_GAP_Y;
+      rowMaxH = 0;
+    }
+    const boxX = cursorX;
+    const boxY = cursorY;
+    cl.nodes.forEach((node, 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 });
+    });
+    rects.push({ key: cl.key, label: cl.label, x: boxX, y: boxY, w: boxW, h: 
boxH, boxed: showBoxes });
+    cursorX += boxW + CLUSTER_GAP_X;
+    rowMaxH = Math.max(rowMaxH, boxH);
+    maxW = Math.max(maxW, boxX + boxW);
+  }
+  return { pos, rects, w: Math.max(320, maxW), h: Math.max(240, cursorY + 
rowMaxH) };
+});
+const pos = computed(() => layout.value.pos);
+const W = computed(() => layout.value.w);
+const H = computed(() => layout.value.h);
+
+// ── 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.
+const callKeys = computed<Set<string>>(() => {
+  const s = new Set<string>();
+  for (const c of calls.value) s.add(`${c.source}|${c.target}`);
+  return s;
+});
+const visibleCalls = computed<ServiceInternalTopologyCall[]>(() =>
+  calls.value.filter((c) => pos.value.has(c.source) && 
pos.value.has(c.target)),
+);
+function edgePathD(c: ServiceInternalTopologyCall): string {
+  const a = pos.value.get(c.source);
+  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 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 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;
+  return `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`;
+}
+
+// ── Pan + zoom (same lifecycle as the instance map).
+const svgEl = ref<SVGSVGElement | null>(null);
+const zoomLayerEl = ref<SVGGElement | null>(null);
+const containerEl = ref<HTMLDivElement | null>(null);
+let zoomBehaviour: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null;
+const zoomT = ref<{ k: number; x: number; y: number }>({ k: 1, x: 0, y: 0 });
+function viewportSize(): { width: number; height: number } {
+  const el = containerEl.value;
+  if (!el) return { width: W.value, height: H.value };
+  const r = el.getBoundingClientRect();
+  return { width: r.width || W.value, height: r.height || H.value };
+}
+function fitToScreen(animate = true): void {
+  if (!svgEl.value || !zoomBehaviour) return;
+  const vp = viewportSize();
+  const pad = 28;
+  const fit = Math.min((vp.width - pad * 2) / W.value, (vp.height - pad * 2) / 
H.value);
+  const k = Math.max(0.2, Math.min(fit, 1));
+  const tx = (vp.width - W.value * k) / 2;
+  const ty = (vp.height - H.value * k) / 2;
+  const transform = d3.zoomIdentity.translate(tx, ty).scale(k);
+  const sel = d3.select(svgEl.value);
+  if (animate) sel.transition().duration(200).call(zoomBehaviour.transform, 
transform);
+  else sel.call(zoomBehaviour.transform, transform);
+}
+function zoomBy(factor: number): void {
+  if (!svgEl.value || !zoomBehaviour) return;
+  
d3.select(svgEl.value).transition().duration(150).call(zoomBehaviour.scaleBy, 
factor);
+}
+function installZoom(): void {
+  if (!svgEl.value || !zoomLayerEl.value) return;
+  const sel = d3.select(svgEl.value);
+  zoomBehaviour = d3
+    .zoom<SVGSVGElement, unknown>()
+    .scaleExtent([0.2, 5])
+    .filter((event) => {
+      if (event.type === 'mousedown' && (event as MouseEvent).button !== 0) 
return false;
+      const target = event.target as Element | null;
+      if (target?.closest?.('[data-node-id], [data-edge-id]')) return false;
+      return !(event as MouseEvent).button;
+    })
+    .on('zoom', (ev) => {
+      zoomT.value = { k: ev.transform.k, x: ev.transform.x, y: ev.transform.y 
};
+      d3.select(zoomLayerEl.value).attr('transform', ev.transform.toString());
+    });
+  sel.call(zoomBehaviour);
+  sel.on('dblclick.zoom', null);
+  sel.on('dblclick', () => fitToScreen(true));
+}
+function installZoomAndFit(): void {
+  if (!svgEl.value || !zoomLayerEl.value) return;
+  installZoom();
+  void nextTick(() => fitToScreen(false));
+}
+// The <svg> lives behind a v-else and unmounts whenever a new service's
+// data is in flight, then remounts when it lands — so re-bind zoom on every
+// (re)mount (a one-shot latch would leave pan/zoom dead after the first
+// service switch).
+watch(svgEl, (el) => { if (el && zoomLayerEl.value) installZoomAndFit(); }, { 
flush: 'post' });
+watch(
+  () => 
`${nodes.value.length}|${visibleCalls.value.length}|${clusters.value.length}`,
+  () => { if (svgEl.value && zoomBehaviour) void nextTick(() => 
fitToScreen(false)); },
+);
+
+// ── Selection (edge → sidebar, node → popover). Reset on service change.
+const selectedCallId = ref<string | null>(null);
+const popoverNodeId = ref<string | null>(null);
+function selectEdge(id: string): void {
+  popoverNodeId.value = null;
+  selectedCallId.value = selectedCallId.value === id ? null : id;
+}
+function selectNode(id: string): void {
+  selectedCallId.value = null;
+  popoverNodeId.value = popoverNodeId.value === id ? null : id;
+}
+watch(selectedId, () => { selectedCallId.value = null; popoverNodeId.value = 
null; });
+const selectedCall = computed<ServiceInternalTopologyCall | null>(
+  () => calls.value.find((c) => c.id === selectedCallId.value) ?? null,
+);
+const popoverNode = computed<ServiceInternalTopologyNode | null>(
+  () => nodes.value.find((n) => n.id === popoverNodeId.value) ?? null,
+);
+function instById(id: string): ServiceInternalTopologyNode | null {
+  return nodes.value.find((n) => n.id === id) ?? null;
+}
+const POP_W = 220;
+const popoverStyle = computed<Record<string, string>>(() => {
+  const n = popoverNode.value;
+  const p = n ? pos.value.get(n.id) : null;
+  const el = containerEl.value;
+  if (!p || !el) return { display: 'none' };
+  const z = zoomT.value;
+  const cw = el.clientWidth;
+  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 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));
+  const top = Math.max(72, Math.min(ny, ch - 72));
+  const style: Record<string, string> = {
+    left: `${left}px`,
+    top: `${top}px`,
+    width: `${POP_W}px`,
+    transform: 'translateY(-50%)',
+  };
+  return style;
+});
+function openInstanceDashboard(n: ServiceInternalTopologyNode): void {
+  const href = router.resolve({
+    path: `/layer/${layerKey.value}/instance`,
+    query: { service: n.serviceId, instance: n.name },
+  }).href;
+  window.open(href, '_blank', 'noopener');
+}
+
+// ── Edge detail rows (aligned client | server) — same as the instance map.
+interface EdgeRow { id: string; label: string; unit: string; serverDef: 
TopologyMetricDef | null; clientDef: TopologyMetricDef | null }
+const edgeRows = computed<EdgeRow[]>(() => {
+  const map = new Map<string, EdgeRow>();
+  for (const m of cfg.value.linkServerMetrics ?? []) {
+    const row = map.get(m.id) ?? { id: m.id, label: m.label, unit: m.unit ?? 
'', serverDef: null, clientDef: null };
+    row.serverDef = m;
+    map.set(m.id, row);
+  }
+  for (const m of cfg.value.linkClientMetrics ?? []) {
+    const row = map.get(m.id) ?? { id: m.id, label: m.label, unit: m.unit ?? 
'', serverDef: null, clientDef: null };
+    row.clientDef = m;
+    if (!row.label) row.label = m.label;
+    if (!row.unit) row.unit = m.unit ?? '';
+    map.set(m.id, row);
+  }
+  return [...map.values()];
+});
+function edgeVal(c: ServiceInternalTopologyCall, side: 'server' | 'client', 
def: TopologyMetricDef | null): number | null {
+  if (!def) return null;
+  const b = side === 'server' ? c.serverMetrics : c.clientMetrics;
+  return b?.[def.id] ?? null;
+}
+function edgeSeries(c: ServiceInternalTopologyCall, side: 'server' | 'client', 
def: TopologyMetricDef | null): Array<number | null> {
+  if (!def) return [];
+  const b = side === 'server' ? c.serverMetricSeries : c.clientMetricSeries;
+  return b?.[def.id] ?? [];
+}
+function seriesAt(arr: Array<number | null>, idx: number | null): number | 
null {
+  if (idx === null || idx < 0 || idx >= arr.length) return null;
+  return arr[idx];
+}
+type EdgeRowKind = 'both' | 'client-only' | 'server-only' | 'none';
+function edgeRowValues(c: ServiceInternalTopologyCall, row: EdgeRow): { kind: 
EdgeRowKind; clientV: number | null; serverV: number | null } {
+  const clientV = row.clientDef ? edgeVal(c, 'client', row.clientDef) : null;
+  const serverV = row.serverDef ? edgeVal(c, 'server', row.serverDef) : null;
+  if (clientV !== null && serverV !== null) return { kind: 'both', clientV, 
serverV };
+  if (clientV !== null) return { kind: 'client-only', clientV, serverV };
+  if (serverV !== null) return { kind: 'server-only', clientV, serverV };
+  return { kind: 'none', clientV, serverV };
+}
+const hoveredEdgeRowId = ref<string | null>(null);
+const hoveredEdgeBucket = ref<number | null>(null);
+function onEdgeBucketHover(rowId: string, bucket: number): void { 
hoveredEdgeRowId.value = rowId; hoveredEdgeBucket.value = bucket; }
+function onEdgeBucketLeave(): void { hoveredEdgeRowId.value = null; 
hoveredEdgeBucket.value = null; }
+function rowCrosshair(rowId: string): number | null { return 
hoveredEdgeRowId.value === rowId ? hoveredEdgeBucket.value : null; }
+
+const showPickPrompt = computed(() => !enabled.value);
+const showLoading = computed(() => enabled.value && isFetching.value && 
nodes.value.length === 0);
+const isEmpty = computed(() => enabled.value && !isFetching.value && 
nodes.value.length === 0);
+
+function onKeyDown(e: KeyboardEvent): void {
+  if (e.key !== 'Escape') return;
+  if (popoverNodeId.value) popoverNodeId.value = null;
+  else if (selectedCallId.value) selectedCallId.value = null;
+  else return;
+  e.preventDefault();
+}
+onMounted(() => window.addEventListener('keydown', onKeyDown, true));
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
+</script>
+
+<template>
+  <div class="sit">
+    <div class="sit-toolbar">
+      <span class="sit-title">{{ title }}</span>
+      <span v-if="serviceName" class="sit-divider" />
+      <span v-if="serviceName" class="sit-svc mono">{{ serviceName }}</span>
+      <span v-if="clusterAlias" class="sit-cluster-chip">{{ t('clustered by') 
}} · {{ clusterAlias }}</span>
+      <span v-if="isFetching" class="sit-hint">{{ t('Reading data…') }}</span>
+      <div class="sit-spacer" />
+      <div v-if="cfg.nodeMetrics.length > 0" class="sit-legend">
+        <span v-for="def in cfg.nodeMetrics" :key="def.id" class="lg-item">
+          <span class="lg-dot" :class="def.role || 'plain'" />{{ def.label 
}}<span v-if="def.unit" class="lg-unit"> ({{ def.unit }})</span>
+        </span>
+      </div>
+    </div>
+
+    <div class="sit-body" :class="{ 'no-selection': !selectedCall }">
+      <div ref="containerEl" class="sit-canvas">
+        <div v-if="showPickPrompt" class="sit-state">{{ t('Pick a service to 
see its internal instance topology.') }}</div>
+        <div v-else-if="showLoading" class="sit-state">{{ t('Reading data…') 
}}</div>
+        <div v-else-if="isEmpty" class="sit-state">{{ t('No internal instance 
topology in this window.') }}</div>
+        <template v-else>
+          <svg ref="svgEl" class="sit-svg" width="100%" height="100%">
+            <g ref="zoomLayerEl" :class="{ 'has-pop': !!popoverNodeId }">
+              <g class="sit-groups">
+                <g v-for="(g, gi) in layout.rects" :key="g.key ?? `__${gi}`" 
:transform="`translate(${g.x}, ${g.y})`">
+                  <template v-if="g.boxed">
+                    <rect :width="g.w" :height="g.h" rx="14" ry="14" 
class="sit-grp-rect" />
+                    <text x="16" y="20" class="sit-grp-head">
+                      <tspan class="sit-grp-name mono">{{ g.label }}</tspan>
+                      <tspan class="sit-grp-alias" dx="8">{{ clusterAlias }} · 
{{ instanceWord }}</tspan>
+                    </text>
+                  </template>
+                </g>
+              </g>
+              <g v-for="c in visibleCalls" :key="c.id" class="sit-edge" 
:data-edge-id="c.id" @click.stop="selectEdge(c.id)">
+                <path :d="edgePathD(c)" fill="none" stroke="transparent" 
stroke-width="14" style="cursor: pointer" />
+                <path
+                  :d="edgePathD(c)" fill="none"
+                  :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
'var(--sw-accent)'"
+                  :stroke-width="selectedCallId === c.id ? 3 : 1.6"
+                  :opacity="selectedCallId === c.id ? 1 : 0.6"
+                  stroke-linecap="round" style="pointer-events: none"
+                />
+                <path
+                  :d="edgePathD(c)" fill="none"
+                  :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
'var(--sw-accent)'"
+                  :stroke-width="selectedCallId === c.id ? 4 : 3"
+                  stroke-linecap="round" stroke-dasharray="4 28" 
opacity="0.95" style="pointer-events: none"
+                >
+                  <animate attributeName="stroke-dashoffset" from="32" to="0" 
dur="3s" repeatCount="indefinite" />
+                </path>
+              </g>
+              <g
+                v-for="n in nodes" :key="n.id" class="sit-node"
+                :class="{ sel: popoverNodeId === n.id }" :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>
+              </g>
+            </g>
+          </svg>
+
+          <div v-if="popoverNode" class="sit-node-pop sw-card" 
:style="popoverStyle">
+            <header class="np-head">
+              <span class="np-name mono">{{ popoverNode.name }}</span>
+              <button class="sw-btn small ghost" type="button" 
@click="popoverNodeId = null">×</button>
+            </header>
+            <dl v-if="popoverNode.attributes.length > 0" class="np-attrs">
+              <template v-for="a in popoverNode.attributes" :key="a.name">
+                <dt>{{ a.name }}</dt>
+                <dd class="mono">{{ a.value }}</dd>
+              </template>
+            </dl>
+            <dl class="np-kv">
+              <template v-for="def in cfg.nodeMetrics" :key="def.id">
+                <dt>{{ def.label }}</dt>
+                <dd class="mono">{{ fmtVal(nodeVal(popoverNode, def), 
def.unit) }}</dd>
+              </template>
+            </dl>
+            <button class="sw-btn small primary np-open" type="button" 
@click="openInstanceDashboard(popoverNode)">
+              {{ t('Open instance dashboard') }} ↗
+            </button>
+          </div>
+
+          <div class="sit-zoom">
+            <button class="sw-btn small" type="button" :title="t('Zoom in')" 
@click="zoomBy(1.25)">+</button>
+            <button class="sw-btn small" type="button" :title="t('Zoom out')" 
@click="zoomBy(1 / 1.25)">−</button>
+            <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 class="lg-label">
+              {{ t('Node ring') }} · {{ ringDef.label }}
+              <span class="lg-direction">{{ ringDirectionHint }}</span>
+            </div>
+            <div class="lg-ramp">
+              <span style="background: var(--sw-ok)" />
+              <span style="background: #fbbf24" />
+              <span style="background: var(--sw-warn)" />
+              <span style="background: var(--sw-err)" />
+            </div>
+            <div class="lg-scale">
+              <span v-for="(lbl, i) in ringScaleLabels" :key="i">{{ lbl 
}}</span>
+            </div>
+          </div>
+        </template>
+      </div>
+
+      <aside v-if="selectedCall" class="sit-panel">
+        <header class="sit-panel-head">
+          <div class="ip-edge mono">
+            <span>{{ instById(selectedCall.source)?.name }}</span>
+            <span class="sit-arrow">→</span>
+            <span>{{ instById(selectedCall.target)?.name }}</span>
+          </div>
+          <button class="sw-btn small ghost" type="button" 
@click="selectedCallId = null">×</button>
+        </header>
+        <div class="ip-tags">
+          <span class="sw-tag">{{ selectedCall.detectPoints.join(' · ') || 
t('relation') }}</span>
+        </div>
+        <div class="sit-panel-body">
+          <div v-if="edgeRows.length > 0" class="ip-edge-rows">
+            <div v-for="row in edgeRows" :key="row.id" class="ip-edge-row">
+              <div class="ip-edge-row-head">
+                <span class="ip-edge-row-label">{{ row.label }}<span 
v-if="row.unit" class="ru"> ({{ row.unit }})</span></span>
+                <span v-if="hoveredEdgeRowId === row.id && hoveredEdgeBucket 
!== null" class="ip-edge-tip">
+                  <template v-if="row.clientDef"><span class="tip-tag" 
style="color: var(--sw-info)">C</span><span class="tip-val">{{ 
fmtMetric(seriesAt(edgeSeries(selectedCall, 'client', row.clientDef), 
hoveredEdgeBucket)) }}</span></template>
+                  <template v-if="row.serverDef"><span 
class="tip-sep">·</span><span class="tip-tag" style="color: 
var(--sw-accent)">S</span><span class="tip-val">{{ 
fmtMetric(seriesAt(edgeSeries(selectedCall, 'server', row.serverDef), 
hoveredEdgeBucket)) }}</span></template>
+                </span>
+              </div>
+              <template v-if="edgeRowValues(selectedCall, row).kind === 
'both'">
+                <div class="ip-edge-pair">
+                  <div class="ip-edge-cell">
+                    <div class="ip-edge-cell-head"><span class="tag c">{{ 
t('Client') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, 
row).clientV) }}</span></div>
+                    <Sparkline :values="edgeSeries(selectedCall, 'client', 
row.clientDef)" color="var(--sw-info)" :height="36" :stroke="1.4" fluid 
:crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => 
onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                  </div>
+                  <div class="ip-edge-cell">
+                    <div class="ip-edge-cell-head"><span class="tag s">{{ 
t('Server') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, 
row).serverV) }}</span></div>
+                    <Sparkline :values="edgeSeries(selectedCall, 'server', 
row.serverDef)" color="var(--sw-accent)" :height="36" :stroke="1.4" fluid 
:crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => 
onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                  </div>
+                </div>
+              </template>
+              <template v-else-if="edgeRowValues(selectedCall, row).kind === 
'client-only'">
+                <div class="ip-edge-cell">
+                  <div class="ip-edge-cell-head"><span class="tag c">{{ 
t('Client') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, 
row).clientV) }}</span></div>
+                  <Sparkline :values="edgeSeries(selectedCall, 'client', 
row.clientDef)" color="var(--sw-info)" :height="36" :stroke="1.4" fluid 
:crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => 
onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                </div>
+              </template>
+              <template v-else-if="edgeRowValues(selectedCall, row).kind === 
'server-only'">
+                <div class="ip-edge-cell">
+                  <div class="ip-edge-cell-head"><span class="tag s">{{ 
t('Server') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, 
row).serverV) }}</span></div>
+                  <Sparkline :values="edgeSeries(selectedCall, 'server', 
row.serverDef)" color="var(--sw-accent)" :height="36" :stroke="1.4" fluid 
:crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => 
onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                </div>
+              </template>
+              <template v-else>
+                <div class="ip-edge-none">{{ t('no value') }}</div>
+              </template>
+            </div>
+          </div>
+          <div v-else class="ip-empty">{{ t('no line metrics configured') 
}}</div>
+        </div>
+      </aside>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.sit { display: flex; flex-direction: column; gap: 10px; height: 100%; 
min-height: 0; }
+.sit-toolbar {
+  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+  padding: 8px 10px; border: 1px solid var(--sw-line); border-radius: 6px; 
background: var(--sw-bg-1);
+}
+.sit-title { font-size: 12px; font-weight: 700; color: var(--sw-fg-0); }
+.sit-divider { width: 1px; height: 18px; background: var(--sw-line-2); margin: 
0 2px; }
+.sit-svc { font-size: 12px; color: var(--sw-fg-1); }
+.sit-cluster-chip {
+  font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.05em; color: 
var(--sw-fg-2);
+  padding: 2px 7px; border-radius: 4px; border: 1px solid var(--sw-line-2); 
background: var(--sw-bg-2);
+}
+.sit-arrow { color: var(--sw-accent); font-weight: 700; }
+.sit-hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.sit-spacer { flex: 1; }
+.sit-legend { display: flex; gap: 14px; align-items: center; }
+.lg-item { display: inline-flex; align-items: center; gap: 5px; font-size: 
10px; color: var(--sw-fg-2); }
+.lg-dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; }
+.lg-dot.center { background: var(--sw-bg-3); border: 1.5px solid 
var(--sw-fg-1); }
+.lg-dot.ring { background: transparent; border: 2px solid var(--sw-fg-2); }
+.lg-dot.secondary { width: 8px; height: 8px; background: var(--sw-fg-3); }
+.lg-dot.lineServer, .lg-dot.lineClient, .lg-dot.plain { width: 8px; height: 
8px; background: var(--sw-fg-3); border-radius: 2px; }
+.lg-unit { color: var(--sw-fg-3); }
+
+.sit-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 
380px; gap: 10px; }
+.sit-body.no-selection { grid-template-columns: 1fr; }
+.sit-canvas {
+  position: relative; overflow: hidden; min-width: 0; min-height: 440px;
+  border: 1px solid var(--sw-line); border-radius: 6px;
+  background: radial-gradient(circle at center, var(--sw-bg-1) 0%, 
var(--sw-bg-0) 100%);
+}
+.sit-state { position: absolute; inset: 0; display: flex; align-items: center; 
justify-content: center; color: var(--sw-fg-3); font-size: 12px; text-align: 
center; padding: 24px; }
+.sit-grp-rect { fill: var(--sw-bg-1); fill-opacity: 0.35; stroke: 
var(--sw-line-2); stroke-width: 1; stroke-dasharray: 4 5; }
+.sit-grp-name { fill: var(--sw-fg-1); font-size: 12px; font-weight: 700; }
+.sit-grp-alias { fill: var(--sw-fg-3); font-size: 9px; text-transform: 
uppercase; letter-spacing: 0.06em; }
+.sit-svg { width: 100%; height: 100%; display: block; cursor: grab; }
+.sit-svg:active { cursor: grabbing; }
+.sit-node { cursor: pointer; }
+.node-bg { fill: var(--sw-bg-2); transition: stroke-width 0.1s ease; }
+.sit-node:hover .node-bg { fill: var(--sw-bg-3); }
+.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; }
+.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; }
+
+.sit-node-pop { position: absolute; z-index: 5; padding: 8px 10px 10px; 
box-shadow: 0 6px 22px rgba(0,0,0,0.4); }
+.np-head { display: flex; align-items: center; gap: 6px; }
+.np-name { font-size: 11.5px; color: var(--sw-fg-0); flex: 1; word-break: 
break-all; }
+.np-attrs { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; 
margin: 8px 0 0; font-size: 10.5px; }
+.np-attrs dt { color: var(--sw-fg-3); }
+.np-attrs dd { margin: 0; color: var(--sw-fg-1); text-align: right; 
word-break: break-all; }
+.np-kv { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; 
margin: 8px 0; font-size: 11px; }
+.np-kv dt { color: var(--sw-fg-3); }
+.np-kv dd { margin: 0; color: var(--sw-fg-1); text-align: right; }
+.np-open { width: 100%; justify-content: center; }
+
+.sit-zoom { position: absolute; right: 12px; bottom: 12px; display: flex; 
flex-direction: column; gap: 4px; z-index: 3; }
+.sit-zoom .sw-btn.small { width: 28px; height: 26px; padding: 0; 
justify-content: center; }
+
+.sit-ring-legend {
+  position: absolute; left: 12px; bottom: 12px; z-index: 3;
+  padding: 8px 10px; min-width: 170px; font-size: 10.5px;
+  background: rgba(15, 19, 26, 0.92); backdrop-filter: blur(8px);
+  border: 1px solid var(--sw-line); border-radius: 6px;
+}
+.sit-ring-legend .lg-label {
+  font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em;
+  color: var(--sw-fg-3); margin-bottom: 4px; display: flex; align-items: 
baseline; gap: 6px;
+}
+.sit-ring-legend .lg-direction { font-size: 9px; letter-spacing: 0.04em; 
text-transform: none; color: var(--sw-fg-3); font-style: italic; opacity: 0.85; 
}
+.sit-ring-legend .lg-ramp { display: grid; grid-template-columns: repeat(4, 
1fr); gap: 2px; margin-bottom: 3px; }
+.sit-ring-legend .lg-ramp span { height: 8px; border-radius: 2px; display: 
block; }
+.sit-ring-legend .lg-scale { display: grid; grid-template-columns: repeat(4, 
1fr); color: var(--sw-fg-3); font-size: 9px; }
+.sit-ring-legend .lg-scale span { text-align: left; }
+
+.sit-panel { border: 1px solid var(--sw-line); border-radius: 6px; background: 
var(--sw-bg-1); display: flex; flex-direction: column; min-width: 0; overflow: 
hidden; }
+.sit-panel-head { display: flex; align-items: flex-start; gap: 8px; padding: 
8px 12px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto; }
+.ip-edge { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; 
flex: 1; font-size: 11px; color: var(--sw-fg-0); word-break: break-all; }
+.ip-tags { padding: 6px 12px 0; }
+.sit-panel-body { flex: 1; overflow-y: auto; padding: 10px 12px 16px; }
+.ip-edge-rows { display: flex; flex-direction: column; gap: 10px; }
+.ip-edge-row { border: 1px solid var(--sw-line); border-radius: 4px; padding: 
6px 8px; background: var(--sw-bg-0); }
+.ip-edge-row-head { display: flex; align-items: baseline; justify-content: 
space-between; gap: 6px; margin-bottom: 4px; }
+.ip-edge-row-label { font-size: 10.5px; color: var(--sw-fg-2); }
+.ip-edge-row-label .ru { color: var(--sw-fg-3); }
+.ip-edge-tip { display: inline-flex; align-items: baseline; gap: 4px; 
font-size: 10px; font-family: var(--sw-mono); }
+.ip-edge-tip .tip-tag { font-weight: 700; }
+.ip-edge-tip .tip-val { color: var(--sw-fg-1); }
+.ip-edge-tip .tip-sep { color: var(--sw-fg-3); }
+.ip-edge-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.ip-edge-cell { min-width: 0; }
+.ip-edge-cell-head { display: flex; align-items: baseline; gap: 6px; 
margin-bottom: 2px; }
+.ip-edge-cell-head .tag { font-size: 8.5px; text-transform: uppercase; 
letter-spacing: 0.05em; font-weight: 700; }
+.ip-edge-cell-head .tag.c { color: var(--sw-info); }
+.ip-edge-cell-head .tag.s { color: var(--sw-accent); }
+.ip-edge-cell-head .num { font-family: var(--sw-mono); font-size: 11px; color: 
var(--sw-fg-0); }
+.ip-edge-none, .ip-empty { color: var(--sw-fg-3); font-size: 11px; padding: 
6px 0; }
+.mono { font-family: var(--sw-mono); }
+.sw-btn.small { height: 24px; padding: 0 10px; font-size: 11px; }
+.sw-btn.ghost { background: transparent; border: 1px solid var(--sw-line-2); 
color: var(--sw-fg-2); cursor: pointer; }
+</style>
diff --git a/apps/ui/src/layer/service-map/useServiceInternalTopology.ts 
b/apps/ui/src/layer/service-map/useServiceInternalTopology.ts
new file mode 100644
index 0000000..7e30c0a
--- /dev/null
+++ b/apps/ui/src/layer/service-map/useServiceInternalTopology.ts
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+
+/**
+ * vue-query wrapper around `GET /api/layer/:key/internal-topology`.
+ *
+ * Drives the Service Internal Topology tab — the instance-to-instance call
+ * graph within ONE service. Gated by `enabled` (a service is picked and the
+ * view is active) so it only fires while the operator is looking at it.
+ * Same topbar-picker queryKey + auto-refresh wiring as the service map.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { useAutoRefreshSubscribe } from 
'../../controls/useAutoRefreshSubscribe';
+import { useTimeRangeStore } from '../../controls/timeRange';
+import { usePreviewLayerBlock } from '@/controls/previewConfig';
+import { bffClient } from '@/api/client';
+
+export function useServiceInternalTopology(
+  layerKey: Ref<string>,
+  serviceId: Ref<string | null>,
+  enabled: Ref<boolean>,
+) {
+  const timeRange = useTimeRangeStore();
+  // Preview-only: the draft top-level `serviceInternalTopology` block, so
+  // the tab previews the operator's unpublished config.
+  const previewCfg = usePreviewLayerBlock(layerKey, 'serviceInternalTopology');
+  const rangeKey = computed(() => ({
+    step: timeRange.step,
+    startMs: timeRange.range.startMs,
+    endMs: timeRange.range.endMs,
+  }));
+  const isEnabled = computed(
+    () => enabled.value && layerKey.value.length > 0 && !!serviceId.value,
+  );
+  const q = useQuery({
+    queryKey: ['layer-internal-topology', layerKey, serviceId, rangeKey, 
previewCfg],
+    queryFn: () =>
+      bffClient.layer.serviceInternalTopology(
+        layerKey.value,
+        serviceId.value as string,
+        rangeKey.value,
+        previewCfg.value,
+      ),
+    enabled: isEnabled,
+    staleTime: 30_000,
+  });
+  // Only ride the global ticker while the view is active — a forced refetch
+  // on a closed/disabled query would fetch needlessly.
+  useAutoRefreshSubscribe(() => {
+    if (isEnabled.value) void q.refetch();
+  });
+
+  return {
+    data: computed(() => q.data.value ?? null),
+    nodes: computed(() => q.data.value?.nodes ?? []),
+    calls: computed(() => q.data.value?.calls ?? []),
+    isLoading: q.isLoading,
+    isFetching: q.isFetching,
+    error: q.error,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 6b359ee..1d56f42 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -539,6 +539,14 @@ watch(
                 >
                   <Icon name="topo" /><span>{{ L.slots.topology ?? 'Topology' 
}}</span>
                 </RouterLink>
+                <RouterLink
+                  v-if="L.caps.serviceInternalTopology"
+                  :to="`/layer/${L.key}/internal-topology`"
+                  class="sw-nav-item"
+                  :class="{ 'is-active': 
isActive(`/layer/${L.key}/internal-topology`) }"
+                >
+                  <Icon name="topo" /><span>{{ L.slots.serviceInternalTopology 
?? 'Internal Topology' }}</span>
+                </RouterLink>
                 <RouterLink
                   v-if="L.caps.endpointDependency"
                   :to="`/layer/${L.key}/dependency`"
@@ -686,6 +694,14 @@ watch(
           >
             <Icon name="topo" /><span>{{ E.layer.slots.topology ?? 'Topology' 
}}</span>
           </RouterLink>
+          <RouterLink
+            v-if="E.layer.caps.serviceInternalTopology"
+            :to="`/layer/${E.layer.key}/internal-topology`"
+            class="sw-nav-item"
+            :class="{ 'is-active': 
isActive(`/layer/${E.layer.key}/internal-topology`) }"
+          >
+            <Icon name="topo" /><span>{{ E.layer.slots.serviceInternalTopology 
?? 'Internal Topology' }}</span>
+          </RouterLink>
           <RouterLink
             v-if="E.layer.caps.endpointDependency"
             :to="`/layer/${E.layer.key}/dependency`"
diff --git a/apps/ui/src/shell/router/index.ts 
b/apps/ui/src/shell/router/index.ts
index 2689908..c99adea 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -55,6 +55,11 @@ function layerRoute(): RouteRecordRaw {
         meta: { ownsServiceSelector: true },
       },
       { path: 'dependency', component: () => 
import('@/layer/endpoint-dependency/LayerEndpointDependencyView.vue') },
+      // Service Internal Topology — instance-to-instance graph within one
+      // service. Service-scoped, so it deliberately does NOT set
+      // `ownsServiceSelector`: the shell's Service header picker stays
+      // visible and the view reads `useSelectedService`.
+      { path: 'internal-topology', component: () => 
import('@/layer/service-map/LayerServiceInternalTopologyView.vue') },
       // `LayerTracesEntry` is a runtime dispatcher: it inspects the
       // layer template's `traces.source` and renders either the native
       // trace view or the Zipkin one. Mesh / k8s layers land on Zipkin.
diff --git a/apps/ui/src/shell/useLayers.ts b/apps/ui/src/shell/useLayers.ts
index 080d3f7..6b1f327 100644
--- a/apps/ui/src/shell/useLayers.ts
+++ b/apps/ui/src/shell/useLayers.ts
@@ -152,6 +152,7 @@ export function firstLayerTab(L: LayerDef | undefined): 
string {
   if (L.caps?.instances ?? Boolean(L.slots?.instances)) return 'instance';
   if (L.caps?.endpoints ?? Boolean(L.slots?.endpoints)) return 'endpoint';
   if (L.caps?.serviceMap || L.caps?.instanceTopology || 
L.caps?.processTopology) return 'topology';
+  if (L.caps?.serviceInternalTopology) return 'internal-topology';
   if (L.caps?.endpointDependency) return 'dependency';
   if (L.caps?.traces) return 'trace';
   if (L.caps?.logs) return 'logs';
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 8225052..80cfa0b 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -66,6 +66,11 @@ export type {
   InstanceTopologyNode,
   InstanceTopologyCall,
   InstanceTopologyResponse,
+  ClusterByRule,
+  ServiceInternalTopologyConfig,
+  ServiceInternalTopologyNode,
+  ServiceInternalTopologyCall,
+  ServiceInternalTopologyResponse,
   EndpointDependencyNode,
   EndpointDependencyCall,
   EndpointDependencyResponse,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index d2f7f7c..59b4cfa 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -40,6 +40,9 @@ export interface LayerSlots {
   topology?: string;
   /** Label for the instance-topology sub-tab. Defaults to "Instance map". */
   instanceTopology?: string;
+  /** Label for the service-internal-topology tab. Defaults to
+   *  "Internal Topology". */
+  serviceInternalTopology?: string;
 }
 
 export interface LayerCaps {
@@ -48,6 +51,10 @@ export interface LayerCaps {
   instances?: boolean;
   endpoints?: boolean;
   instanceTopology?: boolean;
+  /** Per-layer "Service Internal Topology" tab — instance-to-instance
+   *  call graph within one service. Opt-in; gated by the presence of the
+   *  layer template's `serviceInternalTopology` config block. */
+  serviceInternalTopology?: boolean;
   processTopology?: boolean;
   dashboards?: boolean;
   traces?: boolean;
diff --git a/packages/api-client/src/topology.ts 
b/packages/api-client/src/topology.ts
index 4cbdcbf..f9e7fb9 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -128,6 +128,115 @@ export interface InstanceTopologyConfig {
   linkClientMetrics?: TopologyMetricDef[];
 }
 
+/**
+ * How the Service-Internal-Topology view groups instance nodes into
+ * clusters (the dashed bounding boxes). Two mutually-exclusive modes:
+ *
+ *   - `nameRegex`  — parse the INSTANCE name with a named-capture regex,
+ *     exactly the {@link ServiceNamingRule} shape (so the same resolver
+ *     applies). The `valueGroup` capture becomes the cluster key. Use for
+ *     fleets whose grouping dimension is encoded in the pod name
+ *     (`banyandb-data-hot-0` → `data`).
+ *   - `attribute`  — group by an instance ATTRIBUTE value (the
+ *     `attributes [{name,value}]` bag carried on each instance, e.g.
+ *     `node_role`, `node_type`). Lookup is case-insensitive on the
+ *     attribute name. This mode is unique to instance topology — service
+ *     topology has no per-node attributes.
+ *
+ * Absent ⇒ no clustering (all nodes in one ungrouped pane).
+ */
+export type ClusterByRule =
+  | {
+      kind: 'nameRegex';
+      /** JS regex source, run against the instance name. */
+      pattern: string;
+      /** Flags for `new RegExp(pattern, flags)`. Default `''`. */
+      flags?: string;
+      /** Named-capture group for the display label. Defaults `'service'`. */
+      displayGroup?: string;
+      /** Named-capture group for the cluster value. Defaults `'group'`. */
+      valueGroup?: string;
+      /** Human label for the dimension (chip + box title). */
+      alias: string;
+    }
+  | {
+      kind: 'attribute';
+      /** Instance-attribute name to group by (e.g. `node_role`). Matched
+       *  case-insensitively against the instance's `attributes` bag. */
+      attribute: string;
+      /** Human label for the dimension. Defaults to `attribute`. */
+      alias?: string;
+    };
+
+/**
+ * Operator-editable Service-Internal-Topology config. Lives in the layer
+ * JSON's own top-level `serviceInternalTopology` block (independent of the
+ * service-map `topology` block). Drives the per-layer "Service Internal
+ * Topology" tab: the instance-to-instance call graph WITHIN one selected
+ * service, queried via OAP's `getServiceInstanceTopology(svc, svc)`.
+ *
+ * Same node + per-side edge metric shape as {@link InstanceTopologyConfig}
+ * (node MQE under `{ scope: ServiceInstance }`, edge MQE under
+ * ServiceInstanceRelation server / client families), plus the optional
+ * {@link ClusterByRule} for grouping nodes.
+ */
+export interface ServiceInternalTopologyConfig {
+  /** Per-instance MQE under `{ scope: ServiceInstance }`. */
+  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. */
+  clusterBy?: ClusterByRule;
+}
+
+/** One instance node in the service-internal topology. Same shape as
+ *  {@link InstanceTopologyNode} but carries the instance's `attributes`
+ *  bag (so the view can cluster by attribute). All nodes share one
+ *  `serviceId` — the selected service. */
+export interface ServiceInternalTopologyNode {
+  id: string;
+  /** Instance name (e.g. `banyandb-data-hot-0`). */
+  name: string;
+  serviceId: string;
+  serviceName: string;
+  isReal: boolean;
+  /** Keyed by `ServiceInternalTopologyConfig.nodeMetrics[].id`. */
+  metrics: Record<string, number | null>;
+  /** Instance attributes (`node_role`, `node_type`, …) from
+   *  `listInstances`. Empty when OAP exposes none. */
+  attributes: Array<{ name: string; value: string }>;
+}
+
+/** One instance-to-instance call within the selected service. Same
+ *  per-side metric shape as {@link InstanceTopologyCall}. `source ===
+ *  target` is possible (a node that calls itself). */
+export interface ServiceInternalTopologyCall {
+  id: string;
+  source: string;
+  target: string;
+  detectPoints: string[];
+  serverMetrics: Record<string, number | null>;
+  clientMetrics: Record<string, number | null>;
+  serverMetricSeries: Record<string, Array<number | null> | null>;
+  clientMetricSeries: Record<string, Array<number | null> | null>;
+}
+
+/** Response of `GET /api/layer/:key/internal-topology?service=<id>`. The
+ *  graph is the instance topology WITHIN one service. */
+export interface ServiceInternalTopologyResponse {
+  layer: string;
+  serviceId: string;
+  serviceName: string | null;
+  generatedAt: number;
+  config: ServiceInternalTopologyConfig;
+  nodes: ServiceInternalTopologyNode[];
+  calls: ServiceInternalTopologyCall[];
+  reachable: boolean;
+  error?: string;
+}
+
 /** Operator-editable process-topology (network-profiling) dashboard
  *  config. Lives in the layer JSON's `processTopology` block. Drives the
  *  network-profiling page's edge detail panel: clicking a process→process

Reply via email to