This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch perf/3d-topology-scale-caps in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit ce4f909c25caa304d3a40dd1b7b7765102d56891 Author: Wu Sheng <[email protected]> AuthorDate: Mon Jun 22 09:32:23 2026 +0800 perf(maps): topology scale caps, partial-metric banners, parallel 3D load Three independent scale/resilience hardenings for the topology maps and the 3D Infra Map. Output-identical on the happy path. - 3D Infra Map: per-node metric values now load in bounded-concurrency batches (new `metricConcurrency` config, default 4) instead of one chunk at a time, so load rings / traffic values fill in sooner on large layers. - Layer service map: when a graph exceeds the render ceiling (5000 services / 15000 calls) the route returns a `tooLarge` envelope and the UI shows a "too large to render" notice plus narrow-scope guidance, rather than attempting an unreadable layout. Reject-with-guidance, not truncation. - All four topology maps (service map, instance topology, deployment, endpoint dependency) thread per-chunk soft-fail counts and surface a `metricsPartial` advisory when some metric batches fail, so a transient OAP error reads as "unavailable" not "zero". The endpoint-dependency copy notes nodes/edges may be missing (it prunes valueless nodes). New banner strings translated across all 8 locales. Validated against the demo OAP: type-check / build / lint / 122+113 tests green; live topology renders with no false banners on a healthy graph. --- CHANGELOG.md | 3 ++ .../bff/src/bundled_templates/infra-3d/config.json | 1 + apps/bff/src/client/graphql.ts | 6 +++ apps/bff/src/http/query/deployment.ts | 7 +++- apps/bff/src/http/query/endpoint-dependency.ts | 7 +++- apps/bff/src/http/query/instance-topology.ts | 7 +++- apps/bff/src/http/query/topology.ts | 24 ++++++++++- apps/bff/src/logic/infra-3d/types.ts | 2 + apps/bff/src/logic/infra-3d/validate.ts | 2 + apps/ui/src/api/client.ts | 3 ++ apps/ui/src/features/infra-3d/Infra3DView.vue | 49 +++++++++++++--------- apps/ui/src/i18n/locales/de.json | 2 + apps/ui/src/i18n/locales/en.json | 2 + apps/ui/src/i18n/locales/es.json | 2 + apps/ui/src/i18n/locales/fr.json | 2 + apps/ui/src/i18n/locales/ja.json | 2 + apps/ui/src/i18n/locales/ko.json | 2 + apps/ui/src/i18n/locales/pt.json | 2 + apps/ui/src/i18n/locales/zh-CN.json | 2 + .../LayerEndpointDependencyView.vue | 12 ++++++ .../src/layer/service-map/LayerDeploymentView.vue | 6 +++ .../service-map/LayerInstanceTopologyView.vue | 4 ++ .../src/layer/service-map/LayerServiceMapView.vue | 28 ++++++++++--- packages/api-client/src/deployment.ts | 1 + packages/api-client/src/topology.ts | 6 +++ 25 files changed, 151 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e5fd8..1261cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,9 @@ The version line is shared by every package in the monorepo (apps + shared packa - **Layer dashboards skip a redundant service lookup on every load.** The dashboard route used to issue its own `listServices` to auto-pick the service and carry its entity-scope flags; it now reads the shared per-layer service catalog the sidebar already keeps warm, so a dashboard's first paint costs one fewer OAP round-trip in the common case. It still falls back to a live lookup for a just-registered service, a cold snapshot, or to surface an OAP outage (the "OAP unreachable" state no [...] - **The alarms list and count fire their two startup probes in parallel.** The server-time offset and backend-capability probes that precede every alarms query now run concurrently instead of one-after-the-other. +- **The 3D Infra Map loads its metrics in parallel.** Per-node metric values used to fetch one batch at a time; they now load in bounded-concurrency batches, so the load rings and traffic values fill in sooner on large layers. A new `metricConcurrency` setting in the Infra Map config (default 4) caps how many metric batches run at once. +- **Oversized layer topologies fail with a clear message instead of an unreadable map.** When a layer's service graph exceeds the render ceiling (5,000 services or 15,000 calls), the service map shows a "Topology too large to render" notice with the live counts and a hint to pick a specific service or lower the depth, rather than attempting to lay out a graph too dense to read. +- **Partial metric-load failures are now surfaced on every topology map.** If some metric batches fail to load (a transient OAP error) on the service map, instance topology, deployment, or endpoint-dependency map, a banner now explains that blank values may be unavailable rather than zero — and on the endpoint-dependency map, that some endpoints or links may be missing — so a backend hiccup isn't misread as real "no traffic" data. ### Fixes diff --git a/apps/bff/src/bundled_templates/infra-3d/config.json b/apps/bff/src/bundled_templates/infra-3d/config.json index 11fef88..4bb6e87 100644 --- a/apps/bff/src/bundled_templates/infra-3d/config.json +++ b/apps/bff/src/bundled_templates/infra-3d/config.json @@ -10,6 +10,7 @@ }, "pipeline": { "metricChunkSize": 6, + "metricConcurrency": 4, "topologyConcurrency": 4, "templateConcurrency": 8 }, diff --git a/apps/bff/src/client/graphql.ts b/apps/bff/src/client/graphql.ts index a9c98b3..ce0d9b1 100644 --- a/apps/bff/src/client/graphql.ts +++ b/apps/bff/src/client/graphql.ts @@ -143,6 +143,7 @@ export async function graphqlPost<T>( * contributes no aliases, so one transient error degrades that slice to * null metrics instead of aborting the whole fan-out (the topology routes * keep the graph and emit nulls). Returns `{}` when there are no fragments. + * Pass `stats` to accumulate failed/total chunk counts for surfacing. */ export async function fetchAliasedChunks<T>( opts: GraphqlOptions, @@ -150,6 +151,7 @@ export async function fetchAliasedChunks<T>( chunkSize: number, queryName: string, concurrency = 4, + stats?: { failed: number; total: number }, ): Promise<Record<string, T>> { if (fragments.length === 0) return {}; const chunks: string[][] = []; @@ -168,5 +170,9 @@ export async function fetchAliasedChunks<T>( for (const env of envs) { if (env) Object.assign(merged, env); } + if (stats) { + stats.total += chunks.length; + stats.failed += envs.filter((e) => e === null).length; + } return merged; } diff --git a/apps/bff/src/http/query/deployment.ts b/apps/bff/src/http/query/deployment.ts index 73427f3..34e65ec 100644 --- a/apps/bff/src/http/query/deployment.ts +++ b/apps/bff/src/http/query/deployment.ts @@ -504,9 +504,11 @@ export function registerDeploymentRoute( }); } + // track failed metric chunks → surface "blank may be unavailable, not zero" + const mstats = { failed: 0, total: 0 }; const [nodeEnv, edgeEnv] = await Promise.all([ - fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'DeploymentNodeMetrics'), - fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'DeploymentEdgeMetrics'), + fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'DeploymentNodeMetrics', 4, mstats), + fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'DeploymentEdgeMetrics', 4, mstats), ]); for (const [alias, shape] of Object.entries(nodeEnv)) { @@ -594,6 +596,7 @@ export function registerDeploymentRoute( nodes: liveNodes, calls: liveCalls, reachable: true, + ...(mstats.failed > 0 ? { metricsPartial: { failedChunks: mstats.failed, totalChunks: mstats.total } } : {}), } satisfies DeploymentResponse); }, ); diff --git a/apps/bff/src/http/query/endpoint-dependency.ts b/apps/bff/src/http/query/endpoint-dependency.ts index 38c5b26..f2052da 100644 --- a/apps/bff/src/http/query/endpoint-dependency.ts +++ b/apps/bff/src/http/query/endpoint-dependency.ts @@ -469,9 +469,11 @@ export function registerEndpointDependencyRoute( }); } + // track failed metric chunks → surface "blank may be unavailable, not zero" + const mstats = { failed: 0, total: 0 }; const [nodeEnv, edgeEnv] = await Promise.all([ - fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'EndpointMetrics'), - fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'EndpointEdgeMetrics'), + fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'EndpointMetrics', 4, mstats), + fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'EndpointEdgeMetrics', 4, mstats), ]); for (const [alias, shape] of Object.entries(nodeEnv)) { @@ -550,6 +552,7 @@ export function registerEndpointDependencyRoute( nodes: liveNodes, calls: liveCalls, reachable: true, + ...(mstats.failed > 0 ? { metricsPartial: { failedChunks: mstats.failed, totalChunks: mstats.total } } : {}), } satisfies EndpointDependencyResponse); }, ); diff --git a/apps/bff/src/http/query/instance-topology.ts b/apps/bff/src/http/query/instance-topology.ts index af21e23..fb02f65 100644 --- a/apps/bff/src/http/query/instance-topology.ts +++ b/apps/bff/src/http/query/instance-topology.ts @@ -383,9 +383,11 @@ export function registerInstanceTopologyRoute( }); } + // track failed metric chunks → surface "blank may be unavailable, not zero" + const mstats = { failed: 0, total: 0 }; const [nodeEnv, edgeEnv] = await Promise.all([ - fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'InstanceNodeMetrics'), - fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'InstanceEdgeMetrics'), + fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'InstanceNodeMetrics', 4, mstats), + fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'InstanceEdgeMetrics', 4, mstats), ]); for (const [alias, shape] of Object.entries(nodeEnv)) { @@ -483,6 +485,7 @@ export function registerInstanceTopologyRoute( nodes: liveNodes, calls: liveCalls, reachable: true, + ...(mstats.failed > 0 ? { metricsPartial: { failedChunks: mstats.failed, totalChunks: mstats.total } } : {}), } satisfies InstanceTopologyResponse); }, ); diff --git a/apps/bff/src/http/query/topology.ts b/apps/bff/src/http/query/topology.ts index 977f4b3..a718b9d 100644 --- a/apps/bff/src/http/query/topology.ts +++ b/apps/bff/src/http/query/topology.ts @@ -204,6 +204,11 @@ export function seriesFromMqe(env: MqeShape | undefined): Array<number | null> | }); } +// Safety valve: above this the graph can't render legibly and risks OOMing the +// browser, so the route rejects with guidance rather than drawing a partial map. +const TOPOLOGY_MAX_NODES = 5000; +const TOPOLOGY_MAX_EDGES = 15000; + function emptyResponse( layerKey: string, serviceArg: string | null, @@ -418,6 +423,15 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD ); } + // Reject-with-guidance instead of a partial graph: too large to draw + // legibly + risks OOMing the browser. UI shows a narrow-scope hint. + if (nodes.size > TOPOLOGY_MAX_NODES || calls.size > TOPOLOGY_MAX_EDGES) { + return reply.send({ + ...emptyResponse(layerKey, serviceArg, depth, topoCfg, true), + tooLarge: { nodes: nodes.size, edges: calls.size }, + } satisfies TopologyResponse); + } + // (Disconnected services are dropped a few lines below — they // don't belong on a topology map. The earlier "fill them in as // standalone nodes" pass was reverted after a closer look at @@ -505,9 +519,12 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD }); } + // Accumulate failed metric chunks so the response can flag "blank = + // unavailable, not zero" rather than letting an OAP 5xx read as no-traffic. + const mstats = { failed: 0, total: 0 }; const [nodeEnv, edgeEnv] = await Promise.all([ - fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'NodeMetrics'), - fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'EdgeMetrics'), + fetchAliasedChunks<MqeShape>(opts, nodeFragments, 150, 'NodeMetrics', 4, mstats), + fetchAliasedChunks<MqeShape>(opts, edgeFragments, 200, 'EdgeMetrics', 4, mstats), ]); for (const [alias, shape] of Object.entries(nodeEnv)) { @@ -609,6 +626,9 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD nodes: liveNodes, calls: liveCalls, reachable: true, + ...(mstats.failed > 0 + ? { metricsPartial: { failedChunks: mstats.failed, totalChunks: mstats.total } } + : {}), } satisfies TopologyResponse); }, ); diff --git a/apps/bff/src/logic/infra-3d/types.ts b/apps/bff/src/logic/infra-3d/types.ts index d773aa6..cc97479 100644 --- a/apps/bff/src/logic/infra-3d/types.ts +++ b/apps/bff/src/logic/infra-3d/types.ts @@ -121,6 +121,8 @@ export interface InfraPipelineLimits { * landing / dashboard chunking constant (6) so the 3D map shares the * same OAP back-pressure profile. */ metricChunkSize: number; + /** Max concurrent metric-chunk requests in stage 5 (each still ≤ metricChunkSize services). */ + metricConcurrency: number; /** Max concurrent `getServicesTopology` calls in stage 3. */ topologyConcurrency: number; /** Max concurrent `getLayerTemplate` calls in stage 2. */ diff --git a/apps/bff/src/logic/infra-3d/validate.ts b/apps/bff/src/logic/infra-3d/validate.ts index 06945f2..6bf50e7 100644 --- a/apps/bff/src/logic/infra-3d/validate.ts +++ b/apps/bff/src/logic/infra-3d/validate.ts @@ -112,6 +112,8 @@ const configSchema = z // ceiling 5xx's beyond 12 services. A larger chunk size makes every // oversized request fail, so reject it at config-save time. metricChunkSize: z.number().int().min(1).max(12), + // Concurrent chunks in flight (each still ≤ chunkSize); default 4 for older configs. + metricConcurrency: z.number().int().min(1).max(8).default(4), topologyConcurrency: z.number().int().min(1).max(16), templateConcurrency: z.number().int().min(1).max(32), }) diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts index f290628..abcb114 100644 --- a/apps/ui/src/api/client.ts +++ b/apps/ui/src/api/client.ts @@ -690,6 +690,9 @@ export interface InfraEdgeStyle { export interface InfraPipelineLimits { metricChunkSize: number; + /** Max concurrent metric-chunk requests in flight (default 4). Optional + * for back-compat with older saved configs. */ + metricConcurrency?: number; topologyConcurrency: number; templateConcurrency: number; } diff --git a/apps/ui/src/features/infra-3d/Infra3DView.vue b/apps/ui/src/features/infra-3d/Infra3DView.vue index a5713bd..57efd7a 100644 --- a/apps/ui/src/features/infra-3d/Infra3DView.vue +++ b/apps/ui/src/features/infra-3d/Infra3DView.vue @@ -372,8 +372,8 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = { // Group chunks by level so the drawer can label the in-flight // batch with the level the operator is watching land. Within a - // level, slice by chunkSize. Sequential per chunk so the timeline - // progress reads honestly (parallel-chunks would jump the bar). + // level, slice by chunkSize; chunks then run in bounded-concurrency + // groups (below), with progress reported as each chunk lands. const byLevel = new Map<string, FetchUnit[]>(); for (const u of units) { const lvl = levelForLayer(u.layer); @@ -398,30 +398,29 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = { } } + // Fan chunks out in bounded-concurrency groups (each request is still + // ≤ metricChunkSize services, so OAP's per-request budget is unchanged). + const metricConcurrency = Math.max(1, Math.min(8, cfg.pipeline.metricConcurrency ?? 4)); + // Two HOUR buckets — cheapest query that still smooths a spiky minute. + const metricWindow = { startMs: Date.now() - 2 * 3600_000, endMs: Date.now(), step: 'HOUR' as const }; let servicesDone = 0; let errCount = 0; - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]!; - rep.progress( - `fetching ${chunk.level} · chunk ${i + 1} / ${chunks.length}`, - { - kind: 'metrics', - servicesTotal: units.length, - servicesDone, - chunkIndex: i + 1, - chunkTotal: chunks.length, - currentLevel: chunk.level, - }, - ); + let chunksDone = 0; + rep.progress(`fetching metrics · 0 / ${chunks.length} chunks`, { + kind: 'metrics', + servicesTotal: units.length, + servicesDone: 0, + chunkIndex: 0, + chunkTotal: chunks.length, + currentLevel: chunks[0]?.level ?? null, + }); + const runChunk = async (chunk: { level: string; units: FetchUnit[] }) => { try { const r = await bff.infra3d.metrics({ services: chunk.units.map((u) => ({ name: u.name, layer: u.layer, normal: u.normal, mqe: u.mqe, })), - // Last 2h at HOUR step — two buckets, the cheapest query that - // still smooths a single spiky minute. The BFF reduces the - // series to one scalar for the cube's traffic chip. - window: { startMs: Date.now() - 2 * 3600_000, endMs: Date.now(), step: 'HOUR' }, + window: metricWindow, }); setMetricValues(r.values); if (r.errors && Object.keys(r.errors).length > 0) errCount += Object.keys(r.errors).length; @@ -434,6 +433,18 @@ const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = { console.warn('[infra-3d] metrics chunk failed:', err); } servicesDone += chunk.units.length; + chunksDone += 1; + rep.progress(`fetching ${chunk.level} · ${chunksDone} / ${chunks.length} chunks`, { + kind: 'metrics', + servicesTotal: units.length, + servicesDone, + chunkIndex: chunksDone, + chunkTotal: chunks.length, + currentLevel: chunk.level, + }); + }; + for (let g = 0; g < chunks.length; g += metricConcurrency) { + await Promise.all(chunks.slice(g, g + metricConcurrency).map(runChunk)); } const summary = errCount === 0 diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index 3f18c32..b7239ee 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1,5 +1,7 @@ { "_comment": "Deutscher Katalog - Schluessel = englischer Quelltext; Ausgabe = uebersetzte Zeichenkette.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Einige Metriken konnten nicht geladen werden ({failed} von {total} Batches fehlgeschlagen) — leere Werte sind möglicherweise nicht verfügbar, nicht null.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Einige Metriken konnten nicht geladen werden ({failed} von {total} Batches fehlgeschlagen) — einige Endpoints oder Verbindungen fehlen möglicherweise.", "Welcome to SkyWalking": "Willkommen bei SkyWalking", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 112ca4b..13d3f90 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1,5 +1,7 @@ { "_comment": "Source catalog. Keys ARE the English strings (Lingui-style). Non-English locales translate each key; missing keys fall back to English at render. Edit English here first, then run the BFF i18n:seed CLI (no equivalent on the UI side — translators edit catalogs directly).", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.", "Welcome to SkyWalking": "Welcome to SkyWalking", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index 2355f61..a982d2b 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1,5 +1,7 @@ { "_comment": "Traducción al español (variante neutra latinoamericana). Nombres propios (SkyWalking, Horizon, Apache, LDAP, OAP, BFF) se mantienen sin traducir. Los marcadores {placeholder} no se traducen.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "No se pudieron cargar algunas métricas ({failed} de {total} lotes fallaron) — los valores en blanco pueden no estar disponibles, no son cero.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "No se pudieron cargar algunas métricas ({failed} de {total} lotes fallaron) — algunos endpoints o enlaces pueden faltar.", "Welcome to SkyWalking": "Te damos la bienvenida a SkyWalking", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index ec3e344..42fa778 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1,5 +1,7 @@ { "_comment": "Catalogue francais - cles = source anglaise; sortie = chaine traduite.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Certaines métriques n'ont pas pu être chargées ({failed} lots sur {total} en échec) — les valeurs vides peuvent être indisponibles, et non nulles.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Certaines métriques n'ont pas pu être chargées ({failed} lots sur {total} en échec) — certains endpoints ou liens peuvent être manquants.", "Welcome to SkyWalking": "Bienvenue dans SkyWalking", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index cab0eb9..a92e591 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1,5 +1,7 @@ { "_comment": "日本語訳。固有名詞(SkyWalking、Horizon、Apache、LDAP、OAP、BFF)は原文表記を維持する。プレースホルダ {placeholder} は翻訳しない。", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "一部のメトリクスを読み込めませんでした({total} バッチ中 {failed} バッチが失敗)。空白の値はゼロではなく、取得できなかった可能性があります。", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "一部のメトリクスを読み込めませんでした({total} バッチ中 {failed} バッチが失敗)。一部のエンドポイントやリンクが欠落している可能性があります。", "Welcome to SkyWalking": "SkyWalking へようこそ", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index 9219789..f98271d 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1,5 +1,7 @@ { "_comment": "한국어 번역. 고유명사 (SkyWalking, Horizon, Apache, LDAP, OAP, BFF)는 원문 그대로 표기한다. `placeholder` 형식의 자리표시자는 번역하지 않는다.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "일부 지표를 불러오지 못했습니다 ({total}개 배치 중 {failed}개 실패) — 빈 값은 0이 아니라 사용할 수 없는 값일 수 있습니다.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "일부 지표를 불러오지 못했습니다 ({total}개 배치 중 {failed}개 실패) — 일부 엔드포인트나 링크가 누락되었을 수 있습니다.", "Welcome to SkyWalking": "SkyWalking에 오신 것을 환영합니다", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index 0a50561..367a0a1 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1,5 +1,7 @@ { "_comment": "Tradução em português (variante brasileira). Nomes próprios (SkyWalking, Horizon, Apache, LDAP, OAP, BFF) permanecem em inglês. Os marcadores {placeholder} não são traduzidos.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Não foi possível carregar algumas métricas ({failed} de {total} lotes falharam) — valores em branco podem estar indisponíveis, não são zero.", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Não foi possível carregar algumas métricas ({failed} de {total} lotes falharam) — alguns endpoints ou ligações podem estar ausentes.", "Welcome to SkyWalking": "Bem-vindo ao SkyWalking", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index 8ed4bee..b9ae868 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1,5 +1,7 @@ { "_comment": "简体中文译文。专有名词(SkyWalking、Horizon、Apache、LDAP、OAP 等)按 CLAUDE.md 规定保留原文。占位符如 {placeholder} 不要翻译。", + "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "部分指标加载失败({total} 批中有 {failed} 批失败),空白值可能表示数据暂不可用,而非零值。", + "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "部分指标加载失败({total} 批中有 {failed} 批失败),部分端点或链路可能缺失。", "Welcome to SkyWalking": "欢迎使用 SkyWalking", "Horizon": "Horizon", "SkyWalking Horizon": "SkyWalking Horizon", diff --git a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue index aef07a2..299259a 100644 --- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue +++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue @@ -143,6 +143,7 @@ const { nodes: baseNodes, calls: baseCalls, isLoading, isFetching, data } = useL ); const reachable = computed(() => data.value?.reachable !== false); const errorText = computed(() => data.value?.error ?? null); +const metricsPartial = computed(() => data.value?.metricsPartial ?? null); // ── Interactive expansion ───────────────────────────────────────── // `getEndpointDependencies` returns a node's WHOLE neighbourhood (both @@ -929,6 +930,9 @@ function edgeRowCrosshair(rowId: string): number | null { <strong>{{ t('OAP unreachable.') }}</strong> {{ errorText ?? t('API dependency feed failed — check the BFF and OAP.') }} </div> + <div v-if="metricsPartial" class="banner warn"> + {{ t('Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.', { failed: metricsPartial.failedChunks, total: metricsPartial.totalChunks }) }} + </div> <section v-if="selectedEndpoint" class="ep-graph-card sw-card" :class="{ 'has-detail': selectedNode || selectedCall }" :style="{ height: cardHeightPx + 'px' }"> <!-- Two-column layout: graph on the left, selection detail @@ -1555,6 +1559,14 @@ function edgeRowCrosshair(rowId: string): number | null { color: #f87171; font-size: 11.5px; } +.banner.warn { + padding: 8px 12px; + background: var(--sw-warn-soft); + border: 1px solid var(--sw-warn); + border-radius: 6px; + color: var(--sw-warn); + font-size: 11.5px; +} .ep-graph-card { /* Height comes from the script's `cardHeightPx` computed — same adapter as the topology card: 60% floor + 1100px cap, scaled by diff --git a/apps/ui/src/layer/service-map/LayerDeploymentView.vue b/apps/ui/src/layer/service-map/LayerDeploymentView.vue index 7a12c40..b4bfec7 100644 --- a/apps/ui/src/layer/service-map/LayerDeploymentView.vue +++ b/apps/ui/src/layer/service-map/LayerDeploymentView.vue @@ -73,6 +73,7 @@ const { selectedId } = useSelectedService(); const enabled = computed(() => !!selectedId.value); const { data, nodes, calls, isFetching } = useDeployment(layerKey, selectedId, enabled); const serviceName = computed(() => displayServiceName(data.value?.serviceName) || ''); +const metricsPartial = computed(() => data.value?.metricsPartial ?? null); const cfg = computed<DeploymentConfig>( () => data.value?.config ?? { nodeMetrics: [] }, @@ -1042,6 +1043,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true)); </div> </div> + <div v-if="metricsPartial" class="banner warn"> + {{ t('Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.', { failed: metricsPartial.failedChunks, total: metricsPartial.totalChunks }) }} + </div> + <div class="sit-body" :class="{ 'no-selection': !selectedCall || mapTab === 'flows' }"> <div v-show="mapTab === 'topology'" ref="containerEl" class="sit-canvas" :style="{ height: canvasHeightPx + 'px' }"> <div v-if="showPickPrompt" class="sit-state">{{ t('Pick a service to see its deployment topology.') }}</div> @@ -1279,6 +1284,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true)); <style scoped> .sit { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; } +.banner.warn { padding: 8px 12px; background: var(--sw-warn-soft); border: 1px solid var(--sw-warn); border-radius: 6px; color: var(--sw-warn); font-size: 11.5px; } .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); diff --git a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue index 2e69232..d0dfaf8 100644 --- a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue +++ b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue @@ -152,6 +152,7 @@ function pick(which: 'client' | 'server', val: string): void { const enabled = computed(() => !!clientId.value && !!serverId.value); const { data, nodes, calls, isFetching } = useInstanceTopology(layerKey, clientId, serverId, enabled); +const metricsPartial = computed(() => data.value?.metricsPartial ?? null); // Resolve both names through the SAME layer naming rule the service map // uses (`resolveServiceIdentity` → `display`), so the `<group>::` prefix @@ -562,6 +563,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true)); </div> </div> + <div v-if="metricsPartial" class="banner warn">{{ t('Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.', { failed: metricsPartial.failedChunks, total: metricsPartial.totalChunks }) }}</div> + <div class="imv-body" :class="{ 'no-selection': !selectedCall }"> <div ref="containerEl" class="imv-canvas"> <div v-if="showPickPrompt" class="imv-state">{{ t('Pick a client and server service to see their instance topology.') }}</div> @@ -833,4 +836,5 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true)); .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; } +.banner.warn { padding: 8px 12px; background: var(--sw-warn-soft); border: 1px solid var(--sw-warn); border-radius: 6px; color: var(--sw-warn); font-size: 11.5px; } </style> diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue b/apps/ui/src/layer/service-map/LayerServiceMapView.vue index ea78ce5..fa95def 100644 --- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue +++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue @@ -121,8 +121,8 @@ const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.ro // Focus-service is local to the topology view (NOT the header's // `useSelectedService` — the topology map is layer-wide by default). -// - empty array = no focus, BFF seeds from up-to-30 services in the -// layer for a layer-overview graph +// - empty array = no focus, BFF seeds from the layer's services +// for a layer-overview graph // - one or more entries = comma-joined and passed as `?service=` so // the BFF seeds BFS from each selected service (multi-select) const focusServiceNames = ref<string[]>([]); @@ -216,6 +216,8 @@ const { nodes, calls, isLoading, isFetching, data, refetch } = useLayerTopology( ); const reachable = computed(() => data.value?.reachable !== false); const errorText = computed(() => data.value?.error ?? null); +const tooLarge = computed(() => data.value?.tooLarge ?? null); +const metricsPartial = computed(() => data.value?.metricsPartial ?? null); // ── Node visibility filter ────────────────────────────────────────── // One auto-derived facet — LAYER (`node.layers`, multi-valued ⇒ @@ -1651,9 +1653,8 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st </div> </div> <!-- Depth is only meaningful when a focus seed is picked — - "All services" already seeds from up to 30 layer services - so a BFS-depth control would either be redundant or - explode the graph. --> + "All services" already seeds from the whole layer, so a + BFS-depth control would explode the graph. --> <label v-if="focusServiceNames.length > 0" class="depth-pick"> <span>Depth</span> <select v-model.number="depth"> @@ -1670,6 +1671,13 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st <strong>OAP unreachable.</strong> {{ errorText ?? 'Topology feed failed — check the BFF and OAP.' }} </div> + <div v-if="tooLarge" class="banner warn"> + <strong>Topology too large to render</strong> — {{ tooLarge.nodes.toLocaleString() }} services · {{ tooLarge.edges.toLocaleString() }} calls. + {{ embedded ? 'Open the Topology tab and narrow the scope to see a complete map.' : 'Pick a specific service above, or lower the depth, to see a complete map.' }} + </div> + <div v-if="metricsPartial" class="banner warn"> + Some metrics could not be loaded ({{ metricsPartial.failedChunks }} of {{ metricsPartial.totalChunks }} batches failed) — blank values may be unavailable, not zero. + </div> <section class="sm-card sw-card" :class="{ 'has-selection': selectedNode || selectedCall }" :style="{ height: cardHeightPx + 'px' }"> <div ref="containerEl" class="sm-graph"> @@ -2068,7 +2076,7 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st All nodes are hidden by the current filter. <button class="sw-btn small" type="button" @click="resetFilter">Reset filter</button> </div> - <div v-else class="loader"> + <div v-else-if="!tooLarge" class="loader"> No services with metric data in this layer for the last 15 minutes. </div> @@ -2641,6 +2649,14 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st color: #f87171; font-size: 11.5px; } +.banner.warn { + padding: 8px 12px; + background: var(--sw-warn-soft); + border: 1px solid var(--sw-warn); + border-radius: 6px; + color: var(--sw-warn); + font-size: 11.5px; +} .sm-card { /* Map card — graph on the left, dual-stack sidebar on the right when a node / edge is selected; otherwise the card collapses to diff --git a/packages/api-client/src/deployment.ts b/packages/api-client/src/deployment.ts index 5e47d0c..3c4930f 100644 --- a/packages/api-client/src/deployment.ts +++ b/packages/api-client/src/deployment.ts @@ -267,4 +267,5 @@ export interface DeploymentResponse { calls: DeploymentCall[]; reachable: boolean; error?: string; + metricsPartial?: { failedChunks: number; totalChunks: number }; } diff --git a/packages/api-client/src/topology.ts b/packages/api-client/src/topology.ts index bb3b8c7..dd4b713 100644 --- a/packages/api-client/src/topology.ts +++ b/packages/api-client/src/topology.ts @@ -241,6 +241,10 @@ export interface TopologyResponse { calls: TopologyCall[]; reachable: boolean; error?: string; + /** Graph exceeded the render cap — nodes/calls empty, UI shows a narrow-scope hint. */ + tooLarge?: { nodes: number; edges: number }; + /** Some metric chunks failed (OAP 5xx); blank values may be unavailable, not zero. */ + metricsPartial?: { failedChunks: number; totalChunks: number }; } /** One instance node in the instance-topology drill-down. `id` is OAP's @@ -285,6 +289,7 @@ export interface InstanceTopologyResponse { calls: InstanceTopologyCall[]; reachable: boolean; error?: string; + metricsPartial?: { failedChunks: number; totalChunks: number }; } export interface EndpointDependencyNode { @@ -332,4 +337,5 @@ export interface EndpointDependencyResponse { calls: EndpointDependencyCall[]; reachable: boolean; error?: string; + metricsPartial?: { failedChunks: number; totalChunks: number }; }
