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 3fb979b0a7c90cd9ba1f241c181fbdcc030d580b
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 9 20:55:29 2026 +0800

    refactor(layer): nodeMetrics optional / roles-first for Service Internal 
Topology
    
    Clarifies the metric model:
    - main is chosen by roles[].main (not siblingBy/roleBy); siblingBy only
      bundles a pod, roleBy only classifies. Documented on the types.
    - top-level nodeMetrics is now OPTIONAL — it's the no-role/fallback set;
      with roles covering every container it can be omitted. general.json's
      serviceInternalTopology is now roles-only (dropped the redundant
      nodeMetrics).
    - BFF route is role-aware on the real path too: each node's role (roleBy)
      picks its role.nodeMetrics, else the nodeMetrics fallback; node.role is
      set on the response. Preview parser accepts roles-only / mock-only blocks
      and bounds roles[].nodeMetrics MQE.
    - UI legend + ring-legend baseline derive from the union of top-level +
      role metric defs.
    
    Validated: roles-only config (no nodeMetrics) parses + serves the mock
    (13 nodes/9 calls). type-check + lint + 80 BFF tests green.
---
 apps/bff/src/bundled_templates/layers/general.json |  3 --
 apps/bff/src/http/query/internal-topology.ts       | 45 ++++++++++++++++++++--
 apps/bff/src/logic/layers/preview.ts               | 22 ++++++++++-
 .../admin/layer-templates/LayerDashboardsAdmin.vue |  3 +-
 .../LayerServiceInternalTopologyView.vue           | 16 ++++++--
 packages/api-client/src/topology.ts                |  8 ++--
 6 files changed, 81 insertions(+), 16 deletions(-)

diff --git a/apps/bff/src/bundled_templates/layers/general.json 
b/apps/bff/src/bundled_templates/layers/general.json
index f87f272..23f5487 100644
--- a/apps/bff/src/bundled_templates/layers/general.json
+++ b/apps/bff/src/bundled_templates/layers/general.json
@@ -960,9 +960,6 @@
   },
   "serviceInternalTopology": {
     "mock": "banyandb-cluster",
-    "nodeMetrics": [
-      { "id": "cpm", "label": "Load", "mqe": "service_instance_cpm", "unit": 
"cpm", "role": "center", "aggregation": "avg" }
-    ],
     "clusterBy": { "kind": "attribute", "attribute": "node_role", "alias": 
"role" },
     "siblingBy": { "kind": "attribute", "attribute": "pod", "alias": "pod" },
     "roleBy": { "kind": "attribute", "attribute": "container", "alias": 
"container" },
diff --git a/apps/bff/src/http/query/internal-topology.ts 
b/apps/bff/src/http/query/internal-topology.ts
index ad8c443..1af2a03 100644
--- a/apps/bff/src/http/query/internal-topology.ts
+++ b/apps/bff/src/http/query/internal-topology.ts
@@ -42,6 +42,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } 
from 'fastify';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import type {
+  ClusterByRule,
   FetchLike,
   ServiceInternalTopologyCall,
   ServiceInternalTopologyConfig,
@@ -188,6 +189,27 @@ function relationFragment(
   );
 }
 
