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