You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/20 16:08:56 UTC

(skywalking-horizon-ui) 02/02: fix(traces): conclude success trace-wide, and keep every labelled series

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/traceql-otlp-and-layer-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 96e0cbb0fbeeecf62b60348c1dfdfe8f8edb3666
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon Sep 21 00:08:44 2026 +0800

    fix(traces): conclude success trace-wide, and keep every labelled series
    
    All four found in review.
    
    A search span set holds the spans that MATCHED, capped at `spss`, so an `ok`
    among them said nothing about the spans the filter excluded — and reading one
    as success stopped both the deferred pass and the UI from ever looking again.
    Success now comes only from `serviceStats`, which counts the whole trace;
    a failing span still fails it at once, and an OAP that sends no counts leaves
    the row unknown, to be read.
    
    Percentile and status-code metrics answer with several labelled results. The
    GraphQL selection did not ask for the labels and the reader kept `results[0]`,
    so the ten percentiles and the response-status metric this adds would each
    have drawn one unnamed series as though it were the metric. Labels are
    requested, every result is carried, and the edge chart draws them all.
    
    `parsePreviewProcessTopology` still demanded the old side lists, so Preview
    returned null for anything the editor now writes and quietly used the
    published template instead — an edit that looked like it did nothing. Its
    metric cap was 32, below the 38 the bundled layers ship.
    
    The editor normalised an old-spelling config on mount only. Switching layer or
    resetting to remote reuses the component, leaving the rows bound to the
    resolver's copies, so edits landed on a clone the draft never saw.
---
 apps/bff/src/client/traceql.ts                     | 29 ++++++++++++------
 apps/bff/src/http/query/ebpf.ts                    | 34 ++++++++++++++++-----
 apps/bff/src/http/query/traceql.test.ts            | 35 ++++++++++++++++++----
 apps/bff/src/logic/layers/preview.ts               | 21 +++++++++----
 .../layer-templates/ProcessConfigEditor.test.ts    | 20 +++++++++++++
 .../admin/layer-templates/ProcessConfigEditor.vue  | 20 +++++++++----
 apps/ui/src/i18n/locales/de.json                   |  1 +
 apps/ui/src/i18n/locales/en.json                   |  1 +
 apps/ui/src/i18n/locales/es.json                   |  1 +
 apps/ui/src/i18n/locales/fr.json                   |  1 +
 apps/ui/src/i18n/locales/ja.json                   |  1 +
 apps/ui/src/i18n/locales/ko.json                   |  1 +
 apps/ui/src/i18n/locales/pt.json                   |  1 +
 apps/ui/src/i18n/locales/zh-CN.json                |  1 +
 .../layer/profiling/LayerNetworkProfilingView.vue  |  7 +++--
 packages/api-client/src/topology.ts                |  5 ++++
 16 files changed, 144 insertions(+), 35 deletions(-)

