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 75fcd32  feat(live-debug): summarise & diff MAL samples and output 
entities (#52)
75fcd32 is described below

commit 75fcd327ed2ada0efe1267d8ff026f4640fb930b
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Sat Jun 13 23:03:36 2026 +0800

    feat(live-debug): summarise & diff MAL samples and output entities (#52)
    
    The MAL live-debugger right pane rendered one row per captured sample
    (with its full label set) and one meter card per materialised output
    entity. A stage on a high-cardinality metric fanned out to a wall of
    rows, and a record that materialised a metric for several entities
    repeated near-identical entity cards — hiding what actually differs.
    
    - Input samples group by metric name into a collapsed summary
      (`<metric> · N samples · values=…`); expand for the per-sample labels.
    - Expanding a multi-member group opens in diff mode by default: labels
      shared by every member collapse into a dimmed "common" block and only
      the differing ones are highlighted (a "diff" toggle shows the full
      list). The common/varying split is computed across the whole group.
    - A record's run of output samples for one metric folds into one block
      (shared metric / function / timeBucket header + `N outputs · values=…`)
      that diffs the entity fields generically — whichever differ surface,
      not a fixed field — with each output's value beside it.
    - Long fractional values (rate()/avg()) are trimmed for display so they
      stop overflowing the value column; integer counters stay exact and the
      precise value is available on hover.
    
    Rendered detail is capped per group with a "+ N more rows" note and the
    diff scan runs lazily. Pure presentation over the existing debug wire
    shape — no OAP query change. New UI strings translated across all 8
    locales.
---
 CHANGELOG.md                                       |   7 +
 .../src/features/operate/live-debug/DebugMal.vue   | 717 ++++++++++++++++++---
 apps/ui/src/features/operate/live-debug/payload.ts |  16 +
 apps/ui/src/i18n/locales/de.json                   |   6 +
 apps/ui/src/i18n/locales/en.json                   |   6 +
 apps/ui/src/i18n/locales/es.json                   |   6 +
 apps/ui/src/i18n/locales/fr.json                   |   6 +
 apps/ui/src/i18n/locales/ja.json                   |   6 +
 apps/ui/src/i18n/locales/ko.json                   |   6 +
 apps/ui/src/i18n/locales/pt.json                   |   6 +
 apps/ui/src/i18n/locales/zh-CN.json                |   6 +
 11 files changed, 714 insertions(+), 74 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fb991c..1ab442b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -104,6 +104,13 @@ The version line is shared by every package in the 
monorepo (apps + shared packa
 - A layer's Instance or Endpoint page no longer hangs on a perpetual "Reading 
data…" when the selected service reports no instances or endpoints (or a search 
matches nothing). It now shows the empty picker and renders the metric widgets 
in their normal "no data" state, so the layout stays visible and ready for 
services that do report them.
 - **Clearer cluster boundaries on every topology view.** The dashed grouping 
boxes — namespaces on the service map, per-service boxes on the instance map, 
role/tier clusters on the Deployment tab — now draw with a bolder, brighter 
dashed border and a fully transparent background, so the boundary reads clearly 
on every theme (light themes included) instead of fading into the canvas. The 
Deployment tab also packs its cluster boxes evenly: boxes sit at a uniform 
spacing with no dead corrido [...]
 
+### Live debugger
+
+- **MAL sample groups.** A captured step that fans out to many samples no 
longer dumps every label set on screen: the samples are grouped by metric name 
into a one-line summary — `<metric> · N samples · values=…` — and you expand 
only the groups you care about to see each sample's full labels. Groups are 
collapsed by default.
+- **Diff is the default when a group is expanded.** A multi-sample group opens 
straight into diff view: the labels shared by every sample collapse into a 
dimmed "common" block and only the labels that differ are highlighted per 
sample — so it is immediate which label distinguishes each one (e.g. 
`node_role` / `pod_name`) and what value it maps to. A **diff** toggle beside 
the group's header switches back to the full per-sample label list. The 
"common" set is computed across the whole gro [...]
+- **Multiple output entities collapse the same way.** When a record 
materialises one metric for several entities (e.g. a per-endpoint write rate 
over `sw_metricsMinute` / `sw_metricsHour` / `sw_metricsDay`), the repeated 
meter cards fold into one block: a shared header (metric / function / time 
bucket), a `N outputs · values=…` summary, and a diff that surfaces only the 
entity fields that actually differ — whichever they are, not a fixed field — 
with each output's value beside it.
+- **Readable sample values.** Long fractional values from `rate()` / `avg()` 
(e.g. `57.0333333333…`) are trimmed to a few significant digits for display so 
they stop overflowing the value column; integer counters still render exact, 
and the precise value stays available on hover.
+
 ## 0.6.0
 
 This release is the production-readiness pass for Horizon UI: every page now 
renders correctly across the eight supported languages on non-UTC OAP 
deployments, with deliberate caps and validation on the load surfaces that 
operators reach. The pillars below describe the operator- visible result.
diff --git a/apps/ui/src/features/operate/live-debug/DebugMal.vue 
b/apps/ui/src/features/operate/live-debug/DebugMal.vue
index 1b3ab5e..e2482f8 100644
--- a/apps/ui/src/features/operate/live-debug/DebugMal.vue
+++ b/apps/ui/src/features/operate/live-debug/DebugMal.vue
@@ -47,7 +47,12 @@ import { useDebugSession } from 
'@/features/operate/live-debug/useDebugSession';
 import { useDebugHistory, type HistoryEntry } from 
'@/features/operate/live-debug/useDebugHistory';
 import Btn from '@/components/primitives/Btn.vue';
 import DebugView from './DebugView.vue';
-import { isMalOutputPayload, isMalSamplesPayload, shortHash } from 
'./payload.js';
+import {
+  formatSampleValue,
+  isMalOutputPayload,
+  isMalSamplesPayload,
+  shortHash,
+} from './payload.js';
 import {
   DEFAULT_RETENTION_MINUTES,
   MS_PER_MINUTE,
@@ -252,12 +257,21 @@ interface MalSampleRow {
   /** Previous sample's payload — what this step received as input.
    *  Null on the first sample (no upstream step). */
   before: MalBefore | null;
+  /** Set only on the synthetic anchor row that stands in for a run of
+   *  ≥2 `output` samples in one record — collapses the repeated meter
+   *  cards into one diff-aware summary (same metric / function, one
+   *  materialised entity each). */
+  outputGroup?: OutputGroup | null;
 }
 
 interface MalRecordView {
   rec: SessionRecord;
   recordIdx: number;
   rows: MalSampleRow[];
+  /** Rows as rendered: the non-output chain followed by either the lone
+   *  output row (rendered as a full meter card) or one anchor row
+   *  carrying the merged `outputGroup`. */
+  displayRows: MalSampleRow[];
 }
 
 interface MalNodeView extends NodeSlice {
@@ -288,6 +302,17 @@ const displaySession = computed<SessionResponse | null>(
 const selectedRow = ref<MalSampleRow | null>(null);
 const expandedEntities = ref<Set<string>>(new Set());
 const foldedRecords = ref<Set<string>>(new Set());
+/** Per-sample-group view state. A "group" is the set of samples sharing
+ *  a metric name within one stage; collapsed by default so a stage that
+ *  emits hundreds of rows shows a one-line summary first. When a
+ *  multi-sample group is expanded it lands in diff mode by default
+ *  (the shared labels collapse, only the differing ones show);
+ *  `fullLabelGroups` tracks the groups where the operator opted back
+ *  out to the full per-sample label list. Both are keyed by
+ *  `groupKey(...)` and reset on historical load (TDZ guard — see the
+ *  block comment above). */
+const expandedGroups = ref<Set<string>>(new Set());
+const fullLabelGroups = ref<Set<string>>(new Set());
 
 function loadHistorical(entry: HistoryEntry): void {
   historicalEntry.value = entry;
@@ -301,6 +326,8 @@ function loadHistorical(entry: HistoryEntry): void {
   selectedRow.value = null;
   expandedEntities.value = new Set();
   foldedRecords.value = new Set();
+  expandedGroups.value = new Set();
+  fullLabelGroups.value = new Set();
 }
 
 function clearHistorical(): void {
@@ -431,7 +458,25 @@ const nodeViews = computed<MalNodeView[]>(() => {
         prevSamples = thisSamples;
         prevOutput = thisOutput;
       }
-      return { rec, recordIdx: ri, rows };
+      // A MAL record's chain ends with one `output` sample per
+      // materialised entity. When more than one fires they repeat the
+      // same meter card with only the entity differing — collapse that
+      // run into a single anchor carrying an `outputGroup` so the right
+      // pane summarises them the same way input sample groups do.
+      const outputRows = rows.filter((r) => r.output !== null);
+      let displayRows = rows;
+      if (outputRows.length > 1) {
+        const chainRows = rows.filter((r) => r.output === null);
+        const anchor: MalSampleRow = {
+          ...outputRows[0]!,
+          outputGroup: buildOutputGroup(
+            `${nKey}#${ri}#outputs`,
+            outputRows.map((r) => r.output!),
+          ),
+        };
+        displayRows = [...chainRows, anchor];
+      }
+      return { rec, recordIdx: ri, rows, displayRows };
     });
     return { ...n, recordViews };
   });
@@ -478,7 +523,12 @@ interface FlatRow {
   value: number;
 }
 
-const ROWS_CAP = 50;
+// Per-group rendering caps. A stage can emit hundreds of samples; the
+// collapsed summary stays cheap, while an expanded group renders at
+// most GROUP_DETAIL_CAP detail rows (with a "+ N more" note). The
+// summary line previews the first SUMMARY_VALUE_CAP values inline.
+const GROUP_DETAIL_CAP = 50;
+const SUMMARY_VALUE_CAP = 8;
 
 /** Total leaf-row count across both shapes (flat per-stage items, or
  *  nested per-family items on the file-level filter probe). Drives
@@ -499,31 +549,253 @@ function countRows(p: MalSamplesPayload | null): number {
   return p.samples ?? 0;
 }
 
-/** Flatten any payload (flat or nested) to up to ROWS_CAP `{name,
- *  labels, value}` rows for the right-pane RowsTable. */
-function flattenRows(p: MalSamplesPayload | null): FlatRow[] {
-  if (!p || p.empty === true || !p.items || p.items.length === 0) return [];
-  const out: FlatRow[] = [];
+/** One metric-name group within a stage's payload: every leaf row that
+ *  shares `name`. `count` is the exact total; `rows` caps at
+ *  GROUP_DETAIL_CAP for rendering; `diff` is precomputed only when the
+ *  group has >1 sample (the only case the diff toggle is offered). */
+interface SampleGroup {
+  /** Stage-scoped expand/diff toggle key — see `groupKey`. */
+  key: string;
+  name: string;
+  count: number;
+  rows: FlatRow[];
+  diff?: GroupDiff;
+}
+
+interface DiffCell {
+  k: string;
+  v: string;
+  /** Key present on a sibling sample but missing from this one. */
+  absent: boolean;
+}
+
+interface GroupDiff {
+  /** Labels identical (key AND value) across every sample — the shared
+   *  context, rendered once and dimmed. */
+  common: EntityField[];
+  /** Per-sample view carrying only the labels that vary across the
+   *  group, so operators see at a glance what distinguishes each row. */
+  rows: Array<{ value: number; diffs: DiffCell[] }>;
+}
+
+/** A run of ≥2 `output` samples collapsed into one block: same metric /
+ *  function (the shared meter header), one materialised entity each. The
+ *  entity is diffed field-by-field — whichever fields differ surface
+ *  (not just `endpointName`), and each output keeps its own value — so
+ *  no assumption is baked in about what distinguishes the outputs. */
+interface OutputGroup {
+  key: string;
+  metric: string;
+  valueType: string;
+  timeBucket: number;
+  count: number;
+  /** Full-mode rows: every non-null entity field + the value. */
+  rows: OutputRow[];
+  /** Entity fields identical across all outputs (the shared context). */
+  common: EntityField[];
+  /** Diff-mode rows: only the entity fields that vary, + the value. */
+  diffRows: Array<{ diffs: DiffCell[]; value: string; valueRaw: string }>;
+}
+
+interface OutputRow {
+  fields: EntityField[];
+  value: string;
+  valueRaw: string;
+}
+
+/** Walk a payload (flat per-stage items, or nested per-family items on
+ *  the file-level filter probe) yielding every `{name, labels, value}`
+ *  leaf. Mirrors `countRows`' shape detection. */
+function* leafRows(p: MalSamplesPayload | null): Generator<FlatRow> {
+  if (!p || p.empty === true || !p.items || p.items.length === 0) return;
   const first = p.items[0]! as MalSampleItem & MalSamplesPayload;
   const isFlat = typeof first.name === 'string' && typeof first.value === 
'number';
   if (isFlat) {
     for (const it of p.items as MalSampleItem[]) {
-      if (out.length >= ROWS_CAP) break;
-      out.push({ name: it.name, labels: it.labels ?? {}, value: it.value });
+      yield { name: it.name, labels: it.labels ?? {}, value: it.value };
     }
   } else {
     for (const fam of p.items as MalSamplesPayload[]) {
       if (fam.empty === true) continue;
       for (const it of (fam.items ?? []) as MalSampleItem[]) {
-        if (out.length >= ROWS_CAP) break;
         if (typeof it.name !== 'string') continue;
-        out.push({ name: it.name, labels: it.labels ?? {}, value: it.value });
+        yield { name: it.name, labels: it.labels ?? {}, value: it.value };
+      }
+    }
+  }
+}
+
+/** Classify keys across N field maps into common (present in ALL with
+ *  one identical value) vs varying. Computed over the FULL set so the
+ *  "common" claim holds even when only a capped subset of rows renders
+ *  below. Generic over any label/entity field map — no field is special-
+ *  cased, so the differing field surfaces whatever it is. */
+function diffLabels(maps: Array<Record<string, string>>): {
+  common: EntityField[];
+  varying: string[];
+} {
+  const stat = new Map<string, { first: string; constant: boolean; seen: 
number }>();
+  for (const m of maps) {
+    for (const k in m) {
+      const v = m[k]!;
+      const s = stat.get(k);
+      if (!s) stat.set(k, { first: v, constant: true, seen: 1 });
+      else {
+        s.seen++;
+        if (v !== s.first) s.constant = false;
       }
     }
   }
+  const common: EntityField[] = [];
+  const varying: string[] = [];
+  for (const [k, s] of [...stat.entries()].sort((a, b) => 
a[0].localeCompare(b[0]))) {
+    if (s.seen === maps.length && s.constant) common.push({ k, v: s.first });
+    else varying.push(k);
+  }
+  return { common, varying };
+}
+
+/** Per-row diff cells for a varying-key set against one row's map. A key
+ *  present on a sibling but missing here surfaces as `absent`. */
+function diffCells(varying: string[], m: Record<string, string>): DiffCell[] {
+  return varying.map((k) => {
+    const v = m[k];
+    return v === undefined ? { k, v: '', absent: true } : { k, v, absent: 
false };
+  });
+}
+
+/** Group one stage's leaves by metric name for the right-pane summary.
+ *  Detail rows cap at GROUP_DETAIL_CAP; `count` stays exact. The diff's
+ *  common/varying split is computed over EVERY leaf (not just the capped
+ *  rows), so a label uniform across the first 50 but varying later is
+ *  not mis-classified as common. Each group carries a stage-scoped
+ *  `key` so its toggles stay independent across stages/records/nodes. */
+function stageGroups(row: MalSampleRow, stageIdx: number): SampleGroup[] {
+  const map = new Map<string, { group: SampleGroup; maps: Array<Record<string, 
string>> }>();
+  for (const r of leafRows(row.samples)) {
+    let e = map.get(r.name);
+    if (!e) {
+      e = {
+        group: { key: groupKey(row, stageIdx, r.name), name: r.name, count: 0, 
rows: [] },
+        maps: [],
+      };
+      map.set(r.name, e);
+    }
+    e.group.count++;
+    if (e.group.rows.length < GROUP_DETAIL_CAP) e.group.rows.push(r);
+    e.maps.push(r.labels);
+  }
+  const groups: SampleGroup[] = [];
+  for (const { group, maps } of map.values()) {
+    // The diff only renders for an expanded group in diff mode, and the
+    // full-set scan is the costly part on high-cardinality stages — so
+    // compute it lazily here, skipping collapsed / opted-out groups
+    // (the call re-runs reactively when a toggle flips those refs).
+    if (isGroupExpanded(group.key) && isDiffMode(group.key, group.count)) {
+      const { common, varying } = diffLabels(maps);
+      group.diff = {
+        common,
+        rows: group.rows.map((r) => ({ value: r.value, diffs: 
diffCells(varying, r.labels) })),
+      };
+    }
+    groups.push(group);
+  }
+  return groups;
+}
+
+/** First few values of a sample group, comma-joined, for the summary
+ *  line — e.g. `438.95, 57.0333, 0.6667`. Appends `…` when more remain. */
+function summaryValues(g: SampleGroup): string {
+  const shown = g.rows.slice(0, SUMMARY_VALUE_CAP).map((r) => 
formatSampleValue(r.value));
+  if (g.count > shown.length) shown.push('…');
+  return shown.join(', ');
+}
+
+/** Non-null entity fields of one output, as a flat record, for diffing. */
+function entityRecord(entity: string): Record<string, string> {
+  const out: Record<string, string> = {};
+  for (const f of entityFields(entity, false)) out[f.k] = f.v;
   return out;
 }
 
+/** Collapse a run of `output` samples (same metric / function) into one
+ *  diff-aware group. The entity is diffed field-by-field via the same
+ *  generic helper the sample groups use — whatever differs surfaces
+ *  (endpointName here, but any field for other scopes), with each
+ *  output's value kept in its own column. */
+function buildOutputGroup(key: string, outputs: MalOutputPayload[]): 
OutputGroup {
+  const first = outputs[0]!;
+  const maps = outputs.map((o) => entityRecord(o.entity));
+  const { common, varying } = diffLabels(maps);
+  const valueOf = (o: MalOutputPayload): string =>
+    o.value === undefined ? '—' : formatOutputValue(o.value);
+  const rawOf = (o: MalOutputPayload): string =>
+    o.value === undefined ? '' : outputValueRaw(o.value);
+  // Render at most GROUP_DETAIL_CAP rows (with a "+ N more" footer),
+  // mirroring sample groups — `common`/`varying` above is already over
+  // the full set, so the cap only bounds the rendered detail.
+  const shown = outputs.slice(0, GROUP_DETAIL_CAP);
+  return {
+    key,
+    metric: first.metric,
+    valueType: first.valueType,
+    timeBucket: first.timeBucket,
+    count: outputs.length,
+    rows: shown.map((o) => ({
+      fields: entityFields(o.entity, false),
+      value: valueOf(o),
+      valueRaw: rawOf(o),
+    })),
+    common,
+    diffRows: shown.map((o, i) => ({
+      diffs: diffCells(varying, maps[i]!),
+      value: valueOf(o),
+      valueRaw: rawOf(o),
+    })),
+  };
+}
+
+/** First few output values, comma-joined, for the summary line. */
+function outputSummaryValues(g: OutputGroup): string {
+  const shown = g.rows.slice(0, SUMMARY_VALUE_CAP).map((r) => r.value);
+  if (g.count > shown.length) shown.push('…');
+  return shown.join(', ');
+}
+
+// ── Per-group expand / diff state ───────────────────────────────────
+// `expandedGroups` / `fullLabelGroups` are declared above `loadHistorical`
+// (TDZ guard). Keyed by stage + metric name (or `…#outputs`) so each
+// group toggles independently and state survives re-renders across polls.
+
+function groupKey(row: MalSampleRow, stageIdx: number, name: string): string {
+  return `${row.nodeKey}#${row.recordIdx}#${stageIdx}#${name}`;
+}
+
+function isGroupExpanded(key: string): boolean {
+  return expandedGroups.value.has(key);
+}
+
+function toggleGroup(key: string): void {
+  const next = new Set(expandedGroups.value);
+  if (next.has(key)) next.delete(key);
+  else next.add(key);
+  expandedGroups.value = next;
+}
+
+/** Diff is the default view for any multi-member group — a sample group
+ *  with >1 sample or an output group with >1 entity; the operator opts
+ *  out into the full per-member list via the toggle. */
+function isDiffMode(key: string, count: number): boolean {
+  return count > 1 && !fullLabelGroups.value.has(key);
+}
+
+function toggleGroupDiff(key: string): void {
+  const next = new Set(fullLabelGroups.value);
+  if (next.has(key)) next.delete(key);
+  else next.add(key);
+  fullLabelGroups.value = next;
+}
+
 function labelLines(labels: Record<string, string>): string[] {
   const entries = Object.entries(labels);
   if (entries.length === 0) return [];
@@ -685,11 +957,22 @@ function toggleEntity(row: MalSampleRow): void {
  *  step (each labeled key has already been rendered there as its own
  *  row with full label tuples). */
 function formatOutputValue(v: number | string | Record<string, number>): 
string {
-  if (typeof v === 'number') return String(v);
+  if (typeof v === 'number') return formatSampleValue(v);
   if (typeof v === 'string') return v;
   const entries = Object.entries(v);
   if (entries.length === 0) return '{}';
-  return entries.map(([k, n]) => `${k}=${n}`).join(' · ');
+  return entries.map(([k, n]) => `${k}=${formatSampleValue(n)}`).join(' · ');
+}
+
+/** Untrimmed output value for the cell `title` — `formatOutputValue`
+ *  rounds long floats for display, so the precise number stays reachable
+ *  on hover. */
+function outputValueRaw(v: number | string | Record<string, number>): string {
+  if (typeof v === 'number') return String(v);
+  if (typeof v === 'string') return v;
+  return Object.entries(v)
+    .map(([k, n]) => `${k}=${n}`)
+    .join(' · ');
 }
 
 </script>
@@ -860,7 +1143,7 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
             </div>
           </dl>
           <div class="mal__stages">
-            <template v-for="(row, idx) in rv.rows" 
:key="`${rv.recordIdx}-${idx}`">
+            <template v-for="(row, idx) in rv.displayRows" 
:key="`${rv.recordIdx}-${idx}`">
               <div
                 class="mal__stagelbl"
                 :class="{
@@ -876,7 +1159,7 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
                 <div class="mal__stageio">
                   {{ inCountStr(row) }}
                   <span class="mal__arrow">→</span>
-                  <span :class="{ 'mal__warn': isDrop(row) }">{{ 
outCountStr(row) }}</span>
+                  <span :class="{ 'mal__warn': isDrop(row) }">{{ 
row.outputGroup ? row.outputGroup.count : outCountStr(row) }}</span>
                   <span
                     v-if="!row.sample.continueOn"
                     class="mal__stopflag"
@@ -885,7 +1168,7 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
                 </div>
               </div>
               <div class="mal__rail">
-                <div class="mal__railline" :class="{ 'mal__railline--last': 
idx === rv.rows.length - 1 }"></div>
+                <div class="mal__railline" :class="{ 'mal__railline--last': 
idx === rv.displayRows.length - 1 }"></div>
                 <div
                   class="mal__raildot"
                   :class="{
@@ -896,8 +1179,93 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
                 ></div>
               </div>
               <div class="mal__stageright" :class="{ 
'mal__stageright--selected': selectedRow === row }">
-                <template v-if="row.output">
-                  <!-- Output payload renders verbatim — every field
+                <template v-if="row.outputGroup">
+                  <!-- A run of ≥2 output entities for one metric. The
+                       meter header (metric / function / timeBucket) is
+                       shared; the collapsible block summarises the N
+                       entities and, on expand, diffs them field-by-field
+                       — whichever entity field differs surfaces (not just
+                       endpointName), each output keeping its own value. -->
+                  <div class="mal__meter">
+                    <div><span class="mal__mlbl">{{ t('metric') }}</span><span 
class="mal__mval">{{ row.outputGroup.metric }}</span></div>
+                    <div><span class="mal__mlbl">{{ t('function') 
}}</span><span class="mal__mval">{{ row.outputGroup.valueType }}</span></div>
+                    <div><span class="mal__mlbl">timeBucket</span><span 
class="mal__mval">{{ row.outputGroup.timeBucket }}</span></div>
+                  </div>
+                  <div class="mal__groups">
+                    <div class="mal__group">
+                      <button
+                        type="button"
+                        class="mal__grouphead"
+                        :class="{ 'mal__grouphead--open': 
isGroupExpanded(row.outputGroup.key) }"
+                        @click="toggleGroup(row.outputGroup.key)"
+                      >
+                        <span class="mal__groupcaret">{{ 
isGroupExpanded(row.outputGroup.key) ? '▾' : '▸' }}</span>
+                        <code class="mal__groupname">{{ t('{n} outputs', { n: 
row.outputGroup.count }) }}</code>
+                        <span class="mal__groupvals"><span 
class="mal__groupvalsk">{{ t('values') }}=</span>{{ 
outputSummaryValues(row.outputGroup) }}</span>
+                      </button>
+
+                      <div v-if="isGroupExpanded(row.outputGroup.key)" 
class="mal__groupbody">
+                        <table class="mal__gtable">
+                          <colgroup>
+                            <col class="mal__gcollabels" />
+                            <col class="mal__gcolval" />
+                          </colgroup>
+                          <thead>
+                            <tr>
+                              <th class="mal__gthlabels">
+                                <span>{{ t('entity') }}</span>
+                                <button
+                                  type="button"
+                                  class="mal__difftog"
+                                  :class="{ 'mal__difftog--active': 
isDiffMode(row.outputGroup.key, row.outputGroup.count) }"
+                                  :title="t('show only differing labels')"
+                                  @click="toggleGroupDiff(row.outputGroup.key)"
+                                >{{ t('diff') }}</button>
+                              </th>
+                              <th class="mal__rtval">{{ t('value') }}</th>
+                            </tr>
+                          </thead>
+                          <tbody>
+                            <template v-if="isDiffMode(row.outputGroup.key, 
row.outputGroup.count)">
+                              <tr class="mal__diffcommonrow">
+                                <td colspan="2">
+                                  <div class="mal__diffcommonhd">{{ t('common 
({n})', { n: row.outputGroup.common.length }) }}</div>
+                                  <div class="mal__diffcommons">
+                                    <span v-if="row.outputGroup.common.length 
=== 0" class="mal__dim">—</span>
+                                    <span v-for="c in row.outputGroup.common" 
:key="c.k" class="mal__diffcommon">{{ c.k }}={{ c.v }}</span>
+                                  </div>
+                                </td>
+                              </tr>
+                              <tr v-for="(dr, j) in row.outputGroup.diffRows" 
:key="j">
+                                <td class="mal__rtlabels">
+                                  <span v-if="dr.diffs.length === 0" 
class="mal__dim">—</span>
+                                  <span v-for="d in dr.diffs" :key="d.k" 
class="mal__diffcell"><span class="mal__diffk">{{ d.k }}</span>=<span 
v-if="d.absent" class="mal__dim">∅</span><span v-else class="mal__diffv">{{ d.v 
}}</span></span>
+                                </td>
+                                <td class="mal__rtval" :title="dr.valueRaw">{{ 
dr.value }}</td>
+                              </tr>
+                            </template>
+                            <template v-else>
+                              <tr v-for="(o, j) in row.outputGroup.rows" 
:key="j">
+                                <td class="mal__rtlabels">
+                                  <span v-if="o.fields.length === 0" 
class="mal__dim">—</span>
+                                  <div v-for="f in o.fields" :key="f.k" 
class="mal__rtlabel">{{ f.k }}={{ f.v }}</div>
+                                </td>
+                                <td class="mal__rtval" :title="o.valueRaw">{{ 
o.value }}</td>
+                              </tr>
+                            </template>
+                            <tr v-if="row.outputGroup.count > 
row.outputGroup.rows.length">
+                              <td colspan="2" class="mal__rtmore">
+                                {{ t('+ {n} more rows', { n: 
row.outputGroup.count - row.outputGroup.rows.length }) }}
+                              </td>
+                            </tr>
+                          </tbody>
+                        </table>
+                      </div>
+                    </div>
+                  </div>
+                </template>
+                <template v-else-if="row.output">
+                  <!-- Single output payload renders verbatim — every field
                        present on the wire shows up; nothing is
                        inferred from upstream stages. The materialised
                        `value` is optional on the wire (only newer OAP
@@ -908,7 +1276,10 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
                     <div><span class="mal__mlbl">{{ t('function') 
}}</span><span class="mal__mval">{{ row.output.valueType }}</span></div>
                     <div v-if="row.output.value !== undefined">
                       <span class="mal__mlbl">{{ t('value') }}</span>
-                      <span class="mal__mval mal__mvalnum">{{ 
formatOutputValue(row.output.value) }}</span>
+                      <span
+                        class="mal__mval mal__mvalnum"
+                        :title="outputValueRaw(row.output.value)"
+                      >{{ formatOutputValue(row.output.value) }}</span>
                     </div>
                     <div><span class="mal__mlbl">timeBucket</span><span 
class="mal__mval">{{ row.output.timeBucket }}</span></div>
                   </div>
@@ -948,38 +1319,88 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
                   <div class="mal__rtempty">{{ t('empty family · 0 rows') 
}}</div>
                 </template>
                 <template v-else>
-                  <table class="mal__rtable">
-                    <colgroup>
-                      <col class="mal__rtcolname" />
-                      <col class="mal__rtcollabels" />
-                      <col class="mal__rtcolval" />
-                    </colgroup>
-                    <thead>
-                      <tr>
-                        <th>{{ t('name') }}</th>
-                        <th>{{ t('labels') }}</th>
-                        <th class="mal__rtval">{{ t('value') }}</th>
-                      </tr>
-                    </thead>
-                    <tbody>
-                      <tr v-for="(it, j) in flattenRows(row.samples)" :key="j">
-                        <td class="mal__rtname">{{ it.name }}</td>
-                        <td class="mal__rtlabels">
-                          <span v-if="labelLines(it.labels).length === 0" 
class="mal__dim">—</span>
-                          <div v-for="line in labelLines(it.labels)" 
:key="line" class="mal__rtlabel">{{ line }}</div>
-                        </td>
-                        <td class="mal__rtval">{{ it.value }}</td>
-                      </tr>
-                      <tr v-if="flattenRows(row.samples).length === 0">
-                        <td colspan="3" class="mal__rtempty">{{ t('no rows in 
payload') }}</td>
-                      </tr>
-                      <tr v-if="countRows(row.samples) > 
flattenRows(row.samples).length">
-                        <td colspan="3" class="mal__rtmore">
-                          {{ t('+ {n} more rows', { n: countRows(row.samples) 
- flattenRows(row.samples).length }) }}
-                        </td>
-                      </tr>
-                    </tbody>
-                  </table>
+                  <div v-if="countRows(row.samples) === 0" 
class="mal__rtempty">{{ t('no rows in payload') }}</div>
+                  <div v-else class="mal__groups">
+                    <!-- One collapsible block per metric name. Collapsed
+                         (default) is a one-line summary so a stage that
+                         fans out to hundreds of samples doesn't dump its
+                         full label set on screen. Expand reveals the
+                         per-sample labels; for multi-sample groups the
+                         `diff` toggle (beside the LABELS header) hides the
+                         shared labels and highlights only what differs. -->
+                    <div v-for="g in stageGroups(row, idx)" :key="g.name" 
class="mal__group">
+                      <button
+                        type="button"
+                        class="mal__grouphead"
+                        :class="{ 'mal__grouphead--open': 
isGroupExpanded(g.key) }"
+                        @click="toggleGroup(g.key)"
+                      >
+                        <span class="mal__groupcaret">{{ 
isGroupExpanded(g.key) ? '▾' : '▸' }}</span>
+                        <code class="mal__groupname">{{ g.name }}</code>
+                        <span class="mal__groupct">{{ t('{n} samples', { n: 
g.count }) }}</span>
+                        <span class="mal__groupvals"><span 
class="mal__groupvalsk">{{ t('values') }}=</span>{{ summaryValues(g) }}</span>
+                      </button>
+
+                      <div v-if="isGroupExpanded(g.key)" 
class="mal__groupbody">
+                        <table class="mal__gtable">
+                          <colgroup>
+                            <col class="mal__gcollabels" />
+                            <col class="mal__gcolval" />
+                          </colgroup>
+                          <thead>
+                            <tr>
+                              <th class="mal__gthlabels">
+                                <span>{{ t('labels') }}</span>
+                                <button
+                                  v-if="g.count > 1"
+                                  type="button"
+                                  class="mal__difftog"
+                                  :class="{ 'mal__difftog--active': 
isDiffMode(g.key, g.count) }"
+                                  :title="t('show only differing labels')"
+                                  @click="toggleGroupDiff(g.key)"
+                                >{{ t('diff') }}</button>
+                              </th>
+                              <th class="mal__rtval">{{ t('value') }}</th>
+                            </tr>
+                          </thead>
+                          <tbody>
+                            <template v-if="g.diff && isDiffMode(g.key, 
g.count)">
+                              <tr class="mal__diffcommonrow">
+                                <td colspan="2">
+                                  <div class="mal__diffcommonhd">{{ t('common 
({n})', { n: g.diff.common.length }) }}</div>
+                                  <div class="mal__diffcommons">
+                                    <span v-if="g.diff.common.length === 0" 
class="mal__dim">—</span>
+                                    <span v-for="c in g.diff.common" 
:key="c.k" class="mal__diffcommon">{{ c.k }}={{ c.v }}</span>
+                                  </div>
+                                </td>
+                              </tr>
+                              <tr v-for="(dr, j) in g.diff.rows" :key="j">
+                                <td class="mal__rtlabels">
+                                  <span v-if="dr.diffs.length === 0" 
class="mal__dim">—</span>
+                                  <span v-for="d in dr.diffs" :key="d.k" 
class="mal__diffcell"><span class="mal__diffk">{{ d.k }}</span>=<span 
v-if="d.absent" class="mal__dim">∅</span><span v-else class="mal__diffv">{{ d.v 
}}</span></span>
+                                </td>
+                                <td class="mal__rtval" 
:title="String(dr.value)">{{ formatSampleValue(dr.value) }}</td>
+                              </tr>
+                            </template>
+                            <template v-else>
+                              <tr v-for="(it, j) in g.rows" :key="j">
+                                <td class="mal__rtlabels">
+                                  <span v-if="labelLines(it.labels).length === 
0" class="mal__dim">—</span>
+                                  <div v-for="line in labelLines(it.labels)" 
:key="line" class="mal__rtlabel">{{ line }}</div>
+                                </td>
+                                <td class="mal__rtval" 
:title="String(it.value)">{{ formatSampleValue(it.value) }}</td>
+                              </tr>
+                            </template>
+                            <tr v-if="g.count > g.rows.length">
+                              <td colspan="2" class="mal__rtmore">
+                                {{ t('+ {n} more rows', { n: g.count - 
g.rows.length }) }}
+                              </td>
+                            </tr>
+                          </tbody>
+                        </table>
+                      </div>
+                    </div>
+                  </div>
                 </template>
               </div>
             </template>
@@ -1365,36 +1786,113 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
   background: var(--rr-bg3);
 }
 
-.mal__rtable {
+/* ── Per-metric sample groups (right pane) ──────────────────────── */
+
+.mal__groups {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.mal__group {
+  border: 1px solid var(--rr-border);
+  background: var(--rr-bg);
+}
+
+.mal__grouphead {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  width: 100%;
+  padding: 6px 8px;
+  background: transparent;
+  border: none;
+  border-left: 2px solid transparent;
+  color: var(--rr-ink);
+  font-family: var(--rr-font-mono);
+  font-size: var(--sw-fs-base);
+  text-align: left;
+  cursor: pointer;
+}
+
+.mal__grouphead:hover {
+  background: var(--rr-bg2);
+}
+
+.mal__grouphead--open {
+  background: var(--rr-bg2);
+  border-left-color: var(--rr-accent, var(--rr-active));
+}
+
+.mal__groupcaret {
+  flex: none;
+  width: 12px;
+  color: var(--rr-dim);
+  font-size: var(--sw-fs-sm);
+}
+
+.mal__groupname {
+  flex: none;
+  color: var(--rr-heading);
+  font-family: var(--rr-font-mono);
+  background: transparent;
+  padding: 0;
+  word-break: break-all;
+}
+
+.mal__groupct {
+  flex: none;
+  color: var(--rr-dim);
+  font-size: var(--sw-fs-sm);
+  white-space: nowrap;
+}
+
+/* Values preview takes the remaining width and ellipsises — the summary
+   line never wraps or pushes the card wider. */
+.mal__groupvals {
+  flex: 1 1 auto;
+  min-width: 0;
+  color: var(--rr-accent, var(--rr-active));
+  font-size: var(--sw-fs-sm);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.mal__groupvalsk {
+  color: var(--sw-fg-3);
+}
+
+.mal__groupbody {
+  border-top: 1px solid var(--rr-border);
+}
+
+.mal__gtable {
   width: 100%;
   border-collapse: collapse;
   font-family: var(--rr-font-mono);
   font-size: var(--sw-fs-base);
-  border: 1px solid var(--rr-border);
   background: var(--rr-bg);
-  /* Fixed layout so colgroup widths bind consistently across every
-     stage's table — keeps `name` / `labels` / `value` aligned vertically
-     down the record card regardless of per-step content length. */
+  /* Fixed layout binds the colgroup widths; the value column is sized
+     and wraps so a long float can never push the table past the pane. */
   table-layout: fixed;
 }
 
-.mal__rtcolname {
-  width: 280px;
+.mal__gcollabels {
+  width: auto;
 }
 
-.mal__rtcolval {
-  width: 90px;
+.mal__gcolval {
+  width: 96px;
 }
 
-/* labels column = remaining flex space (no width on the colgroup col). */
-
-.mal__rtable thead tr {
+.mal__gtable thead tr {
   background: var(--rr-bg2);
 }
 
-.mal__rtable th {
+.mal__gtable th {
   text-align: left;
-  padding: 5px 8px;
+  padding: 4px 8px;
   font-size: var(--sw-fs-xs);
   font-weight: var(--sw-fw-bold);
   text-transform: uppercase;
@@ -1403,21 +1901,50 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
   border-bottom: 1px solid var(--rr-border);
 }
 
-.mal__rtable td {
+.mal__gthlabels {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+/* Out-specify `.mal__gtable th { text-align: left }` so the value
+   header lines up over its right-aligned numeric column. */
+.mal__gtable th.mal__rtval {
+  text-align: right;
+}
+
+.mal__gtable td {
   padding: 5px 8px;
   border-bottom: 1px solid var(--rr-border);
   vertical-align: top;
 }
 
-.mal__rtable tbody tr:last-child td {
+.mal__gtable tbody tr:last-child td {
   border-bottom: none;
 }
 
-.mal__rtname {
+.mal__difftog {
+  background: transparent;
+  border: 1px solid var(--rr-border);
+  color: var(--rr-ink2);
+  font-family: var(--rr-font-mono);
+  font-size: var(--sw-fs-xs);
+  font-weight: var(--sw-fw-bold);
+  text-transform: uppercase;
+  letter-spacing: var(--sw-ls-caps);
+  padding: 1px 7px;
+  cursor: pointer;
+}
+
+.mal__difftog:hover {
   color: var(--rr-heading);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
+  border-color: var(--rr-ink2);
+}
+
+.mal__difftog--active {
+  background: var(--rr-accent, var(--rr-active));
+  border-color: var(--rr-accent, var(--rr-active));
+  color: var(--rr-bg);
 }
 
 .mal__rtlabels {
@@ -1436,10 +1963,52 @@ function formatOutputValue(v: number | string | 
Record<string, number>): string
 }
 
 .mal__rtval {
-  text-align: left;
+  text-align: right;
   color: var(--rr-accent, var(--rr-active));
+  font-variant-numeric: tabular-nums;
+  overflow-wrap: anywhere;
+}
+
+/* ── Diff mode (differing labels only) ──────────────────────────── */
+
+.mal__diffcommonrow td {
+  background: var(--rr-bg2);
+}
+
+.mal__diffcommonhd {
+  color: var(--sw-fg-3);
+  font-size: var(--sw-fs-xs);
+  font-weight: var(--sw-fw-bold);
+  text-transform: uppercase;
+  letter-spacing: var(--sw-ls-caps);
+  margin-bottom: 4px;
+}
+
+.mal__diffcommons {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 2px 10px;
+}
+
+.mal__diffcommon {
+  color: var(--rr-dim);
+  word-break: break-all;
+}
+
+.mal__diffcell {
+  margin-right: 10px;
+  color: var(--rr-ink2);
   white-space: nowrap;
-  width: 1%;
+}
+
+.mal__diffk {
+  color: var(--sw-fg-3);
+}
+
+.mal__diffv {
+  color: var(--rr-heading);
+  background: color-mix(in oklab, var(--rr-accent, var(--rr-active)) 20%, 
transparent);
+  padding: 0 3px;
 }
 
 .mal__rtempty {
diff --git a/apps/ui/src/features/operate/live-debug/payload.ts 
b/apps/ui/src/features/operate/live-debug/payload.ts
index efd56d7..145ed90 100644
--- a/apps/ui/src/features/operate/live-debug/payload.ts
+++ b/apps/ui/src/features/operate/live-debug/payload.ts
@@ -97,6 +97,22 @@ export function shortHash(h: string | undefined | null): 
string {
   return h.slice(0, 8);
 }
 
+/** Trim a captured sample value for display. Integers (the common
+ *  counter case — `3692965`) render exact; floats that arrive as long
+ *  repeating decimals from `rate()` / `avg()` (`57.03333333333…`,
+ *  `0.66666666…`) collapse to ≤4 significant digits so they stop
+ *  overflowing the value column. Non-finite sentinels (`NaN`,
+ *  `Infinity`) pass through verbatim. Callers keep the precise value on
+ *  a `title` so nothing is lost — this is display-only rounding. */
+export function formatSampleValue(v: number): string {
+  if (!Number.isFinite(v)) return String(v);
+  if (Number.isInteger(v)) return String(v);
+  const abs = Math.abs(v);
+  // ≥1 → cap at 4 decimals; <1 → 4 significant figures so tiny rates
+  // don't round to a misleading 0. `String()` drops trailing zeros.
+  return String(abs >= 1 ? Math.round(v * 1e4) / 1e4 : 
Number(v.toPrecision(4)));
+}
+
 /** Pill tone for a sample-type badge. Centralised across MAL / LAL /
  *  OAL views so a tone tweak lands in one place.
  *
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index d001392..a934d07 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -854,6 +854,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "klicke auf eine Zeile, um ihr 
aufgezeichnetes DSL im Quell-Panel zu inspizieren (schalte „Quelle anzeigen“ im 
Aufzeichnungs-Header um).",
   "click to load": "klicken zum Laden",
   "collect": "sammeln",
+  "common ({n})": "gemeinsam ({n})",
   "compact": "kompakt",
   "completed": "abgeschlossen",
   "configured (disarmed)": "konfiguriert (entschärft)",
@@ -866,6 +867,7 @@
   "delete all saved captures?": "alle gespeicherten Aufzeichnungen löschen?",
   "delete this entry": "diesen Eintrag löschen",
   "delete this entry (does not stop the OAP session)": "diesen Eintrag löschen 
(stoppt die OAP-Sitzung nicht)",
+  "diff": "Diff",
   "disabled": "deaktiviert",
   "effective": "effektiv",
   "empty family · 0 rows": "leere Familie · 0 Zeilen",
@@ -987,6 +989,7 @@
   "session {id}": "Sitzung {id}",
   "show all": "alle anzeigen",
   "show diff & reset": "Diff anzeigen & zurücksetzen",
+  "show only differing labels": "nur abweichende Labels anzeigen",
   "source": "Quelle",
   "step (one bucket per day)": "Schritt (ein Bucket pro Tag)",
   "step (one bucket per hour)": "Schritt (ein Bucket pro Stunde)",
@@ -1001,6 +1004,7 @@
   "use for entities /inspect/entities did not surface.": "verwende für 
Entitäten, die /inspect/entities nicht zurückgegeben hat.",
   "username": "Benutzername",
   "value": "Wert",
+  "values": "Werte",
   "visible": "sichtbar",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "wo execExpression feuert · aufgelöst über 
/debugging/config/dump auf dem Admin-Server",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "welche 
Sidebar-Einträge jede Rolle sieht · gesteuert durch das Lese-Verb in der 
letzten Spalte (das UI blendet aus; das BFF erzwingt dasselbe serverseitig)",
@@ -1018,9 +1022,11 @@
   "{n} nodes": "{n} Knoten",
   "{n} of {total} match": "{n} von {total} passen",
   "{n} ongoing": "{n} laufend",
+  "{n} outputs": "{n} Ausgaben",
   "{n} records": "{n} Datensätze",
   "{n} rule": "{n} Regel",
   "{n} rules": "{n} Regeln",
+  "{n} samples": "{n} Samples",
   "{n} selected · {after} / {cap} after add": "{n} ausgewählt · {after} / 
{cap} nach Hinzufügen",
   "{n} steps": "{n} Schritte",
   "{n} user(s) defined": "{n} Benutzer definiert",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 91723dc..cdb07f6 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -835,6 +835,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "click a row to inspect its captured DSL on 
the source pane (toggle \"show source\" in the capture header).",
   "click to load": "click to load",
   "collect": "collect",
+  "common ({n})": "common ({n})",
   "compact": "compact",
   "completed": "completed",
   "configured (disarmed)": "configured (disarmed)",
@@ -847,6 +848,7 @@
   "delete all saved captures?": "delete all saved captures?",
   "delete this entry": "delete this entry",
   "delete this entry (does not stop the OAP session)": "delete this entry 
(does not stop the OAP session)",
+  "diff": "diff",
   "disabled": "disabled",
   "effective": "effective",
   "empty family · 0 rows": "empty family · 0 rows",
@@ -967,6 +969,7 @@
   "session {id}": "session {id}",
   "show all": "show all",
   "show diff & reset": "show diff & reset",
+  "show only differing labels": "show only differing labels",
   "source": "source",
   "step (one bucket per day)": "step (one bucket per day)",
   "step (one bucket per hour)": "step (one bucket per hour)",
@@ -981,6 +984,7 @@
   "use for entities /inspect/entities did not surface.": "use for entities 
/inspect/entities did not surface.",
   "username": "username",
   "value": "value",
+  "values": "values",
   "visible": "visible",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "where execExpression fires · resolved via 
/debugging/config/dump on the admin server",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "which sidebar items 
each role sees · gated by the read verb in the last column (UI hides; the BFF 
enforces the same server-side)",
@@ -998,9 +1002,11 @@
   "{n} nodes": "{n} nodes",
   "{n} of {total} match": "{n} of {total} match",
   "{n} ongoing": "{n} ongoing",
+  "{n} outputs": "{n} outputs",
   "{n} records": "{n} records",
   "{n} rule": "{n} rule",
   "{n} rules": "{n} rules",
+  "{n} samples": "{n} samples",
   "{n} selected · {after} / {cap} after add": "{n} selected · {after} / {cap} 
after add",
   "{n} steps": "{n} steps",
   "{n} user(s) defined": "{n} user(s) defined",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 3d2dd20..11e75c0 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -854,6 +854,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "haz clic en una fila para inspeccionar su 
DSL capturado en el panel de origen (activa \"show source\" en el encabezado de 
la captura).",
   "click to load": "haz clic para cargar",
   "collect": "recolectar",
+  "common ({n})": "comunes ({n})",
   "compact": "compacto",
   "completed": "completado",
   "configured (disarmed)": "configurado (desarmado)",
@@ -866,6 +867,7 @@
   "delete all saved captures?": "¿eliminar todas las capturas guardadas?",
   "delete this entry": "eliminar esta entrada",
   "delete this entry (does not stop the OAP session)": "eliminar esta entrada 
(no detiene la sesión de OAP)",
+  "diff": "diff",
   "disabled": "deshabilitado",
   "effective": "efectivo",
   "empty family · 0 rows": "familia vacía · 0 filas",
@@ -987,6 +989,7 @@
   "session {id}": "sesión {id}",
   "show all": "mostrar todo",
   "show diff & reset": "mostrar diff y restablecer",
+  "show only differing labels": "mostrar solo las etiquetas que difieren",
   "source": "origen",
   "step (one bucket per day)": "step (un bucket por día)",
   "step (one bucket per hour)": "step (un bucket por hora)",
@@ -1001,6 +1004,7 @@
   "use for entities /inspect/entities did not surface.": "úsalo para entidades 
que /inspect/entities no expuso.",
   "username": "usuario",
   "value": "valor",
+  "values": "valores",
   "visible": "visible",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "donde se dispara execExpression · resuelto vía 
/debugging/config/dump en el admin server",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "qué elementos del 
menú lateral ve cada rol · controlado por el verbo de lectura en la última 
columna (la UI oculta; el BFF aplica lo mismo del lado del servidor)",
@@ -1018,9 +1022,11 @@
   "{n} nodes": "{n} nodos",
   "{n} of {total} match": "{n} de {total} coinciden",
   "{n} ongoing": "{n} en curso",
+  "{n} outputs": "{n} salidas",
   "{n} records": "{n} registros",
   "{n} rule": "{n} regla",
   "{n} rules": "{n} reglas",
+  "{n} samples": "{n} muestras",
   "{n} selected · {after} / {cap} after add": "{n} seleccionadas · {after} / 
{cap} tras agregar",
   "{n} steps": "{n} pasos",
   "{n} user(s) defined": "{n} usuario(s) definido(s)",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index da2de2b..c520752 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -854,6 +854,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "cliquez sur une ligne pour inspecter son 
DSL capturé dans le panneau source (activez « afficher la source » dans 
l'en-tête de capture).",
   "click to load": "cliquez pour charger",
   "collect": "collecter",
+  "common ({n})": "communs ({n})",
   "compact": "compact",
   "completed": "terminé",
   "configured (disarmed)": "configuré (désarmé)",
@@ -866,6 +867,7 @@
   "delete all saved captures?": "supprimer toutes les captures enregistrées ?",
   "delete this entry": "supprimer cette entrée",
   "delete this entry (does not stop the OAP session)": "supprimer cette entrée 
(n'arrête pas la session OAP)",
+  "diff": "diff",
   "disabled": "désactivé",
   "effective": "effectif",
   "empty family · 0 rows": "famille vide · 0 ligne",
@@ -987,6 +989,7 @@
   "session {id}": "session {id}",
   "show all": "tout afficher",
   "show diff & reset": "afficher le diff et réinitialiser",
+  "show only differing labels": "afficher uniquement les libellés qui 
diffèrent",
   "source": "source",
   "step (one bucket per day)": "pas (un bucket par jour)",
   "step (one bucket per hour)": "pas (un bucket par heure)",
@@ -1001,6 +1004,7 @@
   "use for entities /inspect/entities did not surface.": "à utiliser pour les 
entités que /inspect/entities n'a pas fait remonter.",
   "username": "nom d'utilisateur",
   "value": "valeur",
+  "values": "valeurs",
   "visible": "visible",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "où execExpression se déclenche · résolu via 
/debugging/config/dump sur le serveur d'administration",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "quels éléments de 
la barre latérale chaque rôle voit · contrôlé par le verbe de lecture dans la 
dernière colonne (l'UI masque ; le BFF applique la même règle côté serveur)",
@@ -1018,9 +1022,11 @@
   "{n} nodes": "{n} nœuds",
   "{n} of {total} match": "{n} sur {total} correspondent",
   "{n} ongoing": "{n} en cours",
+  "{n} outputs": "{n} sorties",
   "{n} records": "{n} enregistrements",
   "{n} rule": "{n} règle",
   "{n} rules": "{n} règles",
+  "{n} samples": "{n} échantillons",
   "{n} selected · {after} / {cap} after add": "{n} sélectionné(s) · {after} / 
{cap} après ajout",
   "{n} steps": "{n} étapes",
   "{n} user(s) defined": "{n} utilisateur(s) défini(s)",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 9d981d4..100c16f 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -854,6 +854,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "行をクリックすると、ソースペインでキャプチャされた DSL 
を確認できます(キャプチャヘッダーの「show source」で切り替え)。",
   "click to load": "クリックで読み込み",
   "collect": "収集",
+  "common ({n})": "共通 ({n})",
   "compact": "コンパクト",
   "completed": "完了",
   "configured (disarmed)": "configured(無効)",
@@ -866,6 +867,7 @@
   "delete all saved captures?": "保存済みキャプチャをすべて削除しますか?",
   "delete this entry": "このエントリを削除",
   "delete this entry (does not stop the OAP session)": "このエントリを削除(OAP 
セッションは停止しません)",
+  "diff": "差分",
   "disabled": "無効",
   "effective": "実効",
   "empty family · 0 rows": "空のファミリー · 0 行",
@@ -987,6 +989,7 @@
   "session {id}": "セッション {id}",
   "show all": "すべて表示",
   "show diff & reset": "差分表示とリセット",
+  "show only differing labels": "異なるラベルのみ表示",
   "source": "ソース",
   "step (one bucket per day)": "ステップ(1 日あたり 1 バケット)",
   "step (one bucket per hour)": "ステップ(1 時間あたり 1 バケット)",
@@ -1001,6 +1004,7 @@
   "use for entities /inspect/entities did not surface.": "/inspect/entities 
が表示しなかったエンティティに使用します。",
   "username": "ユーザー名",
   "value": "値",
+  "values": "値",
   "visible": "表示",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "execExpression が発火する場所 · admin サーバーの /debugging/config/dump 
経由で解決",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "各ロールが参照できるサイドバー項目 · 
最終列の read 動詞でゲートされます(UI は非表示にし、BFF がサーバー側で同じ制御を強制します)",
@@ -1018,9 +1022,11 @@
   "{n} nodes": "{n} ノード",
   "{n} of {total} match": "{total} 件中 {n} 件が一致",
   "{n} ongoing": "進行中 {n} 件",
+  "{n} outputs": "{n} 出力",
   "{n} records": "{n} レコード",
   "{n} rule": "{n} ルール",
   "{n} rules": "{n} ルール",
+  "{n} samples": "{n} サンプル",
   "{n} selected · {after} / {cap} after add": "{n} 件選択中 · 追加後 {after} / {cap}",
   "{n} steps": "{n} ステップ",
   "{n} user(s) defined": "定義済みユーザー {n} 名",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index d700d21..0c24c67 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -854,6 +854,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "행을 클릭하면 소스 패널에서 캡처된 DSL을 검사할 수 있습니다(캡처 헤더에서 
\"show source\" 토글).",
   "click to load": "클릭하여 로드",
   "collect": "수집",
+  "common ({n})": "공통 ({n})",
   "compact": "압축",
   "completed": "완료됨",
   "configured (disarmed)": "설정됨 (비활성)",
@@ -866,6 +867,7 @@
   "delete all saved captures?": "저장된 모든 캡처를 삭제하시겠습니까?",
   "delete this entry": "이 항목 삭제",
   "delete this entry (does not stop the OAP session)": "이 항목 삭제 (OAP 세션은 중지하지 
않음)",
+  "diff": "diff",
   "disabled": "비활성",
   "effective": "유효",
   "empty family · 0 rows": "빈 패밀리 · 0행",
@@ -987,6 +989,7 @@
   "session {id}": "세션 {id}",
   "show all": "모두 표시",
   "show diff & reset": "diff 표시 및 재설정",
+  "show only differing labels": "다른 라벨만 표시",
   "source": "소스",
   "step (one bucket per day)": "단계 (일당 버킷 1개)",
   "step (one bucket per hour)": "단계 (시간당 버킷 1개)",
@@ -1001,6 +1004,7 @@
   "use for entities /inspect/entities did not surface.": "/inspect/entities가 
노출하지 않은 엔터티에 사용합니다.",
   "username": "사용자 이름",
   "value": "값",
+  "values": "값",
   "visible": "표시됨",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "execExpression이 실행되는 위치 · admin 서버의 /debugging/config/dump를 통해 
해석",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "각 역할이 보는 사이드바 항목 · 
마지막 열의 읽기 권한으로 제어됨(UI에서 숨김 처리, BFF가 서버 측에서 동일하게 강제 적용)",
@@ -1018,9 +1022,11 @@
   "{n} nodes": "노드 {n}개",
   "{n} of {total} match": "{total}개 중 {n}개 일치",
   "{n} ongoing": "진행 중 {n}개",
+  "{n} outputs": "출력 {n}개",
   "{n} records": "레코드 {n}개",
   "{n} rule": "규칙 {n}개",
   "{n} rules": "규칙 {n}개",
+  "{n} samples": "샘플 {n}개",
   "{n} selected · {after} / {cap} after add": "{n}개 선택됨 · 추가 후 {after} / 
{cap}",
   "{n} steps": "단계 {n}개",
   "{n} user(s) defined": "사용자 {n}명 정의됨",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 5c16445..0fcdf17 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -854,6 +854,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "clique em uma linha para inspecionar seu 
DSL capturado no painel de origem (alterne \"mostrar origem\" no cabeçalho da 
captura).",
   "click to load": "clique para carregar",
   "collect": "coletar",
+  "common ({n})": "comuns ({n})",
   "compact": "compacto",
   "completed": "concluído",
   "configured (disarmed)": "configurado (desarmado)",
@@ -866,6 +867,7 @@
   "delete all saved captures?": "excluir todas as capturas salvas?",
   "delete this entry": "excluir esta entrada",
   "delete this entry (does not stop the OAP session)": "excluir esta entrada 
(não interrompe a sessão do OAP)",
+  "diff": "diff",
   "disabled": "desabilitado",
   "effective": "efetivo",
   "empty family · 0 rows": "família vazia · 0 linhas",
@@ -987,6 +989,7 @@
   "session {id}": "sessão {id}",
   "show all": "mostrar tudo",
   "show diff & reset": "mostrar diff e redefinir",
+  "show only differing labels": "mostrar apenas os rótulos divergentes",
   "source": "origem",
   "step (one bucket per day)": "step (um bucket por dia)",
   "step (one bucket per hour)": "step (um bucket por hora)",
@@ -1001,6 +1004,7 @@
   "use for entities /inspect/entities did not surface.": "use para entidades 
que /inspect/entities não retornou.",
   "username": "usuário",
   "value": "valor",
+  "values": "valores",
   "visible": "visível",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "onde execExpression dispara · resolvido via 
/debugging/config/dump no admin server",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "quais itens da 
barra lateral cada papel vê · controlado pelo verbo de leitura na última coluna 
(a UI oculta; o BFF impõe o mesmo no servidor)",
@@ -1018,9 +1022,11 @@
   "{n} nodes": "{n} nós",
   "{n} of {total} match": "{n} de {total} correspondem",
   "{n} ongoing": "{n} em andamento",
+  "{n} outputs": "{n} saídas",
   "{n} records": "{n} registros",
   "{n} rule": "{n} regra",
   "{n} rules": "{n} regras",
+  "{n} samples": "{n} amostras",
   "{n} selected · {after} / {cap} after add": "{n} selecionadas · {after} / 
{cap} após adicionar",
   "{n} steps": "{n} steps",
   "{n} user(s) defined": "{n} usuário(s) definido(s)",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index 427a98e..92954d4 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -854,6 +854,7 @@
   "click a row to inspect its captured DSL on the source pane (toggle \"show 
source\" in the capture header).": "点击某一行,可在源面板查看其抓取的 DSL(在抓取标题区切换\"显示源\")。",
   "click to load": "点击以加载",
   "collect": "采集",
+  "common ({n})": "公共({n})",
   "compact": "紧凑",
   "completed": "已完成",
   "configured (disarmed)": "已配置(未启用)",
@@ -866,6 +867,7 @@
   "delete all saved captures?": "删除所有已保存的抓取?",
   "delete this entry": "删除该条目",
   "delete this entry (does not stop the OAP session)": "删除该条目(不会停止 OAP 会话)",
+  "diff": "差异",
   "disabled": "已禁用",
   "effective": "生效",
   "empty family · 0 rows": "空族 · 0 行",
@@ -987,6 +989,7 @@
   "session {id}": "会话 {id}",
   "show all": "全部显示",
   "show diff & reset": "查看差异并重置",
+  "show only differing labels": "仅显示存在差异的标签",
   "source": "来源",
   "step (one bucket per day)": "step(每天一个桶)",
   "step (one bucket per hour)": "step(每小时一个桶)",
@@ -1001,6 +1004,7 @@
   "use for entities /inspect/entities did not surface.": "用于 /inspect/entities 
未返回的实体。",
   "username": "用户名",
   "value": "值",
+  "values": "值",
   "visible": "可见",
   "where execExpression fires · resolved via /debugging/config/dump on the 
admin server": "execExpression 触发位置 · 通过管理端的 /debugging/config/dump 解析",
   "which sidebar items each role sees · gated by the read verb in the last 
column (UI hides; the BFF enforces the same server-side)": "每个角色看到的侧边栏条目 · 
由最后一列的读权限控制(UI 隐藏;BFF 在服务端执行同样的限制)",
@@ -1018,9 +1022,11 @@
   "{n} nodes": "{n} 个节点",
   "{n} of {total} match": "{total} 中有 {n} 项匹配",
   "{n} ongoing": "进行中 {n} 项",
+  "{n} outputs": "{n} 个输出",
   "{n} records": "{n} 条记录",
   "{n} rule": "{n} 条规则",
   "{n} rules": "{n} 条规则",
+  "{n} samples": "{n} 个样本",
   "{n} selected · {after} / {cap} after add": "已选 {n} 项 · 添加后为 {after} / 
{cap}",
   "{n} steps": "{n} 步",
   "{n} user(s) defined": "已定义 {n} 个用户",

Reply via email to