+/** Resolve a rule's key for an instance — attribute value (case-insensitive)
+ *  or a named-capture from a regex on the instance name. Mirrors the UI's
+ *  `keyFromRule`; used for `roleBy` so per-role MQE is picked server-side. */
+function ruleKey(
+  rule: ClusterByRule | undefined,
+  name: string,
+  attrs: Array<{ name: string; value: string }>,
+): string | null {
+  if (!rule) return null;
+  if (rule.kind === 'attribute') {
+    const want = rule.attribute.toLowerCase();
+    return attrs.find((a) => a.name.toLowerCase() === want)?.value || null;
+  }
+  try {
+    const m = new RegExp(rule.pattern, rule.flags ?? '').exec(name);
+    return (m?.groups?.[rule.valueGroup ?? 'group']) || null;
+  } catch {
+    return null;
+  }
+}
+
 function emptyResponse(
   layerKey: string,
   serviceId: string,
@@ -374,15 +396,29 @@ export function registerInternalTopologyRoute(
       function attrsFor(n: OapInstNode): Array<{ name: string; value: string 
}> {
         return attrsById.get(n.id) ?? attrsByName.get(n.name) ?? [];
       }
+      // Per-node role (from roleBy) + its metric defs: the role's 
`nodeMetrics`
+      // if any, else the top-level `nodeMetrics` fallback (which may be empty
+      // for a roles-only config). Keeps the real path role-aware once a
+      // clustered store actually emits intra-service instance relations.
+      const cfgNN = cfg; // non-null past the 404 guard; stable for closures
+      const cfgRoles = cfgNN.roles ?? [];
+      function roleOf(n: OapInstNode): string | undefined {
+        return ruleKey(cfgNN.roleBy, n.name, attrsFor(n)) ?? undefined;
+      }
+      function defsFor(n: OapInstNode): TopologyMetricDef[] {
+        const rk = roleOf(n);
+        const rc = rk ? cfgRoles.find((r) => r.key.toLowerCase() === 
rk.toLowerCase()) : undefined;
+        return rc?.nodeMetrics ?? cfgNN.nodeMetrics ?? [];
+      }
 
-      // ── Per-node MQE.
+      // ── Per-node MQE (each node uses its role's defs).
       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) => {
+          defsFor(n).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));
@@ -488,7 +524,7 @@ export function registerInternalTopologyRoute(
         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;
+        for (const def of defsFor(n)) filled[def.id] = m[def.id] ?? null;
         liveNodes.push({
           id: n.id,
           name: n.name,
@@ -497,6 +533,7 @@ export function registerInternalTopologyRoute(
           isReal: n.isReal,
           metrics: filled,
           attributes: attrsFor(n),
+          role: roleOf(n),
         });
       }
       const liveNodeIds = new Set(liveNodes.map((n) => n.id));
diff --git a/apps/bff/src/logic/layers/preview.ts 
b/apps/bff/src/logic/layers/preview.ts
index 87d1c04..c14a747 100644
--- a/apps/bff/src/logic/layers/preview.ts
+++ b/apps/bff/src/logic/layers/preview.ts
@@ -96,9 +96,29 @@ export function parsePreviewServiceInternalTopology(
   raw: string | undefined,
 ): ServiceInternalTopologyConfig | null {
   const o = parseJson(raw);
-  if (!o || !isMetricList(o.nodeMetrics)) return null;
+  if (!o) return null;
+  if (o.nodeMetrics !== undefined && !isMetricList(o.nodeMetrics)) return null;
   if (o.linkServerMetrics !== undefined && !isMetricList(o.linkServerMetrics)) 
return null;
   if (o.linkClientMetrics !== undefined && !isMetricList(o.linkClientMetrics)) 
return null;
+  // Each role's nodeMetrics is bounded the same way (it carries MQE too).
+  if (o.roles !== undefined) {
+    if (!Array.isArray(o.roles) || o.roles.length > MAX_METRICS) return null;
+    for (const r of o.roles) {
+      if (!r || typeof r !== 'object') return null;
+      const rr = r as Record<string, unknown>;
+      if (typeof rr.key !== 'string' || rr.key.length === 0) return null;
+      if (rr.nodeMetrics !== undefined && !isMetricList(rr.nodeMetrics)) 
return null;
+    }
+  }
+  // Need at least one metric source — top-level, a role, or a mock fixture.
+  const hasTop = Array.isArray(o.nodeMetrics) && o.nodeMetrics.length > 0;
+  const hasRole =
+    Array.isArray(o.roles) &&
+    o.roles.some((r) => {
+      const nm = (r as Record<string, unknown>)?.nodeMetrics;
+      return Array.isArray(nm) && nm.length > 0;
+    });
+  if (!hasTop && !hasRole && typeof o.mock !== 'string') return null;
   return o as unknown as ServiceInternalTopologyConfig;
 }
 
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 7985ebd..995e716 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -1236,6 +1236,7 @@ function ensureServiceInternalTopology(): 
ServiceInternalTopologyConfig {
   const tpl = draft.template;
   if (!tpl.serviceInternalTopology) tpl.serviceInternalTopology = 
emptyServiceInternalTopology();
   const s = tpl.serviceInternalTopology;
+  if (!s.nodeMetrics) s.nodeMetrics = [];
   if (!s.linkServerMetrics) s.linkServerMetrics = [];
   if (!s.linkClientMetrics) s.linkClientMetrics = [];
   return s;
@@ -1262,7 +1263,7 @@ function getMetricList(bucket: MetricBucket): 
TopologyMetricDef[] {
     if (bucket === 'instLinkClient') return 
t.instanceTopology?.linkClientMetrics ?? [];
   } else if (activeScope.value === 'serviceInternalTopology') {
     const t = ensureServiceInternalTopology();
-    if (bucket === 'sitNode') return t.nodeMetrics;
+    if (bucket === 'sitNode') return t.nodeMetrics ?? [];
     if (bucket === 'sitLinkServer') return t.linkServerMetrics ?? [];
     if (bucket === 'sitLinkClient') return t.linkClientMetrics ?? [];
   } else if (activeScope.value === 'dependency') {
diff --git a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue 
b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
index fcf9f36..805bfd7 100644
--- a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
@@ -94,8 +94,16 @@ function centerDefFor(n: ServiceInternalTopologyNode): 
TopologyMetricDef | null
 function ringDefFor(n: ServiceInternalTopologyNode): TopologyMetricDef | null {
   return pickByRole(metricDefsFor(n), 'ring');
 }
-// Default defs (no-role / legend baseline).
-const defaultRingDef = computed(() => pickByRole(cfg.value.nodeMetrics, 
'ring'));
+// Union of the top-level + every role's metric defs (deduped by id) — drives
+// the toolbar legend + the ring-legend baseline, since with roles the
+// top-level `nodeMetrics` may be empty.
+const allMetricDefs = computed<TopologyMetricDef[]>(() => {
+  const map = new Map<string, TopologyMetricDef>();
+  for (const d of cfg.value.nodeMetrics ?? []) if (!map.has(d.id)) 
map.set(d.id, d);
+  for (const r of cfg.value.roles ?? []) for (const d of r.nodeMetrics ?? []) 
if (!map.has(d.id)) map.set(d.id, d);
+  return [...map.values()];
+});
+const defaultRingDef = computed(() => pickByRole(allMetricDefs.value, 'ring'));
 
 function nodeVal(n: ServiceInternalTopologyNode, def: TopologyMetricDef | 
null): number | null {
   return def ? (n.metrics?.[def.id] ?? null) : null;
@@ -704,8 +712,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', 
onKeyDown, true));
       <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">
+      <div v-if="allMetricDefs.length > 0" class="sit-legend">
+        <span v-for="def in allMetricDefs" :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>
diff --git a/packages/api-client/src/topology.ts 
b/packages/api-client/src/topology.ts
index f471e08..f96ace1 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -198,9 +198,11 @@ export interface NodeRoleConfig {
 }
 
 export interface ServiceInternalTopologyConfig {
-  /** Per-instance MQE under `{ scope: ServiceInstance }`. Default for any
-   *  instance whose role defines no `nodeMetrics`. */
-  nodeMetrics: TopologyMetricDef[];
+  /** Per-instance MQE under `{ scope: ServiceInstance }`. Optional: it's the
+   *  metric set for instances with NO role (the simple, no-sibling case) and
+   *  the FALLBACK for a role that defines none. When `roles` cover every
+   *  container, this can be omitted — metrics come from 
`roles[].nodeMetrics`. */
+  nodeMetrics?: TopologyMetricDef[];
   /** Per-edge MQE under ServiceInstanceRelation, server side. */
   linkServerMetrics?: TopologyMetricDef[];
   /** Per-edge MQE under ServiceInstanceRelation, client side. */

Reply via email to