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 };
 }


Reply via email to