diff --git a/apps/bff/src/client/traceql.ts b/apps/bff/src/client/traceql.ts
index be60ad28..feae0359 100644
--- a/apps/bff/src/client/traceql.ts
+++ b/apps/bff/src/client/traceql.ts
@@ -236,6 +236,9 @@ interface WireSearchTrace {
   startTimeUnixNano?: string;
   durationMs?: number;
   spanSets?: Array<{ spans?: WireSearchSpan[]; matched?: number }>;
+  /** Span and error counts per service over the WHOLE trace, which the span
+   *  sets are not: they hold the spans that matched, capped at `spss`. */
+  serviceStats?: Record<string, { spanCount?: number; errorCount?: number }>;
 }
 
 interface WireSearchResponse {
@@ -379,14 +382,21 @@ export function focusServiceOf(q: string | undefined): string | null {
   return m ? m[1] : null;
 }
 
+/** Errors over the whole trace, or null when OAP did not count them. Older
+ *  OAPs send no `serviceStats`, and then the row stays unknown and is read. */
+function traceWideErrors(t: WireSearchTrace): number | null {
+  const stats = t.serviceStats;
+  if (!stats || Object.keys(stats).length === 0) return null;
+  let errors = 0;
+  for (const s of Object.values(stats)) errors += s.errorCount ?? 0;
+  return errors;
+}
+
 function toRow(ds: TraceQLDatasource, t: WireSearchTrace, focus: string | null): TraceQLTraceRow {
   const spans = (t.spanSets ?? []).flatMap((s) => s.spans ?? []);
   const traceStartNs = num(t.startTimeUnixNano);
   const services: string[] = [];
   let anyError = false;
-  /** Whether the response ANSWERED for the status, so an unknown row is told
-   *  apart from one OAP called healthy — the list only re-reads the unknowns. */
-  let statusKnown = false;
   let entry: TraceQLServiceEntry | undefined;
   let entryStartNs: number | null = null;
   for (const s of spans) {
@@ -402,7 +412,6 @@ function toRow(ds: TraceQLDatasource, t: WireSearchTrace, focus: string | null):
     if (listed === 'error') anyError = true;
     else if (listed !== 'ok' && !anyError
       && attributesReportError(attrList(s.attributes), { emptyIsAbsent: true })) anyError = true;
-    if (listed === 'error' || listed === 'ok') statusKnown = true;
     if (focus && svc === focus) {
       const startNs = num(s.startTimeUnixNano);
       // The service's FIRST span — where it enters the trace. A service can be
@@ -430,11 +439,13 @@ function toRow(ds: TraceQLDatasource, t: WireSearchTrace, focus: string | null):
     services,
     serviceEntry: entry,
     // `undefined` is UNKNOWN, and the list re-reads those traces one by one.
-    // OAP answering with a `status` settles the row here, which is the whole
-    // point of it projecting one. Without that, a marker declares FAILURE only:
-    // no marker is not success, since the result tags are configurable upstream
-    // and a span can fail carrying none of them.
-    isError: anyError ? true : statusKnown ? false : undefined,
+    //
+    // SUCCESS is only ever concluded from `serviceStats`, which counts the
+    // whole trace. A span set holds the spans that MATCHED, capped at `spss`,
+    // so an `ok` among them says nothing about the spans the query excluded —
+    // reading one as success hides a failure downstream of the filter and
+    // stops anything looking again.
+    isError: anyError ? true : traceWideErrors(t) === 0 ? false : undefined,
   };
 }
 
diff --git a/apps/bff/src/http/query/ebpf.ts b/apps/bff/src/http/query/ebpf.ts
index 325d4710..34355d6e 100644
--- a/apps/bff/src/http/query/ebpf.ts
+++ b/apps/bff/src/http/query/ebpf.ts
@@ -302,7 +302,13 @@ function sanitiseNetworkSamplings(
 
 interface MqeEnv {
   error?: string | null;
-  results?: Array<{ values?: Array<{ value: string | number | null }> }>;
+  results?: Array<{
+    /** Labels distinguish the results of a multi-result metric — a percentile
+     *  publishes one per rank, a status-code metric one per code. Without them
+     *  the extra results are indistinguishable and get dropped. */
+    metric?: { labels?: Array<{ key?: string; value?: string }> };
+    values?: Array<{ value: string | number | null }>;
+  }>;
 }
 
 /**
@@ -334,20 +340,32 @@ function processRelationFragment(
     ` destServiceInstanceName: ${JSON.stringify(dst.serviceInstanceName)},` +
     ` destProcessName: ${JSON.stringify(dst.processName)} },\n` +
     `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE${coldFrag} }\n` +
-    `    ) { error results { values { value } } }`
+    `    ) { error results { metric { labels { key value } } values { value } } }`
   );
 }
 
-function relationSeries(env: MqeEnv | undefined): Array<number | null> {
-  if (!env || env.error) return [];
-  const values = env.results?.[0]?.values ?? [];
-  return values.map((v) => {
+function numbers(values: Array<{ value: string | number | null }> | undefined): Array<number | null> {
+  return (values ?? []).map((v) => {
     if (v.value === null || v.value === undefined) return null;
     const n = Number(v.value);
     return Number.isFinite(n) ? n : null;
   });
 }
 
+/** EVERY result, each under its labels. A percentile metric answers with one
+ *  result per rank and a status-code metric one per code; keeping only the
+ *  first drew one of them, unnamed, as if it were the whole metric. */
+function relationSeriesAll(env: MqeEnv | undefined): Array<{ label?: string; values: Array<number | null> }> {
+  if (!env || env.error) return [];
+  return (env.results ?? []).map((r) => {
+    const label = (r.metric?.labels ?? [])
+      .map((l) => l.value)
+      .filter((v): v is string => !!v)
+      .join(' · ');
+    return { ...(label ? { label } : {}), values: numbers(r.values) };
+  });
+}
+
 // `detectType` matters because the two sides disagree on VIRTUAL processes:
 // GraphQL listProcesses calls the DAO with includeVirtual = TRUE, while the gate
 // that decides whether a network task may be created —
@@ -784,11 +802,13 @@ export function registerEBPFRoutes(app: FastifyInstance, deps: EBPFRouteDeps): v
       try {
         const raw = await graphqlPost<Record<string, MqeEnv>>(opts, query);
         for (const [alias, { side, metric }] of aliasMap) {
+          const series = relationSeriesAll(raw[alias]);
           const out: ProcessRelationMetric = {
             id: metric.id,
             label: metric.label,
             unit: metric.unit,
-            values: relationSeries(raw[alias]),
+            values: series[0]?.values ?? [],
+            ...(series.length > 1 ? { series } : {}),
           };
           if (side === 'client') payload.client.push(out);
           else if (side === 'server') payload.server.push(out);
diff --git a/apps/bff/src/http/query/traceql.test.ts b/apps/bff/src/http/query/traceql.test.ts
index 52fe4acf..f8e55fff 100644
--- a/apps/bff/src/http/query/traceql.test.ts
+++ b/apps/bff/src/http/query/traceql.test.ts
@@ -251,10 +251,33 @@ describe('search: the window is seconds, and the rows are reduced', () => {
     expect(zipkinRow!.isError).toBe(true);
   });
 
-  it('settles the row from the status OAP projects onto a search span', async () => {
+  it('will not call a trace healthy from the spans a query matched', async () => {
+    // A span set holds the spans that MATCHED, capped at `spss`. An `ok`
+    // among them says nothing about the ones the filter excluded, so success
+    // comes from serviceStats — which counts the whole trace — or not at all.
+    const withStats = (stats?: Record<string, { errorCount: number }>): FetchLike => async () => {
+      const body = searchBody();
+      body.traces[0]!.spanSets[0]!.spans[0]!.attributes.push(attr('status', 'ok'));
+      if (stats) (body.traces[0] as Record<string, unknown>).serviceStats = stats;
+      return json(body);
+    };
+    const row = async (stats?: Record<string, { errorCount: number }>) => {
+      const opts = buildTraceQLOpts(cfgWith({ otlpUrl: OTLP_URL }), 'otlp', withStats(stats))!;
+      const [r] = await traceqlSearch(opts, { startMs: 1, endMs: 2, limit: 5 });
+      return r!.isError;
+    };
+    // An ok span and no trace-wide count: unknown, and the list reads it.
+    expect(await row()).toBeUndefined();
+    // Counted, and clean across every service: settled here.
+    expect(await row({ a: { errorCount: 0 }, b: { errorCount: 0 } })).toBe(false);
+    // Counted, and a service the span set never listed failed.
+    expect(await row({ a: { errorCount: 0 }, b: { errorCount: 2 } })).toBeUndefined();
+  });
+
+  it('reads a FAILURE from the status OAP projects onto a search span', async () => {
     // OAP writes `status` on every search-result span so a list can show
-    // failures without reading each trace. Without it the row stays unknown
-    // and the page reads the trace to find out.
+    // failures without reading each trace. One failing span fails the trace
+    // whatever the query matched, which is why this direction settles.
     const withStatus = (status: string): FetchLike => async () => {
       const body = searchBody();
       body.traces[0]!.spanSets[0]!.spans[0]!.attributes.push(attr('status', status));
@@ -266,9 +289,9 @@ describe('search: the window is seconds, and the rows are reduced', () => {
       return r!.isError;
     };
     expect(await row('error')).toBe(true);
-    // `ok` is an ANSWER, so the row is settled and nothing re-reads it.
-    expect(await row('ok')).toBe(false);
-    // `unset` is the absence of one, and leaves the row to the status pass.
+    // `ok` settles nothing on its own — it speaks for one matched span, not
+    // for the trace. Success needs the trace-wide count; see the test above.
+    expect(await row('ok')).toBeUndefined();
     expect(await row('unset')).toBeUndefined();
   });
 
diff --git a/apps/bff/src/logic/layers/preview.ts b/apps/bff/src/logic/layers/preview.ts
index cb2d5fb2..0f59e712 100644
--- a/apps/bff/src/logic/layers/preview.ts
+++ b/apps/bff/src/logic/layers/preview.ts
@@ -44,7 +44,10 @@ import type {
 } from './loader.js';
 
 const MAX_RAW_BYTES = 32_000;
-const MAX_METRICS = 32;
+/** A preview config comes from the browser, so every list is bounded. 64
+ *  clears the largest bundled set — network profiling ships 38 edge metrics —
+ *  with room to add a few while editing. */
+const MAX_METRICS = 64;
 const MAX_MQE_LEN = 512;
 
 function isMetricList(v: unknown): v is TopologyMetricDef[] {
@@ -188,13 +191,19 @@ function previewStores(raw: unknown): TracesConfig['stores'] | null {
   return Object.keys(out).length > 0 ? out : null;
 }
 
-/** `processTopology` block — the network-profiling edge-detail metric
- *  lists (ProcessRelation scope, client + server). */
+/** `processTopology` block — the network-profiling edge-detail metrics
+ *  (ProcessRelation scope). `edgeMetrics` is what the editor writes; the two
+ *  side lists are still accepted from a draft that predates it. Refusing the
+ *  new spelling made Preview fall back to the published template without
+ *  saying so, which reads as the edit having no effect. */
 export function parsePreviewProcessTopology(raw: string | undefined): ProcessTopologyConfig | null {
   const o = parseJson(raw);
   if (!o) return null;
-  if (o.edgeClientMetrics !== undefined && !isMetricList(o.edgeClientMetrics)) return null;
-  if (o.edgeServerMetrics !== undefined && !isMetricList(o.edgeServerMetrics)) return null;
-  if (o.edgeClientMetrics === undefined && o.edgeServerMetrics === undefined) return null;
+  for (const key of ['edgeMetrics', 'edgeClientMetrics', 'edgeServerMetrics']) {
+    if (o[key] !== undefined && !isMetricList(o[key])) return null;
+  }
+  if (o.edgeMetrics === undefined
+    && o.edgeClientMetrics === undefined
+    && o.edgeServerMetrics === undefined) return null;
   return o as unknown as ProcessTopologyConfig;
 }
diff --git a/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.test.ts b/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.test.ts
index a80001c6..774f206e 100644
--- a/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.test.ts
+++ b/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.test.ts
@@ -18,6 +18,8 @@
 import { describe, expect, it } from 'vitest';
 import { mount } from '@vue/test-utils';
 import { i18n } from '@/i18n';
+import type { ProcessTopologyConfig } from '@skywalking-horizon-ui/api-client';
+import { nextTick } from 'vue';
 import ProcessConfigEditor from './ProcessConfigEditor.vue';
 
 const config = () => ({
@@ -46,3 +48,21 @@ describe('ProcessConfigEditor MQE explorer coverage', () => {
     expect(wrapper.find('.mqe-run').exists()).toBe(false);
   });
 });
+
+describe('editing a config written in the older spelling', () => {
+  it('rewrites it to one list, so edits land on the draft', async () => {
+    // The component is REUSED when a layer changes or Reset to remote runs,
+    // so normalising on mount alone left the rows bound to copies and threw
+    // every edit away.
+    const wrapper = mount(ProcessConfigEditor, {
+      props: { config: config(), layerKey: 'GENERAL' },
+      global: { plugins: [i18n] },
+    });
+    await nextTick();
+    const written = wrapper.emitted('update:config')?.at(-1)?.[0] as ProcessTopologyConfig;
+    expect(written.edgeMetrics).toEqual([
+      { id: 'write', label: 'Write', mqe: 'process_relation_client_write_cpm', aggregation: 'avg', side: 'client' },
+      { id: 'read', label: 'Read', mqe: 'process_relation_server_read_cpm', aggregation: 'avg', side: 'server' },
+    ]);
+  });
+});
diff --git a/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.vue b/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.vue
index e7402392..1e2c7caf 100644
--- a/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.vue
+++ b/apps/ui/src/features/admin/layer-templates/ProcessConfigEditor.vue
@@ -22,7 +22,7 @@
   (no role / thresholds).
 -->
 <script setup lang="ts">
-import { computed, onMounted } from 'vue';
+import { computed, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import type {
   ProcessRelationSide,
@@ -51,11 +51,21 @@ function ensure(): ProcessTopologyConfig & { edgeMetrics: EdgeMetric[] } {
   }
   return config.value as ProcessTopologyConfig & { edgeMetrics: EdgeMetric[] };
 }
-onMounted(() => {
-  if (!props.readOnly) ensure();
-});
+const edgeMetrics = computed<EdgeMetric[]>(() => config.value?.edgeMetrics ?? resolveEdgeMetrics(config.value));
+
+/** Normalise whenever the config is REPLACED, not just on mount: switching
+ *  layer or resetting to remote reuses this component, and a replacement in
+ *  the old spelling would leave the rows bound to `resolveEdgeMetrics`'s
+ *  copies — edits landing on a clone the draft never sees. */
+watch(
+  config,
+  (c) => {
+    if (props.readOnly) return;
+    if (!c || !c.edgeMetrics) ensure();
+  },
+  { immediate: true },
+);
 
-const edgeMetrics = computed<EdgeMetric[]>(() => resolveEdgeMetrics(config.value));
 
 function blankMetric(taken: readonly TopologyMetricDef[]): TopologyMetricDef {
   const id = nextFreeId('metric_', taken.map((m) => m.id));
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index e4861000..03cf1666 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1036,6 +1036,7 @@
   "Side": "Seite",
   "neither — describes the exchange": "keine — beschreibt den Austausch",
   "Both sides": "Beide Seiten",
+  "{n} series": "{n} Reihen",
   "No shared metrics configured.": "Keine gemeinsamen Metriken konfiguriert.",
   "No service matches this": "Kein Service entspricht dem",
   "No services match": "Keine Services passen",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 0c6d64bf..2335a860 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1036,6 +1036,7 @@
   "Side": "Side",
   "neither — describes the exchange": "neither — describes the exchange",
   "Both sides": "Both sides",
+  "{n} series": "{n} series",
   "No shared metrics configured.": "No shared metrics configured.",
   "No service matches this": "No service matches this",
   "No services match": "No services match",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 25e6e066..19710159 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1036,6 +1036,7 @@
   "Side": "Lado",
   "neither — describes the exchange": "ninguno — describe el intercambio",
   "Both sides": "Ambos lados",
+  "{n} series": "{n} series",
   "No shared metrics configured.": "No hay métricas compartidas configuradas.",
   "No service matches this": "Ningún servicio coincide con esto",
   "No services match": "Ningún servicio coincide",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 8e7a687f..a93d9320 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1036,6 +1036,7 @@
   "Side": "Côté",
   "neither — describes the exchange": "aucun — décrit l’échange",
   "Both sides": "Les deux côtés",
+  "{n} series": "{n} séries",
   "No shared metrics configured.": "Aucune métrique partagée configurée.",
   "No service matches this": "Aucun service ne correspond",
   "No services match": "Aucun service ne correspond",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 4acba6e8..c60785a8 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1036,6 +1036,7 @@
   "Side": "側",
   "neither — describes the exchange": "どちらでもない — やり取りを表す",
   "Both sides": "両側",
+  "{n} series": "{n} 系列",
   "No shared metrics configured.": "共有メトリクスが設定されていません。",
   "No service matches this": "該当するサービスはありません",
   "No services match": "一致するサービスがありません",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index c46dffa0..f3e796e7 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1036,6 +1036,7 @@
   "Side": "측",
   "neither — describes the exchange": "해당 없음 — 교환 자체를 설명",
   "Both sides": "양측",
+  "{n} series": "{n}개 계열",
   "No shared metrics configured.": "구성된 공유 메트릭이 없습니다.",
   "No service matches this": "해당하는 서비스가 없습니다",
   "No services match": "일치하는 서비스가 없습니다",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 7cc47007..290d5848 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1036,6 +1036,7 @@
   "Side": "Lado",
   "neither — describes the exchange": "nenhum — descreve a troca",
   "Both sides": "Ambos os lados",
+  "{n} series": "{n} séries",
   "No shared metrics configured.": "Nenhuma métrica partilhada configurada.",
   "No service matches this": "Nenhum serviço corresponde a isto",
   "No services match": "Nenhum serviço corresponde",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 6e0a04b9..39508b91 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1036,6 +1036,7 @@
   "Side": "端",
   "neither — describes the exchange": "都不是 — 描述交互本身",
   "Both sides": "双端",
+  "{n} series": "{n} 条序列",
   "No shared metrics configured.": "未配置双端共享指标。",
   "No service matches this": "没有服务符合该条件",
   "No services match": "无匹配的服务",
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index b33d7fc1..f0d42293 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -509,10 +509,13 @@ function fmtTime(ms: number): string {
               <div v-for="m in relationMetrics[side]" :key="m.id" class="edge-widget sw-card">
                 <div class="ew-head">
                   <span class="ew-label">{{ m.label }}</span>
-                  <span class="ew-val mono">{{ fmtMetric(latestValue(m.values), m.unit) }}</span>
+                  <span v-if="!m.series?.length" class="ew-val mono">{{ fmtMetric(latestValue(m.values), m.unit) }}</span>
+                  <span v-else class="ew-val mono dim">{{ t('{n} series', { n: m.series.length }) }}</span>
                 </div>
                 <TimeChart
-                  :series="[{ label: m.label, data: m.values, unit: m.unit }]"
+                  :series="m.series?.length
+                    ? m.series.map((s) => ({ label: s.label || m.label, data: s.values, unit: m.unit }))
+                    : [{ label: m.label, data: m.values, unit: m.unit }]"
                   :x-labels="edgeXLabels"
                   :height="150"
                   :unit="m.unit"
diff --git a/packages/api-client/src/topology.ts b/packages/api-client/src/topology.ts
index 3bc95d65..818386fa 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -179,8 +179,13 @@ export interface ProcessRelationMetric {
   unit?: string;
   /** Per-bucket values over the duration window (MINUTE step). */
   values: Array<number | null>;
+  series?: Array<{ label?: string; values: Array<number | null> }>;
 }
 
+/** The further results of a multi-result metric, each under its own labels —
+ *  a percentile's ranks, a status-code metric's codes. Absent when the metric
+ *  answers with one series, whose values are in `values`. */
+
 /** Response of `POST /api/ebpf/network/process-relation-metrics`. */
 export interface ProcessRelationMetricsResponse {
   client: ProcessRelationMetric[];