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
