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. */
