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 cf6e846 feat(live-debug): Envoy-ALS rendering, LAL matrix UX
(filter/inspect/diff), grid/flow (#88)
cf6e846 is described below
commit cf6e846201538f43978e1ad807ed951e6f765344
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Tue Jun 30 09:53:43 2026 +0800
feat(live-debug): Envoy-ALS rendering, LAL matrix UX (filter/inspect/diff),
grid/flow (#88)
## Why
Debugging a real **Envoy access-log (ALS)** rule against a live OAP cluster
surfaced a chain of gaps in the Live Debugger's LAL view: first the grid/flow
issues (the frozen column drifting, tall results trapped in an inner scroll
box); then, once those were fixed, that the matrix only rendered generic
`LogData` (Envoy ALS cells were **blank**), that there was no way to find the
few records that reached a given stage, that cluster nodes' grids interfered
with each other, and that there [...]
## What
**Grid & flow** — the frozen first column stays pinned on horizontal
scroll; clicking a source line flashes the whole step row; MAL/LAL/OAL pages
scroll as one page for tall captures instead of trapping the result in a
fixed-height box.
**Format-agnostic rendering** — cells render whatever fields OAP
serialized, not a fixed `LogData` subset: an `EnvoyAccessLogBuilder` snapshot
shows its service/endpoint/response data (was blank); a raw proto input OAP
couldn't serialize surfaces the reason (`jsonformat-failed …`) instead of a
blank cell; complex object fields pretty-print; each cell names its payload
class. Search + the cell popout follow the same rendering.
**Matrix UX** — a per-row "has-data" filter (shown only on rows with gaps)
narrows the grid to the records that produced data for that step; row counts
reflect the whole capture, not the visible page. The row filter and the
column-pin highlight are **per-node**, so acting on one cluster node's grid no
longer affects another's. Cells are height-capped, columns width-capped.
Statement-mode labels render `function @7` (the vue-i18n `@` is escaped,
clearing the broken `function @{line}` l [...]
**Inspect & diff** — a persistent `VIEW` (input) / `DIFF` (builder) button
per cell opens the complete payload in a read-only Monaco JSON viewer with the
nested log `content` inlined. For builder snapshots a compare picker renders
the **captured DSL itself** — per-statement steps marked on their line, the
`extractor {…}` / `sink {…}` block snapshots drawn as selectable ranges — and
picking one shows a side-by-side Monaco diff of the two snapshots, so you can
see exactly what a stateme [...]
---
CHANGELOG.md | 8 +
.../ui/src/features/operate/_shared/MonacoView.vue | 85 +++++
.../src/features/operate/live-debug/DebugLal.vue | 247 +++++++++++-
.../src/features/operate/live-debug/DebugView.vue | 3 -
.../ui/src/features/operate/live-debug/LalCell.vue | 64 +++-
.../features/operate/live-debug/LalCellPopout.vue | 412 +++++++++++++++++++++
.../operate/live-debug/LiveDebuggerView.vue | 3 +-
.../features/operate/live-debug/lalPayload.test.ts | 138 +++++++
.../src/features/operate/live-debug/lalPayload.ts | 114 ++++--
apps/ui/src/i18n/locales/de.json | 9 +-
apps/ui/src/i18n/locales/en.json | 9 +-
apps/ui/src/i18n/locales/es.json | 9 +-
apps/ui/src/i18n/locales/fr.json | 9 +-
apps/ui/src/i18n/locales/ja.json | 9 +-
apps/ui/src/i18n/locales/ko.json | 9 +-
apps/ui/src/i18n/locales/pt.json | 9 +-
apps/ui/src/i18n/locales/zh-CN.json | 9 +-
docs/operate/live-debugger.md | 12 +
18 files changed, 1078 insertions(+), 80 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51b9b5f..6c7b6d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,14 @@ The version line is shared by every package in the monorepo
(apps + shared packa
- **Denser Kubernetes dashboard tables** — the K8s layer's table widgets show
more rows without scrolling.
+- **Live debugger reads cleanly on tall and wide results.** The LAL pipeline
matrix's frozen first column now stays pinned when you scroll the grid sideways
(it used to drift off with the rest of the matrix), clicking a source line
flashes the whole matching step row — not just its label — and the MAL / LAL /
OAL debugger pages now scroll as one page for tall captures instead of trapping
the result in a fixed-height inner box.
+
+- **The LAL pipeline matrix renders Envoy access-log (ALS) and any non-generic
log format.** Each cell now shows whatever fields OAP serialized for the record
rather than a fixed `LogData` subset — so an `EnvoyAccessLogBuilder` snapshot
displays its service / endpoint / response data where it used to render blank,
a record whose raw proto input OAP couldn't serialize surfaces the reason
(`jsonformat-failed …`) instead of an empty cell, and each cell names its
payload class. The free-text [...]
+
+- **The LAL matrix gains per-row filtering and is correct across cluster
nodes.** A filter on each step row — shown only when that row has gaps —
narrows the grid to the records that actually produced data for that step (e.g.
just the records that emitted output), and the row counts now reflect the whole
capture rather than the visible page. Each OAP node's matrix filters and
column-pins independently, so acting on one node's grid no longer changes
another's. Oversized cells are height-c [...]
+
+- **Inspect a LAL cell's full data and diff pipeline stages.** Every cell
carries a persistent button — `VIEW` on the input row, `DIFF` on the builder
rows — that opens the complete payload in a syntax-highlighted JSON viewer with
the nested log `content` inlined as real JSON. For the builder snapshots a
compare picker renders the captured DSL itself, with per-statement steps marked
on their line and the `extractor` / `sink` block snapshots drawn as selectable
ranges; picking one shows a [...]
+
### Dashboards
- **Cards can render values as colored status chips.** A card widget with
`format: enum` now takes an optional chip color per value-map entry — `ok`
(green), `warn` (amber), `err` (red), `info` (blue), `neutral` (grey) — and
renders each matched value, or metric label, as a colored chip instead of a
bare number. Set it in the layer-dashboard admin's value-map editor, next to
the existing value → label mapping.
diff --git a/apps/ui/src/features/operate/_shared/MonacoView.vue
b/apps/ui/src/features/operate/_shared/MonacoView.vue
new file mode 100644
index 0000000..6830ca5
--- /dev/null
+++ b/apps/ui/src/features/operate/_shared/MonacoView.vue
@@ -0,0 +1,85 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<script setup lang="ts">
+/**
+ * Read-only Monaco viewer — a single editor for inspecting a value with
+ * syntax highlighting (the inspect popout uses it for the cell's full
+ * JSON). The editable counterpart is `MonacoYaml`; the two-pane diff is
+ * `MonacoDiff`.
+ */
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as monaco from 'monaco-editor';
+import { setupMonaco, RR_THEME_NAME } from '../../../monaco/setup.js';
+
+const props = withDefaults(
+ defineProps<{ value: string; language?: string }>(),
+ { language: 'json' },
+);
+
+const host = ref<HTMLDivElement | null>(null);
+let editor: monaco.editor.IStandaloneCodeEditor | null = null;
+let model: monaco.editor.ITextModel | null = null;
+
+onMounted(() => {
+ if (!host.value) return;
+ setupMonaco();
+ model = monaco.editor.createModel(props.value, props.language);
+ editor = monaco.editor.create(host.value, {
+ model,
+ theme: RR_THEME_NAME,
+ automaticLayout: true,
+ fontFamily: "'JetBrains Mono', ui-monospace, monospace",
+ fontSize: 13,
+ minimap: { enabled: false },
+ readOnly: true,
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ });
+});
+
+watch(
+ () => props.value,
+ (next) => {
+ if (model && model.getValue() !== next) model.setValue(next);
+ },
+);
+watch(
+ () => props.language,
+ (next) => {
+ if (model) monaco.editor.setModelLanguage(model, next);
+ },
+);
+
+onBeforeUnmount(() => {
+ editor?.dispose();
+ model?.dispose();
+ editor = null;
+ model = null;
+});
+</script>
+
+<template>
+ <div ref="host" class="mview" :data-testid="'monaco-view'" />
+</template>
+
+<style scoped>
+.mview {
+ width: 100%;
+ height: 100%;
+ min-height: 320px;
+}
+</style>
diff --git a/apps/ui/src/features/operate/live-debug/DebugLal.vue
b/apps/ui/src/features/operate/live-debug/DebugLal.vue
index 8439ca0..29c7f3f 100644
--- a/apps/ui/src/features/operate/live-debug/DebugLal.vue
+++ b/apps/ui/src/features/operate/live-debug/DebugLal.vue
@@ -44,13 +44,16 @@ import { bff } from '@/api/client';
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 Icon from '@/components/icons/Icon.vue';
import DebugView from './DebugView.vue';
import LalCell from './LalCell.vue';
+import LalCellPopout from './LalCellPopout.vue';
import { isLalSamplePayload } from './payload.js';
import {
cellAt,
cssEscape,
formatTime,
+ fullJson,
nodeKey,
recordMatches,
stepKeyOf,
@@ -199,6 +202,10 @@ const displaySession = computed<SessionResponse | null>(
* functions, an `?historyId=` deep-link would throw a TDZ
* ReferenceError and blank the page. */
const selectedCell = ref<LalCellData | null>(null);
+/** Node that owns the selected cell. The column-pin highlight keys on
+ * recIdx, which collides across nodes (each node numbers its records
+ * from 0), so the pin must also match this node. */
+const selectedNodeKey = ref<string | null>(null);
function loadHistorical(entry: HistoryEntry): void {
historicalEntry.value = entry;
@@ -356,8 +363,14 @@ const nodeViews = computed<LalNodeView[]>(() => {
* `<mark>` highlight inside the captured DSL. `selectedCell` itself
* is declared above `loadHistorical` (TDZ guard). */
-function selectCell(cell: LalCellData): void {
- selectedCell.value = selectedCell.value === cell ? null : cell;
+function selectCell(cell: LalCellData, nKey: string): void {
+ if (selectedCell.value === cell) {
+ selectedCell.value = null;
+ selectedNodeKey.value = null;
+ } else {
+ selectedCell.value = cell;
+ selectedNodeKey.value = nKey;
+ }
}
// ── Single-record expand mode ─────────────────────────────────────
@@ -385,6 +398,101 @@ function toggleExpandRecord(nKey: string, recIdx:
number): void {
}
}
+// ── Per-row "has-data" filter ─────────────────────────────────────
+
+/** Per-node active row filter (`nodeKey → step key`). When a node has one
+ * set, its matrix keeps only the records (columns) that produced data for
+ * that step — e.g. filter the OUTPUT row to the records that actually
+ * emitted output. Keyed by node so each OAP node's matrix filters
+ * independently; a cluster renders one matrix per node. */
+const rowFilter = ref<Record<string, string>>({});
+
+function toggleRowFilter(nKey: string, stepKey: string): void {
+ const next = { ...rowFilter.value };
+ if (next[nKey] === stepKey) delete next[nKey];
+ else next[nKey] = stepKey;
+ rowFilter.value = next;
+}
+
+// ── Single-cell full-data popout ──────────────────────────────────
+
+/** The cell whose complete data (all fields + full pretty-printed
+ * content) is shown in the popout modal; null when closed. The dense
+ * matrix clips each cell, so the expand icon opens this to read it all. */
+const popoutOpen = ref<boolean>(false);
+const popoutTitle = ref<string>('');
+const popoutScript = ref<string>('');
+const popoutJson = ref<string>('');
+const popoutComparable = ref<
+ { label: string; script: string; json: string; lineStart: number; lineEnd:
number }[]
+>([]);
+const popoutDslLines = ref<string[]>([]);
+const popoutCurrentLine = ref<number>(0);
+
+/** Brace-match a top-level DSL block (`extractor {` … `}`) and return its
+ * 1-based line span, or null when the opener isn't present. */
+function blockRange(openRe: RegExp): { start: number; end: number } | null {
+ const lines = sourceDslLines.value;
+ let start = -1;
+ for (let i = 0; i < lines.length; i++) {
+ if (openRe.test(lines[i]!)) {
+ start = i;
+ break;
+ }
+ }
+ if (start < 0) return null;
+ let depth = 0;
+ for (let i = start; i < lines.length; i++) {
+ for (const ch of lines[i]!) {
+ if (ch === '{') depth++;
+ else if (ch === '}' && --depth === 0) return { start: start + 1, end: i
+ 1 };
+ }
+ }
+ return { start: start + 1, end: lines.length };
+}
+
+/** A step's DSL span: one line for per-statement steps; the whole block
+ * for the block-level snapshots so the picker shows them as ranges. The
+ * `output` snapshot maps to the `sink {}` block (where the built log
+ * goes); the post-extractor snapshot maps to `extractor {}`. */
+function stepLines(step: LalStep): { start: number; end: number } {
+ if (step.sourceLine > 0) return { start: step.sourceLine, end:
step.sourceLine };
+ const r = blockRange(step.type === 'output' ? /^\s*sink\s*\{/ :
/^\s*extractor\s*\{/);
+ return r ?? { start: 0, end: 0 };
+}
+
+function openCellPopout(cell: LalCellData, step: LalStep, node: LalNodeView):
void {
+ popoutTitle.value = `${step.kindLabel} · ${recordTitle({ rec: cell.rec,
recIdx: cell.recIdx })}`;
+ popoutScript.value = cell.sample.sourceText.trim();
+ popoutJson.value = fullJson(cell.payload, step.type);
+ popoutDslLines.value = sourceDslLines.value;
+ popoutCurrentLine.value = stepLines(step).start;
+ // Comparable = the record's other same-format (builder) cells. Per-
+ // statement snapshots map to their DSL line; the post-extractor /
+ // output snapshots map to their whole block range, so the captured
+ // script doubles as the row selector. The input row (different proto
+ // format, no builder output) has no comparator.
+ const isBuilder = !!cell.payload?.output;
+ popoutComparable.value = node.steps
+ .filter((s) => s.key !== step.key)
+ .map((s) => ({ s, c: cellAt(node, s, cell.recIdx) }))
+ .filter((x) => x.c !== undefined && !!x.c.payload?.output === isBuilder)
+ .map((x) => {
+ const r = stepLines(x.s);
+ return {
+ label: x.s.kindLabel,
+ script: x.c!.sample.sourceText.trim(),
+ json: fullJson(x.c!.payload, x.s.type),
+ lineStart: r.start,
+ lineEnd: r.end,
+ };
+ });
+ popoutOpen.value = true;
+}
+function closeCellPopout(): void {
+ popoutOpen.value = false;
+}
+
// ── Search + display limit ────────────────────────────────────────
/** Free-text filter on log content. A record matches when ANY of its
@@ -428,10 +536,15 @@ const displayedByNode = computed<Map<string,
DisplayedShape>>(() => {
continue;
}
}
- const matched =
+ const searched =
q === ''
? view.recordViews
: view.recordViews.filter((rv) => recordMatches(rv.rec, q));
+ const rf = rowFilter.value[nKey];
+ const matched =
+ rf === undefined
+ ? searched
+ : searched.filter((rv) => view.cells.get(rf)?.get(rv.recIdx) !==
undefined);
map.set(nKey, {
records: matched.slice(0, limit),
matched: matched.length,
@@ -523,9 +636,10 @@ const blockHookByLine = computed<Map<number, string>>(()
=> {
});
/** Soft-highlight: when an operator clicks a line in the source
- * panel, the corresponding step row in the matrix gets a brief
- * outline. We track the highlighted step key and clear it after a
- * short delay so the cue is visible without being permanent. */
+ * panel, the corresponding step row in the matrix briefly flashes
+ * (label outline + full-row wash). We track the highlighted step key
+ * and clear it after a short delay so the cue is visible without
+ * being permanent. */
const highlightedStepKey = ref<string | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
@@ -534,7 +648,7 @@ function jumpToStep(stepKey: string): void {
if (highlightTimer !== null) clearTimeout(highlightTimer);
highlightTimer = setTimeout(() => {
highlightedStepKey.value = null;
- }, 1500);
+ }, 10000);
// Scroll the matrix's matching step label into view inside the
// wrapper. The element id is set on the step row's leading label
// cell so this works for both the sticky-pinned column and free-
@@ -768,7 +882,7 @@ function recordTitle(view: LalRecordView): string {
<div
v-else
class="lal__matrix"
- :style="`grid-template-columns: 180px
repeat(${displayedRecords(node).length}, minmax(200px, 1fr));`"
+ :style="`grid-template-columns: 180px
repeat(${displayedRecords(node).length}, ${expandedRecord ? '1fr' :
'minmax(220px, 380px)'});`"
>
<div class="lal__hdrlbl">
{{ t('block ▾ / record →') }}
@@ -786,7 +900,9 @@ function recordTitle(view: LalRecordView): string {
class="lal__hdrec"
:class="{
'lal__hdrec--pinned':
- selectedCell !== null && selectedCell.recIdx === rv.recIdx,
+ selectedCell !== null &&
+ selectedNodeKey === nodeKey(node) &&
+ selectedCell.recIdx === rv.recIdx,
}"
>
<div class="lal__hdrtitle">
@@ -811,7 +927,15 @@ function recordTitle(view: LalRecordView): string {
<code>{{ step.nameLabel }}</code>
</div>
<div class="lal__stepct">
- {{ t('{n} / {total} records', { n:
displayedRecords(node).filter((rv) => cellAt(node, step, rv.recIdx) !==
undefined).length, total: displayedRecords(node).length }) }}
+ <span>{{ t('{n} / {total} records', { n:
node.cells.get(step.key)?.size ?? 0, total: node.recordViews.length }) }}</span>
+ <button
+ v-if="(node.cells.get(step.key)?.size ?? 0) <
node.recordViews.length"
+ type="button"
+ class="lal__rowfilter"
+ :class="{ 'lal__rowfilter--on': rowFilter[nodeKey(node)] ===
step.key }"
+ :title="rowFilter[nodeKey(node)] === step.key ? t('show all
records') : t('show only records with data in this row')"
+ @click.stop="toggleRowFilter(nodeKey(node), step.key)"
+ ><Icon name="filter" :size="12" /></button>
</div>
</div>
<div
@@ -819,24 +943,35 @@ function recordTitle(view: LalRecordView): string {
:key="`${step.key}-${rv.recIdx}`"
class="lal__cell"
:class="{
+ 'lal__cell--rowflash': highlightedStepKey === step.key,
'lal__cell--selected':
selectedCell !== null &&
+ selectedNodeKey === nodeKey(node) &&
selectedCell.recIdx === rv.recIdx &&
selectedCell.sample === cellAt(node, step,
rv.recIdx)?.sample,
'lal__cell--pinned':
- selectedCell !== null && selectedCell.recIdx === rv.recIdx,
+ selectedCell !== null &&
+ selectedNodeKey === nodeKey(node) &&
+ selectedCell.recIdx === rv.recIdx,
'lal__cell--missing': cellAt(node, step, rv.recIdx) ===
undefined,
}"
- @click="(() => { const c = cellAt(node, step, rv.recIdx); if (c)
selectCell(c); })()"
+ @click="(() => { const c = cellAt(node, step, rv.recIdx); if (c)
selectCell(c, nodeKey(node)); })()"
>
<template v-if="cellAt(node, step, rv.recIdx) === undefined">
<span class="lal__cellabsent">—</span>
</template>
- <LalCell
- v-else
- :step-type="step.type"
- :payload="cellAt(node, step, rv.recIdx)?.payload ?? null"
- />
+ <template v-else>
+ <button
+ type="button"
+ class="lal__cellfull"
+ :title="t('show complete data')"
+ @click.stop="openCellPopout(cellAt(node, step, rv.recIdx)!,
step, node)"
+ ><Icon name="expand" :size="11" /><span>{{ step.type ===
'input' ? t('view') : t('diff') }}</span></button>
+ <LalCell
+ :step-type="step.type"
+ :payload="cellAt(node, step, rv.recIdx)?.payload ?? null"
+ />
+ </template>
</div>
</template>
</div>
@@ -845,6 +980,16 @@ function recordTitle(view: LalRecordView): string {
</div>
</template>
</DebugView>
+ <LalCellPopout
+ :open="popoutOpen"
+ :title="popoutTitle"
+ :script="popoutScript"
+ :json="popoutJson"
+ :comparable="popoutComparable"
+ :dsl-lines="popoutDslLines"
+ :current-line="popoutCurrentLine"
+ @close="closeCellPopout"
+ />
</template>
<style scoped>
@@ -1134,6 +1279,7 @@ function recordTitle(view: LalRecordView): string {
font-family: var(--rr-font-mono);
font-size: var(--sw-fs-base);
background: var(--rr-bg);
+ width: max-content;
min-width: 100%;
}
@@ -1301,9 +1447,37 @@ function recordTitle(view: LalRecordView): string {
.lal__stepct {
color: var(--rr-dim);
font-size: var(--sw-fs-xs);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+}
+
+.lal__rowfilter {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1px 3px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ color: var(--rr-dim);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.lal__rowfilter:hover {
+ color: var(--rr-ink2);
+ border-color: var(--rr-border);
+}
+
+.lal__rowfilter--on {
+ color: var(--rr-accent, var(--rr-active));
+ border-color: var(--rr-accent, var(--rr-active));
}
.lal__cell {
+ position: relative;
padding: 8px 10px;
border-right: 1px solid var(--rr-border);
border-bottom: 1px solid var(--rr-border);
@@ -1313,6 +1487,37 @@ function recordTitle(view: LalRecordView): string {
cursor: pointer;
background: transparent;
min-width: 0;
+ /* Clip a runaway record (huge ALS field set / content); the expand
+ icon opens the full data in the popout. */
+ max-height: 300px;
+ overflow: hidden;
+}
+
+/* Persistent (always-visible) inspect button — accent-outlined + labeled
+ so it reads unmistakably as a button, not a ghost icon. */
+.lal__cellfull {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 1px 5px 1px 4px;
+ background: var(--rr-bg2);
+ border: 1px solid var(--rr-accent, var(--rr-active));
+ border-radius: 3px;
+ color: var(--rr-accent, var(--rr-active));
+ font-family: var(--rr-font-mono);
+ font-size: var(--sw-fs-xs);
+ text-transform: uppercase;
+ letter-spacing: var(--sw-ls-caps);
+ cursor: pointer;
+}
+
+.lal__cellfull:hover {
+ color: var(--rr-heading);
+ background: color-mix(in srgb, var(--rr-accent, var(--rr-active)) 16%,
var(--rr-bg));
}
.lal__cell:hover {
@@ -1320,7 +1525,7 @@ function recordTitle(view: LalRecordView): string {
}
.lal__cell--pinned {
- background: rgba(143, 175, 199, 0.05);
+ background: color-mix(in srgb, var(--rr-accent, var(--rr-active)) 6%,
var(--rr-bg));
}
.lal__cell--selected,
@@ -1342,6 +1547,12 @@ function recordTitle(view: LalRecordView): string {
font-size: var(--sw-fs-sm);
}
+.lal__steplbl--flash,
+.lal__cell--rowflash {
+ background: color-mix(in srgb, var(--rr-accent, var(--rr-active)) 20%,
var(--rr-bg));
+ transition: background 0.5s ease;
+}
+
.lal__histbanner {
display: flex;
align-items: center;
diff --git a/apps/ui/src/features/operate/live-debug/DebugView.vue
b/apps/ui/src/features/operate/live-debug/DebugView.vue
index 7c3ec83..b7d84e9 100644
--- a/apps/ui/src/features/operate/live-debug/DebugView.vue
+++ b/apps/ui/src/features/operate/live-debug/DebugView.vue
@@ -224,9 +224,6 @@ function nodeStatusTone(status: NodeSlice['status']): 'ok'
| 'info' | 'warn' | '
display: flex;
flex-direction: column;
gap: 12px;
- flex: 1 1 auto;
- min-height: 0;
- overflow: auto;
}
.dv__controls {
diff --git a/apps/ui/src/features/operate/live-debug/LalCell.vue
b/apps/ui/src/features/operate/live-debug/LalCell.vue
index e37aee4..b85408f 100644
--- a/apps/ui/src/features/operate/live-debug/LalCell.vue
+++ b/apps/ui/src/features/operate/live-debug/LalCell.vue
@@ -16,13 +16,15 @@
-->
<script setup lang="ts">
/**
- * The inner body of one matrix cell in `DebugLal.vue` — the repetitive
- * per-cell markup that renders an `input` LogData (service/endpoint kv +
- * carried tags + body preview) or a `function`/`output` LogBuilder
- * snapshot (kv + carried/added tag groups + content preview). The view
- * owns the surrounding `.lal__cell` wrapper (selection/pin/click state);
- * this renders only what's inside it for a given step type + payload.
+ * The inner body of one matrix cell in `DebugLal.vue`. The payload is
+ * OAP-serialized JSON whose field set varies by log format (LogData,
+ * EnvoyAccessLogBuilder, a raw proto, …), so scalar fields render as a
+ * generic key/value dump rather than a fixed subset; tags and body /
+ * content keep their own groups. The view owns the surrounding
+ * `.lal__cell` wrapper (selection/pin/click state); this renders only
+ * what's inside it for a given step type + payload.
*/
+import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { LalSamplePayload, SampleType } from
'@skywalking-horizon-ui/api-client';
import {
@@ -38,16 +40,30 @@ import {
const props = defineProps<{
stepType: SampleType;
payload: LalSamplePayload | null;
+ /** When set (the cell popout), body/content shows its complete pretty-
+ * printed value instead of the 80-char dense-matrix preview. */
+ full?: boolean;
}>();
const { t } = useI18n({ useScope: 'global' });
+
+/** Concrete payload class (LogData / LogBuilder / EnvoyAccessLogBuilder /
+ * the raw proto on the Envoy path) — shown as the cell's format kicker
+ * since the field set below depends on it. */
+const ptype = computed(
+ () => (props.stepType === 'input' ? props.payload?.input :
props.payload?.output)?.type ?? '',
+);
+const bodyTxt = computed(() => bodyPreview(props.payload, props.full));
+const contentTxt = computed(() => contentPreview(props.payload, props.full));
+const inputKvs = computed(() => inputEntries(props.payload, props.full));
+const outputKvs = computed(() => outputEntries(props.payload, props.full));
</script>
<template>
- <!-- input: LogData -->
+ <div v-if="ptype" class="lal__ptype">{{ ptype }}</div>
<template v-if="props.stepType === 'input'">
<div class="lal__kvs">
- <div v-for="kv in inputEntries(props.payload)" :key="kv.k"
class="lal__kv">
+ <div v-for="kv in inputKvs" :key="kv.k" class="lal__kv">
<span class="lal__kvk">{{ kv.k }}</span>
<span class="lal__kvv">{{ kv.v }}</span>
</div>
@@ -62,15 +78,15 @@ const { t } = useI18n({ useScope: 'global' });
class="lal__tag lal__tag--orig"
>{{ tag.key }}={{ tag.value }}</span>
</div>
- <div v-if="bodyPreview(props.payload)" class="lal__body">
- {{ bodyPreview(props.payload) }}
+ <div v-if="bodyTxt" class="lal__body" :class="{ 'lal__body--full':
props.full }">
+ {{ bodyTxt }}
</div>
</template>
- <!-- function / output: LogBuilder snapshot -->
+ <!-- function / output -->
<template v-else>
<div class="lal__kvs">
- <div v-for="kv in outputEntries(props.payload)" :key="kv.k"
class="lal__kv">
+ <div v-for="kv in outputKvs" :key="kv.k" class="lal__kv">
<span class="lal__kvk">{{ kv.k }}</span>
<span class="lal__kvv">{{ kv.v }}</span>
</div>
@@ -98,8 +114,8 @@ const { t } = useI18n({ useScope: 'global' });
:class="tag.status === 'lal-override' ? 'lal__tag--over' :
'lal__tag--add'"
>{{ tag.key }}={{ tag.value }}</span>
</div>
- <div v-if="contentPreview(props.payload)" class="lal__body">
- {{ contentPreview(props.payload) }}
+ <div v-if="contentTxt" class="lal__body" :class="{ 'lal__body--full':
props.full }">
+ {{ contentTxt }}
</div>
</template>
<div v-if="props.payload?.aborted" class="lal__abort">{{ t('aborted')
}}</div>
@@ -118,16 +134,16 @@ const { t } = useI18n({ useScope: 'global' });
.lal__kvk {
color: var(--sw-fg-3);
+ 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);
- align-self: center;
+ align-self: start;
}
.lal__kvv {
color: var(--rr-ink);
word-break: break-all;
+ white-space: pre-wrap;
font-size: var(--sw-fs-sm);
}
@@ -142,6 +158,20 @@ const { t } = useI18n({ useScope: 'global' });
white-space: pre-wrap;
}
+/* Cell popout: the complete pretty-printed content, slightly larger and
+ more legible than the dense one-line preview. */
+.lal__body--full {
+ font-size: var(--sw-fs-sm);
+ color: var(--rr-ink);
+}
+
+.lal__ptype {
+ font-family: var(--rr-font-mono);
+ font-size: var(--sw-fs-xs);
+ color: var(--rr-dim);
+ word-break: break-all;
+}
+
.lal__tags {
display: flex;
flex-wrap: wrap;
diff --git a/apps/ui/src/features/operate/live-debug/LalCellPopout.vue
b/apps/ui/src/features/operate/live-debug/LalCellPopout.vue
new file mode 100644
index 0000000..2fe7fcd
--- /dev/null
+++ b/apps/ui/src/features/operate/live-debug/LalCellPopout.vue
@@ -0,0 +1,412 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<script setup lang="ts">
+/**
+ * Full-data inspect popout for one LAL matrix cell. Opens the cell's
+ * complete payload as a syntax-highlighted JSON viewer. The compare
+ * picker IS the captured DSL: every rule line, with per-statement sibling
+ * steps marked `▶` on their line and the block-level snapshots
+ * (extractor / output) drawn as an accent-barred range over their whole
+ * `{…}` block — click any of them to diff. The opened cell's line is
+ * marked `◆`. Picking swaps the viewer for a side-by-side diff. The input
+ * row has no comparator (its raw-proto format differs).
+ */
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import MonacoView from '@/features/operate/_shared/MonacoView.vue';
+import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue';
+import FloatingPanel from '@/components/primitives/FloatingPanel.vue';
+import Icon from '@/components/icons/Icon.vue';
+
+const props = defineProps<{
+ open: boolean;
+ title: string;
+ /** The opened cell's verbatim DSL statement (empty for block-level steps).
*/
+ script: string;
+ /** The opened cell's complete payload, pretty JSON. */
+ json: string;
+ /** Same-format sibling cells. `lineStart..lineEnd` is the DSL span
+ * (equal for per-statement steps, the block range for block steps). */
+ comparable: { label: string; script: string; json: string; lineStart:
number; lineEnd: number }[];
+ /** The captured DSL, one entry per line. */
+ dslLines: string[];
+ /** The opened cell's 1-based DSL line (0 if unmapped). */
+ currentLine: number;
+}>();
+defineEmits<{ close: [] }>();
+
+const { t } = useI18n();
+
+/** Index into `comparable`, or -1 for the single-cell view. */
+const compareIdx = ref<number>(-1);
+const ddOpen = ref<boolean>(false);
+const ddAnchor = ref<HTMLElement | null>(null);
+
+watch(
+ () => props.title,
+ () => {
+ compareIdx.value = -1;
+ ddOpen.value = false;
+ },
+);
+
+/** 1-based opening line → comparable index (the `▶` + click anchor). */
+const startToCmp = computed(() => {
+ const m = new Map<number, number>();
+ props.comparable.forEach((c, i) => {
+ if (c.lineStart > 0) m.set(c.lineStart, i);
+ });
+ return m;
+});
+/** Every line inside a multi-line block range → its comparable index
+ * (the accent range bar; also lets clicking anywhere in the block pick it).
*/
+const blockLineToCmp = computed(() => {
+ const m = new Map<number, number>();
+ props.comparable.forEach((c, i) => {
+ if (c.lineEnd > c.lineStart) {
+ for (let ln = c.lineStart; ln <= c.lineEnd; ln++) m.set(ln, i);
+ }
+ });
+ return m;
+});
+/** Steps with no DSL span at all — kept as a fallback list (rare). */
+const lineless = computed(() =>
+ props.comparable.map((c, i) => ({ c, i })).filter((x) => x.c.lineStart <= 0),
+);
+
+const compareJson = computed(() =>
+ compareIdx.value >= 0 ? (props.comparable[compareIdx.value]?.json ?? '') :
'',
+);
+const selectedLabel = computed(() =>
+ compareIdx.value >= 0 ? (props.comparable[compareIdx.value]?.label ?? '') :
'—',
+);
+
+function pick(i: number): void {
+ compareIdx.value = compareIdx.value === i ? -1 : i;
+ ddOpen.value = false;
+}
+function pickLine(n: number): void {
+ const idx = startToCmp.value.get(n) ?? blockLineToCmp.value.get(n);
+ if (idx !== undefined) pick(idx);
+}
+function clearCompare(): void {
+ compareIdx.value = -1;
+}
+</script>
+
+<template>
+ <Modal :open="open" :title="title" width="82vw" :fit-body="true"
@close="$emit('close')">
+ <div class="lpop">
+ <pre v-if="script" class="lpop__script">{{ script }}</pre>
+
+ <div v-if="comparable.length > 0" class="lpop__bar">
+ <span class="lpop__lbl">{{ t('compare with') }}</span>
+ <button
+ ref="ddAnchor"
+ type="button"
+ class="lpop__ddbtn"
+ :class="{ 'lpop__ddbtn--on': compareIdx >= 0 }"
+ @click="ddOpen = !ddOpen"
+ >
+ <span>{{ selectedLabel }}</span>
+ <Icon name="caret" :size="10" />
+ </button>
+ <button
+ v-if="compareIdx >= 0"
+ type="button"
+ class="lpop__clear"
+ :title="t('clear')"
+ @click="clearCompare"
+ >
+ ✕
+ </button>
+ <span v-if="compareIdx >= 0" class="lpop__hint">{{ t('diff vs this
cell') }}</span>
+ </div>
+
+ <FloatingPanel :open="ddOpen" :anchor="ddAnchor" :width="800"
@close="ddOpen = false">
+ <div class="lpop__menu">
+ <div class="lpop__dsl">
+ <button
+ v-for="(line, i) in dslLines"
+ :key="i"
+ type="button"
+ class="lpop__dline"
+ :class="{
+ 'lpop__dline--cmp': startToCmp.has(i + 1) ||
blockLineToCmp.has(i + 1),
+ 'lpop__dline--block': blockLineToCmp.has(i + 1),
+ 'lpop__dline--cur': i + 1 === currentLine,
+ 'lpop__dline--sel':
+ startToCmp.get(i + 1) === compareIdx || blockLineToCmp.get(i
+ 1) === compareIdx,
+ }"
+ @click="pickLine(i + 1)"
+ >
+ <span class="lpop__dgut">{{ i + 1 }}</span>
+ <span class="lpop__dmark">{{
+ i + 1 === currentLine ? '◆' : startToCmp.has(i + 1) ? '▶' : ''
+ }}</span>
+ <code class="lpop__dtext">{{ line }}</code>
+ </button>
+ </div>
+
+ <template v-if="lineless.length > 0">
+ <div class="lpop__sep">{{ t('other') }}</div>
+ <button
+ v-for="x in lineless"
+ :key="`l-${x.i}`"
+ type="button"
+ class="lpop__opt"
+ :class="{ 'lpop__opt--sel': compareIdx === x.i }"
+ @click="pick(x.i)"
+ >
+ <span class="lpop__opttag">{{ x.c.label }}</span>
+ <code v-if="x.c.script" class="lpop__optscript">{{ x.c.script
}}</code>
+ </button>
+ </template>
+ </div>
+ </FloatingPanel>
+
+ <div class="lpop__view">
+ <MonacoDiff
+ v-if="compareIdx >= 0"
+ :original="compareJson"
+ :modified="json"
+ language="json"
+ />
+ <MonacoView v-else :value="json" language="json" />
+ </div>
+ </div>
+ </Modal>
+</template>
+
+<style scoped>
+.lpop {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.lpop__script {
+ margin: 0;
+ padding: 6px 9px;
+ background: var(--rr-bg);
+ border: 1px solid var(--rr-border);
+ border-left: 2px solid var(--rr-accent, var(--rr-active));
+ color: var(--rr-ink2);
+ font-family: var(--rr-font-mono);
+ font-size: var(--sw-fs-sm);
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-word;
+ flex-shrink: 0;
+}
+
+.lpop__bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.lpop__lbl {
+ font-size: var(--sw-fs-xs);
+ text-transform: uppercase;
+ letter-spacing: var(--sw-ls-caps);
+ color: var(--rr-dim);
+}
+
+.lpop__ddbtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 160px;
+ justify-content: space-between;
+ background: var(--rr-bg);
+ border: 1px solid var(--rr-border);
+ color: var(--rr-ink);
+ font-family: var(--rr-font-mono);
+ font-size: var(--sw-fs-sm);
+ padding: 3px 8px;
+ cursor: pointer;
+}
+
+.lpop__ddbtn--on {
+ border-color: var(--rr-accent, var(--rr-active));
+ color: var(--rr-accent, var(--rr-active));
+}
+
+.lpop__clear {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ background: var(--rr-bg);
+ border: 1px solid var(--rr-border);
+ color: var(--rr-dim);
+ cursor: pointer;
+}
+
+.lpop__clear:hover {
+ color: var(--rr-ink);
+ border-color: var(--rr-ink2);
+}
+
+.lpop__hint {
+ font-size: var(--sw-fs-xs);
+ color: var(--rr-dim);
+}
+
+.lpop__menu {
+ display: flex;
+ flex-direction: column;
+ background: var(--rr-bg);
+ overflow: auto;
+ max-height: calc(100vh - 16px);
+}
+
+/* The captured-DSL row selector. */
+.lpop__dsl {
+ display: flex;
+ flex-direction: column;
+ padding: 4px 0;
+}
+
+.lpop__dline {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-left: 2px solid transparent;
+ padding: 2px 12px;
+ cursor: default;
+ color: var(--rr-dim);
+}
+
+.lpop__dline--cmp {
+ cursor: pointer;
+ color: var(--rr-ink);
+}
+
+.lpop__dline--cmp:hover {
+ background: var(--rr-bg2);
+}
+
+/* Block-level range (extractor / sink) — a continuous accent rail. */
+.lpop__dline--block {
+ border-left-color: var(--rr-accent, var(--rr-active));
+ color: var(--rr-ink);
+}
+
+.lpop__dline--cur {
+ color: var(--rr-ink);
+ background: color-mix(in srgb, var(--rr-accent, var(--rr-active)) 9%,
var(--rr-bg));
+}
+
+.lpop__dline--sel {
+ background: color-mix(in srgb, var(--rr-accent, var(--rr-active)) 20%,
var(--rr-bg));
+}
+
+.lpop__dgut {
+ flex-shrink: 0;
+ width: 22px;
+ text-align: right;
+ color: var(--rr-dim);
+ font-family: var(--rr-font-mono);
+ font-size: var(--sw-fs-xs);
+ user-select: none;
+ line-height: 1.4;
+}
+
+.lpop__dmark {
+ flex-shrink: 0;
+ width: 10px;
+ color: var(--rr-accent, var(--rr-active));
+ font-size: var(--sw-fs-xs);
+ line-height: 1.4;
+}
+
+.lpop__dtext {
+ flex: 1;
+ min-width: 0;
+ font-family: var(--rr-font-mono);
+ font-size: var(--sw-fs-sm);
+ white-space: pre-wrap;
+ word-break: break-word;
+ line-height: 1.4;
+}
+
+.lpop__sep {
+ padding: 6px 12px 2px;
+ font-size: var(--sw-fs-xs);
+ text-transform: uppercase;
+ letter-spacing: var(--sw-ls-caps);
+ color: var(--rr-dim);
+ border-top: 1px solid var(--rr-border);
+}
+
+.lpop__opt {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--rr-border);
+ padding: 8px 12px;
+ cursor: pointer;
+}
+
+.lpop__opt:hover {
+ background: var(--rr-bg2);
+}
+
+.lpop__opt--sel {
+ background: color-mix(in srgb, var(--rr-accent, var(--rr-active)) 12%,
var(--rr-bg));
+}
+
+.lpop__opttag {
+ 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);
+ color: var(--rr-accent, var(--rr-active));
+}
+
+.lpop__optscript {
+ font-family: var(--rr-font-mono);
+ font-size: var(--sw-fs-sm);
+ color: var(--rr-ink2);
+ white-space: pre-wrap;
+ word-break: break-word;
+ line-height: 1.4;
+}
+
+.lpop__view {
+ flex: 1;
+ min-height: 0;
+ border: 1px solid var(--rr-border);
+}
+</style>
diff --git a/apps/ui/src/features/operate/live-debug/LiveDebuggerView.vue
b/apps/ui/src/features/operate/live-debug/LiveDebuggerView.vue
index c571cb3..7a098f7 100644
--- a/apps/ui/src/features/operate/live-debug/LiveDebuggerView.vue
+++ b/apps/ui/src/features/operate/live-debug/LiveDebuggerView.vue
@@ -92,8 +92,7 @@ function selectTab(t: Tab): void {
.dbg {
display: flex;
flex-direction: column;
- height: 100%;
- min-height: 0;
+ min-height: 100%;
padding: 18px 24px;
gap: 12px;
}
diff --git a/apps/ui/src/features/operate/live-debug/lalPayload.test.ts
b/apps/ui/src/features/operate/live-debug/lalPayload.test.ts
new file mode 100644
index 0000000..0bcbe3b
--- /dev/null
+++ b/apps/ui/src/features/operate/live-debug/lalPayload.test.ts
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { LalSamplePayload } from '@skywalking-horizon-ui/api-client';
+import {
+ inputEntries,
+ outputEntries,
+ objectEntries,
+ logBuilderOutput,
+ logDataInput,
+} from './lalPayload.js';
+
+describe('objectEntries (generic, format-agnostic dump)', () => {
+ it('returns [] for nullish', () => {
+ expect(objectEntries(null)).toEqual([]);
+ expect(objectEntries(undefined)).toEqual([]);
+ });
+
+ it('dumps scalar keys, skipping the structural keys rendered separately', ()
=> {
+ expect(
+ objectEntries({
+ type: 'LogData',
+ service: 'svc',
+ endpoint: '/api',
+ tags: [{ key: 'k', value: 'v' }],
+ body: { text: 'hi' },
+ content: 'c',
+ }),
+ ).toEqual([
+ { k: 'service', v: 'svc' },
+ { k: 'endpoint', v: '/api' },
+ ]);
+ });
+
+ it('skips nullish + empty-string values but keeps 0', () => {
+ expect(objectEntries({ a: null, b: undefined, c: '', d: 'x', spanId: 0
})).toEqual([
+ { k: 'd', v: 'x' },
+ { k: 'spanId', v: '0' },
+ ]);
+ });
+
+ it('stringifies nested objects compact in the dense cell', () => {
+ expect(objectEntries({ obj: { a: 1 } })).toEqual([{ k: 'obj', v: '{"a":1}'
}]);
+ });
+
+ it('pretty-prints nested objects when full (the popout) to enrich reading',
() => {
+ expect(objectEntries({ obj: { a: 1 } }, true)).toEqual([{ k: 'obj', v:
'{\n "a": 1\n}' }]);
+ });
+});
+
+describe('inputEntries — renders whatever shape the log format carries', () =>
{
+ it('LogData → its OAP keys verbatim', () => {
+ const p = {
+ input: { type: 'LogData', service: 'svc', endpoint: '/api',
serviceInstance: 'inst', layer: 'GENERAL' },
+ } as unknown as LalSamplePayload;
+ expect(inputEntries(p)).toEqual([
+ { k: 'service', v: 'svc' },
+ { k: 'endpoint', v: '/api' },
+ { k: 'serviceInstance', v: 'inst' },
+ { k: 'layer', v: 'GENERAL' },
+ ]);
+ });
+
+ it('Envoy proto that OAP could not serialize → the error, not a blank cell',
() => {
+ const p = {
+ input: {
+ type: 'envoy.data.accesslog.v3.HTTPAccessLogEntry',
+ error: 'jsonformat-failed',
+ detail: 'Cannot find type for url:
type.googleapis.com/google.protobuf.BytesValue',
+ },
+ } as unknown as LalSamplePayload;
+ expect(inputEntries(p)).toEqual([
+ { k: 'error', v: 'jsonformat-failed' },
+ { k: 'detail', v: 'Cannot find type for url:
type.googleapis.com/google.protobuf.BytesValue' },
+ ]);
+ });
+});
+
+describe('outputEntries + logBuilderOutput — tolerant of the builder class',
() => {
+ const envoyOut = {
+ output: {
+ type: 'EnvoyAccessLogBuilder',
+ service: 'mesh-svc',
+ endpoint: 'GET:/x',
+ responseCode: 404,
+ responseFlags: 'NR',
+ timestamp: 1700000000000,
+ content: 'access log line',
+ },
+ } as unknown as LalSamplePayload;
+
+ it('renders an EnvoyAccessLogBuilder output (was dropped as
non-LogBuilder)', () => {
+ expect(outputEntries(envoyOut)).toEqual([
+ { k: 'service', v: 'mesh-svc' },
+ { k: 'endpoint', v: 'GET:/x' },
+ { k: 'responseCode', v: '404' },
+ { k: 'responseFlags', v: 'NR' },
+ { k: 'timestamp', v: '1700000000000' },
+ ]);
+ });
+
+ it('logBuilderOutput accepts any *Builder, rejects non-builders + missing
output', () => {
+ expect(logBuilderOutput({ output: { type: 'LogBuilder' } } as unknown as
LalSamplePayload)).not.toBeNull();
+ expect(
+ logBuilderOutput({ output: { type: 'EnvoyAccessLogBuilder' } } as
unknown as LalSamplePayload),
+ ).not.toBeNull();
+ expect(logBuilderOutput({ output: { type: 'SomethingElse' } } as unknown
as LalSamplePayload)).toBeNull();
+ expect(logBuilderOutput({ input: { type: 'LogData' } } as unknown as
LalSamplePayload)).toBeNull();
+ expect(logBuilderOutput(null)).toBeNull();
+ });
+});
+
+describe('logDataInput — narrows to LogData only', () => {
+ it('LogData passes, other input classes do not', () => {
+ expect(
+ logDataInput({ input: { type: 'LogData', service: 's' } } as unknown as
LalSamplePayload),
+ ).not.toBeNull();
+ expect(
+ logDataInput({ input: { type: 'HTTPAccessLogEntry' } } as unknown as
LalSamplePayload),
+ ).toBeNull();
+ expect(logDataInput(null)).toBeNull();
+ });
+});
diff --git a/apps/ui/src/features/operate/live-debug/lalPayload.ts
b/apps/ui/src/features/operate/live-debug/lalPayload.ts
index e30e803..4de27e3 100644
--- a/apps/ui/src/features/operate/live-debug/lalPayload.ts
+++ b/apps/ui/src/features/operate/live-debug/lalPayload.ts
@@ -106,7 +106,10 @@ export function cellAt(
export function logBuilderOutput(p: LalSamplePayload | null):
LalLogBuilderOutput | null {
if (!p?.output) return null;
- if (p.output.type !== 'LogBuilder') return null;
+ // The snapshot's concrete type is the builder class — `LogBuilder` for
+ // generic logs, `EnvoyAccessLogBuilder` for Envoy ALS — all LogBuilder-
+ // shaped. Accept any `*Builder`, or every Envoy output renders blank.
+ if (!p.output.type.endsWith('Builder')) return null;
return p.output as LalLogBuilderOutput;
}
@@ -116,15 +119,38 @@ export function logDataInput(p: LalSamplePayload | null):
LalLogDataInput | null
return p.input as LalLogDataInput;
}
-export function inputEntries(p: LalSamplePayload | null): KvEntry[] {
- const inp = logDataInput(p);
- if (!inp) return [];
- return [
- { k: 'service', v: inp.service ?? '—' },
- { k: 'endpoint', v: inp.endpoint ?? '—' },
- { k: 'instance', v: inp.serviceInstance ?? '—' },
- { k: 'layer', v: inp.layer ?? '—' },
- ];
+/** Keys rendered in their own groups (or as the cell's format kicker),
+ * not as scalar rows. */
+const STRUCTURAL_KEYS = new Set(['type', 'tags', 'body', 'content']);
+
+/** Generic scalar dump of an LAL payload object. LAL inputs and outputs
+ * are OAP-serialized JSON whose field set varies by log format — a
+ * `LogData` carries service/endpoint/layer, an `EnvoyAccessLogBuilder`
+ * adds responseCode/responseFlags/…, a proto the recorder couldn't
+ * serialize carries `error`/`detail`. Render whatever scalar keys are
+ * present rather than a fixed subset; structural keys (tags/body/content)
+ * and nullish values are skipped, objects are stringified. */
+export function objectEntries(
+ obj: Record<string, unknown> | null | undefined,
+ full = false,
+): KvEntry[] {
+ if (!obj) return [];
+ const out: KvEntry[] = [];
+ for (const [k, v] of Object.entries(obj)) {
+ if (STRUCTURAL_KEYS.has(k)) continue;
+ if (v === null || v === undefined || v === '') continue;
+ // Complex object fields are OAP-serialized JSON — pretty-print them in
+ // the popout (`full`) so nested structures read cleanly; keep them
+ // compact in the dense matrix cell.
+ const value =
+ typeof v === 'object' ? JSON.stringify(v, null, full ? 2 : undefined) :
String(v);
+ out.push({ k, v: value });
+ }
+ return out;
+}
+
+export function inputEntries(p: LalSamplePayload | null, full = false):
KvEntry[] {
+ return objectEntries(p?.input, full);
}
/** The agent-supplied log tags (`code.filepath`, `os.type`,
@@ -157,31 +183,58 @@ export function addedTags(p: LalSamplePayload | null):
LalLogBuilderTag[] {
return out.tags.filter((t) => t.status === 'lal-added' || t.status ===
'lal-override');
}
-export function outputEntries(p: LalSamplePayload | null): KvEntry[] {
- const out = logBuilderOutput(p);
- if (!out) return [];
- return [
- { k: 'service', v: out.service ?? '—' },
- { k: 'endpoint', v: out.endpoint ?? '—' },
- { k: 'timestamp', v: out.timestamp ? String(out.timestamp) : '—' },
- ];
+export function outputEntries(p: LalSamplePayload | null, full = false):
KvEntry[] {
+ return objectEntries(p?.output, full);
}
-export function bodyPreview(p: LalSamplePayload | null): string {
- const inp = logDataInput(p);
- const text = inp?.body?.text;
+/** Pretty-print JSON content (ALS / structured-log content is JSON);
+ * fall back to the raw trimmed string when it isn't parseable JSON. */
+function prettyJson(s: string): string {
+ const t = s.trim();
+ try {
+ return JSON.stringify(JSON.parse(t), null, 2);
+ } catch {
+ return t;
+ }
+}
+
+/** Input body / output content text. `full` returns the complete value
+ * (pretty-printed if JSON) for the cell popout; otherwise a one-glance
+ * preview clipped to 80 chars for the dense matrix cell. */
+export function bodyPreview(p: LalSamplePayload | null, full = false): string {
+ const text = logDataInput(p)?.body?.text;
if (!text) return '';
+ if (full) return prettyJson(text);
const t = text.trim();
return t.length > 80 ? `${t.slice(0, 80)}…` : t;
}
-export function contentPreview(p: LalSamplePayload | null): string {
- const lb = logBuilderOutput(p);
- if (!lb?.content) return '';
- const t = lb.content.trim();
+export function contentPreview(p: LalSamplePayload | null, full = false):
string {
+ const content = logBuilderOutput(p)?.content;
+ if (!content) return '';
+ if (full) return prettyJson(content);
+ const t = content.trim();
return t.length > 80 ? `${t.slice(0, 80)}…` : t;
}
+/** The cell's complete payload object (the `input` or `output` side) as
+ * pretty JSON — the source for the inspect popout's JSON viewer and the
+ * same-format diff. The builder's `content` is itself a JSON string, so
+ * inline it as nested JSON rather than leave it an escaped blob. */
+export function fullJson(p: LalSamplePayload | null, stepType: SampleType):
string {
+ const obj = stepType === 'input' ? p?.input : p?.output;
+ if (!obj) return '';
+ const clone: Record<string, unknown> = { ...(obj as Record<string, unknown>)
};
+ if (typeof clone.content === 'string') {
+ try {
+ clone.content = JSON.parse(clone.content);
+ } catch {
+ /* not JSON — leave the raw string */
+ }
+ }
+ return JSON.stringify(clone, null, 2);
+}
+
/** Free-text filter on log content. A record matches when ANY of its
* samples carry text containing the substring (case-insensitive):
* - input LogData body.text
@@ -194,13 +247,10 @@ export function recordMatches(rec: SessionRecord, q:
string): boolean {
for (const sample of rec.samples ?? []) {
if (!isLalSamplePayload(sample.payload)) continue;
const p = sample.payload;
- if (p.input?.type === 'LogData') {
- const body = (p.input as LalLogDataInput).body;
- const text = body?.text;
- if (typeof text === 'string' && text.toLowerCase().includes(needle))
return true;
- }
- if (p.output?.type === 'LogBuilder') {
- const out = p.output as LalLogBuilderOutput;
+ const text = logDataInput(p)?.body?.text;
+ if (typeof text === 'string' && text.toLowerCase().includes(needle))
return true;
+ const out = logBuilderOutput(p);
+ if (out) {
if (typeof out.content === 'string' &&
out.content.toLowerCase().includes(needle)) {
return true;
}
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index bff323f..4a9b649 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -318,7 +318,13 @@
"{n} captured": "{n} erfasst",
"collapse to full matrix": "auf vollständige Matrix einklappen",
"expand this record to full width": "diesen Eintrag auf volle Breite
ausklappen",
- "function @{line}": "Funktion @{line}",
+ "show complete data": "vollständige Daten anzeigen",
+ "view": "ansehen",
+ "compare with": "vergleichen mit",
+ "diff vs this cell": "Diff zu dieser Zelle",
+ "show all records": "alle Einträge anzeigen",
+ "show only records with data in this row": "nur Einträge mit Daten in dieser
Zeile anzeigen",
+ "function @{line}": "Funktion {'@'}{line}",
"extractor": "Extraktor",
"record {n} · {time}": "Eintrag {n} · {time}",
"{n} / {total} records": "{n} / {total} Einträge",
@@ -934,6 +940,7 @@
"no values in range": "keine Werte im Bereich",
"node": "Knoten",
"none": "keine",
+ "other": "andere",
"normal": "normal",
"not": "nicht",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "nicht MQE-abfragbar · /inspect/entities akzeptiert
REGULAR_VALUE + LABELED_VALUE",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index d772b8d..59d366e 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -322,7 +322,13 @@
"{n} captured": "{n} captured",
"collapse to full matrix": "collapse to full matrix",
"expand this record to full width": "expand this record to full width",
- "function @{line}": "function @{line}",
+ "show complete data": "show complete data",
+ "view": "view",
+ "compare with": "compare with",
+ "diff vs this cell": "diff vs this cell",
+ "show all records": "show all records",
+ "show only records with data in this row": "show only records with data in
this row",
+ "function @{line}": "function {'@'}{line}",
"extractor": "extractor",
"record {n} · {time}": "record {n} · {time}",
"{n} / {total} records": "{n} / {total} records",
@@ -914,6 +920,7 @@
"no values in range": "no values in range",
"node": "node",
"none": "none",
+ "other": "other",
"normal": "normal",
"not": "not",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 5117e2a..9a8da2c 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -318,7 +318,13 @@
"{n} captured": "{n} capturados",
"collapse to full matrix": "contraer a la matriz completa",
"expand this record to full width": "expandir este registro a ancho
completo",
- "function @{line}": "función @{line}",
+ "show complete data": "mostrar datos completos",
+ "view": "ver",
+ "compare with": "comparar con",
+ "diff vs this cell": "diferencia con esta celda",
+ "show all records": "mostrar todos los registros",
+ "show only records with data in this row": "mostrar solo los registros con
datos en esta fila",
+ "function @{line}": "función {'@'}{line}",
"extractor": "extractor",
"record {n} · {time}": "registro {n} · {time}",
"{n} / {total} records": "{n} / {total} registros",
@@ -934,6 +940,7 @@
"no values in range": "sin valores en el rango",
"node": "nodo",
"none": "ninguno",
+ "other": "otros",
"normal": "normal",
"not": "no",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "no consultable por MQE · /inspect/entities acepta
REGULAR_VALUE + LABELED_VALUE",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 4445a7e..ed816cb 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -318,7 +318,13 @@
"{n} captured": "{n} capturés",
"collapse to full matrix": "revenir à la matrice complète",
"expand this record to full width": "développer cet enregistrement sur toute
la largeur",
- "function @{line}": "fonction @{line}",
+ "show complete data": "afficher les données complètes",
+ "view": "voir",
+ "compare with": "comparer avec",
+ "diff vs this cell": "diff avec cette cellule",
+ "show all records": "afficher tous les enregistrements",
+ "show only records with data in this row": "afficher uniquement les
enregistrements avec des données dans cette ligne",
+ "function @{line}": "fonction {'@'}{line}",
"extractor": "extracteur",
"record {n} · {time}": "enregistrement {n} · {time}",
"{n} / {total} records": "{n} / {total} enregistrements",
@@ -934,6 +940,7 @@
"no values in range": "aucune valeur dans la plage",
"node": "nœud",
"none": "aucun",
+ "other": "autres",
"normal": "normal",
"not": "non",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "non interrogeable via MQE · /inspect/entities accepte
REGULAR_VALUE + LABELED_VALUE",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 1b66010..a18b2e6 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -318,7 +318,13 @@
"{n} captured": "{n} 件キャプチャ",
"collapse to full matrix": "全体マトリクスに戻す",
"expand this record to full width": "このレコードを全幅に展開",
- "function @{line}": "関数 @{line}",
+ "show complete data": "完全なデータを表示",
+ "view": "表示",
+ "compare with": "比較対象",
+ "diff vs this cell": "このセルとの差分",
+ "show all records": "すべてのレコードを表示",
+ "show only records with data in this row": "この行にデータがあるレコードのみ表示",
+ "function @{line}": "関数 {'@'}{line}",
"extractor": "抽出処理",
"record {n} · {time}": "レコード {n} · {time}",
"{n} / {total} records": "{n} / {total} 件",
@@ -934,6 +940,7 @@
"no values in range": "範囲内に値がありません",
"node": "ノード",
"none": "なし",
+ "other": "その他",
"normal": "通常",
"not": "影響を受けません",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "MQE クエリ不可 · /inspect/entities は REGULAR_VALUE + LABELED_VALUE
を受け付けます",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 74f28bb..61155d6 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -318,7 +318,13 @@
"{n} captured": "{n}건 캡처됨",
"collapse to full matrix": "전체 매트릭스로 접기",
"expand this record to full width": "이 레코드를 전체 너비로 확장",
- "function @{line}": "함수 @{line}",
+ "show complete data": "전체 데이터 표시",
+ "view": "보기",
+ "compare with": "비교 대상",
+ "diff vs this cell": "이 셀과의 차이",
+ "show all records": "모든 레코드 표시",
+ "show only records with data in this row": "이 행에 데이터가 있는 레코드만 표시",
+ "function @{line}": "함수 {'@'}{line}",
"extractor": "추출기",
"record {n} · {time}": "레코드 {n} · {time}",
"{n} / {total} records": "{n} / {total} 레코드",
@@ -934,6 +940,7 @@
"no values in range": "범위 내 값 없음",
"node": "노드",
"none": "없음",
+ "other": "기타",
"normal": "보통",
"not": "받지 않습니다",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "MQE 쿼리 불가 · /inspect/entities는 REGULAR_VALUE + LABELED_VALUE만
허용",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 5834829..a691189 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -318,7 +318,13 @@
"{n} captured": "{n} capturados",
"collapse to full matrix": "recolher para a matriz completa",
"expand this record to full width": "expandir este registro para toda a
largura",
- "function @{line}": "função @{line}",
+ "show complete data": "mostrar dados completos",
+ "view": "ver",
+ "compare with": "comparar com",
+ "diff vs this cell": "diferença com esta célula",
+ "show all records": "mostrar todos os registros",
+ "show only records with data in this row": "mostrar apenas os registros com
dados nesta linha",
+ "function @{line}": "função {'@'}{line}",
"extractor": "extrator",
"record {n} · {time}": "registro {n} · {time}",
"{n} / {total} records": "{n} / {total} registros",
@@ -934,6 +940,7 @@
"no values in range": "sem valores no intervalo",
"node": "nó",
"none": "nenhum",
+ "other": "outros",
"normal": "normal",
"not": "não",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "não consultável por MQE · /inspect/entities aceita
REGULAR_VALUE + LABELED_VALUE",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json
b/apps/ui/src/i18n/locales/zh-CN.json
index 5a3f108..d6923ed 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -318,7 +318,13 @@
"{n} captured": "已抓取 {n} 条",
"collapse to full matrix": "收起回完整矩阵",
"expand this record to full width": "将该记录展开至全宽",
- "function @{line}": "函数 @{line}",
+ "show complete data": "显示完整数据",
+ "view": "查看",
+ "compare with": "对比",
+ "diff vs this cell": "与该单元格对比",
+ "show all records": "显示所有记录",
+ "show only records with data in this row": "仅显示该行有数据的记录",
+ "function @{line}": "函数 {'@'}{line}",
"extractor": "提取器",
"record {n} · {time}": "记录 {n} · {time}",
"{n} / {total} records": "{n} / {total} 条记录",
@@ -934,6 +940,7 @@
"no values in range": "范围内无数值",
"node": "节点",
"none": "无",
+ "other": "其他",
"normal": "普通",
"not": "不",
"not MQE-queryable · /inspect/entities accepts REGULAR_VALUE +
LABELED_VALUE": "不可通过 MQE 查询 · /inspect/entities 仅接受 REGULAR_VALUE +
LABELED_VALUE",
diff --git a/docs/operate/live-debugger.md b/docs/operate/live-debugger.md
index 1c274f2..7595081 100644
--- a/docs/operate/live-debugger.md
+++ b/docs/operate/live-debugger.md
@@ -58,6 +58,18 @@ When a stage emits many samples that share a metric name,
they are grouped under
Very large groups render a capped number of detail rows with a "+ N more"
note; the summary count is always exact.
+## The LAL pipeline matrix
+
+A LAL capture renders as a grid — one column per captured record, one row per
pipeline step (input, the per-statement or per-block function steps, output).
The first column names each step and stays pinned as you scroll sideways
through the records; each cell holds that record's data at that step.
+
+**It reads any log format.** A cell shows whatever fields OAP serialized for
the record — a plain `LogData` input shows service / endpoint / tags / body,
while an Envoy access-log (ALS) record shows its built snapshot (service,
endpoint, response data, and the access-log content as JSON). When OAP cannot
serialize a record's raw input, the cell shows the reason (for example
`jsonformat-failed …`) instead of rendering blank, and a small label names each
cell's payload class.
+
+**Filter a row to the records that have data.** A step row that has gaps
carries a filter; turning it on narrows the grid to just the records that
produced data for that step — for example the output row to only the records
that emitted output (an abnormal-only rule aborts most records, so only a few
reach output). The row count shows how many of all captured records reached
that step.
+
+**Inspect and diff a cell.** Each cell has a button — `VIEW` on the input row,
`DIFF` on the builder rows — that opens the cell's complete payload in a JSON
viewer with the log content shown as formatted JSON. For the built-log
snapshots you can compare stages: a picker presents the captured rule with each
per-statement step on its line and the extractor / sink blocks as selectable
ranges, and choosing one shows a side-by-side diff of the two snapshots — the
quickest way to see which sta [...]
+
+Each OAP node renders its own matrix; filtering or selecting in one node's
grid does not affect another's.
+
## Capture history
Every session you run is saved to **capture history**, browse it at
`/operate/live-debug/history` (or the *history* link on each tab). History is
stored locally in your browser — it is not shared between users or machines and
survives reloads, with the most recent captures kept per DSL family.