This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new c66e2c8 perf(maps): topology scale caps, partial-metric banners,
parallel 3D load (#65)
c66e2c8 is described below
commit c66e2c81273cb5c4ba110a4ead864d1b474bb515
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Mon Jun 22 10:00:53 2026 +0800
perf(maps): topology scale caps, partial-metric banners, parallel 3D load
(#65)
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.
---
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 };
}