This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit c000a818b01b5ab6d1ac9f0c6d9e6e46e6aca519 Author: Luigi De Masi <[email protected]> AuthorDate: Sat Oct 10 12:49:24 2026 +0200 CAMEL-25499: Improve the semantic audit inspector and navigation Show route decisions beside linked evaluation evidence and retained input. Add readable settings, filter selectors, local timestamps, decision colours, linked-record navigation and pausable live refresh. Include real showcase screenshots in the manual. Co-authored-by: OpenAI Codex <[email protected]> --- .../jbang/camel-tui-semantic-audit-allow.png | Bin 0 -> 407111 bytes .../jbang/camel-tui-semantic-audit-block.png | Bin 0 -> 403161 bytes .../ROOT/pages/camel-jbang-tui-observe.adoc | 31 +- .../jbang/core/commands/tui/SemanticAuditView.java | 541 ++++++++++++++++++--- .../jbang/core/commands/tui/SemanticDetails.java | 5 +- .../src/main/resources/tui/help/semantic.md | 19 +- .../jbang/core/commands/tui/SemanticTabTest.java | 216 +++++++- 7 files changed, 724 insertions(+), 88 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-allow.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-allow.png new file mode 100644 index 000000000000..fe23fb84a63b Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-allow.png differ diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-block.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-block.png new file mode 100644 index 000000000000..586f87ffa778 Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-block.png differ diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc index a1e331ffb55b..2d5e4d43ff1b 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc @@ -471,10 +471,39 @@ connection and is independent of OpenTelemetry. Use `/` for exact `field=value` filters (`category`, `action`, `expert`, `routeId`, `namespace`, `correlationId`, `since`), `n` for older records, `g` for the latest page and `r` to refresh. -Select a record and use `Tab` to focus its inspector, then use the arrows to scroll details and linked evidence. The view reports +The header shows the global audit default, per-expert overrides and OpenTelemetry independently, +with the active filters and reader below. Decisions are coloured by action. Timestamps use the +local timezone shown below the table; the inspector also retains the full timestamp. + +These captures show auditing enabled for the `security` expert and disabled for the +`decisions` expert, with OpenTelemetry and input capture disabled in these screenshots. The expert supplies the evaluation; +the route records its policy decision separately. + +.A blocked request with the linked evaluation that detected prompt injection +image::jbang/camel-tui-semantic-audit-block.png[Semantic Audit showing a BLOCK decision beside its linked security evaluation with result true] + +.An allowed request with the linked evaluation that found no prompt injection +image::jbang/camel-tui-semantic-audit-allow.png[Semantic Audit showing an ALLOW decision beside its linked security evaluation with result false] + +Select a record and use `Tab` to focus its inspector, then use the arrows to scroll. +Wide terminals show the route decision and its linked evaluation side by side; narrow terminals +stack the same details. Use `[` / `]` to choose between multiple evidence records, `Enter` to follow +the selected linked evaluation, and `Esc` or `Enter` to return to the decision. +When input capture is enabled for an expert, the inspector shows the stored *Input* alongside +its evaluation, including linked evidence. Otherwise it shows *not captured* or an omission +reason. Input capture is disabled by default; configure it in the semantic audit DSL. +Use `Tab` and the arrows to scroll longer input. Only the selected state is captured, subject +to its configured size limit and optional redactor. +The latest page refreshes every second; `Space` pauses or resumes polling. Older pages stay fixed +until explicitly refreshed. Filters use exact values; no time window is applied until `since` is set. +The view reports evictions and delivery failures; the built-in memory backend is recent history, not persistent storage. The read-only `tui_get_audit` MCP tool queries the same history, linked evidence and backend health without running an expert or changing the visible screen. Pass `eventId` alone, or filters plus `limit` and the returned `cursor`. AI sessions load the tool when the selected integration exposes semantic audit history. Use `tui_get_table` to inspect the visible Audit screen. `r` refreshes the current audit page; `g` returns to the newest page. Repeated refreshes are coalesced. + +In Audit, `c`, `a`, `e` and `N` open searchable selectors for category, action, expert and +namespace. Use arrows and `Enter` to apply, or `Esc` to cancel. Suggestions include values +on the current page; `/` accepts arbitrary exact filters, including an ISO-8601 `since` timestamp. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java index bc5e9cfad44b..2c0f586adf40 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java @@ -17,13 +17,20 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.CompletableFuture; +import java.util.function.LongSupplier; +import java.util.stream.Collectors; import dev.tamboui.layout.Constraint; import dev.tamboui.layout.Layout; @@ -39,6 +46,7 @@ import dev.tamboui.widgets.block.BorderType; import dev.tamboui.widgets.block.Borders; import dev.tamboui.widgets.input.TextInputState; import dev.tamboui.widgets.paragraph.Paragraph; +import dev.tamboui.widgets.table.Cell; import dev.tamboui.widgets.table.Row; import dev.tamboui.widgets.table.Table; import dev.tamboui.widgets.table.TableState; @@ -52,11 +60,18 @@ final class SemanticAuditView { private static final Set<String> FILTERS = Set.of("category", "action", "expert", "routeId", "namespace", "correlationId", "since"); private final MonitorContext context; + private final LongSupplier nanoTime; + private long lastRefresh; + private boolean paused; + private String returnId; + private int evidenceIndex; private final TableState selection = new TableState(); private String pid; private String filter = ""; private Map<String, String> filters = Map.of(); private TextInputState input; + private AutocompletePopup filterChoices; + private String filterField; private JsonObject page; private JsonObject details; private CompletableFuture<JsonObject> pending; @@ -72,7 +87,12 @@ final class SemanticAuditView { private boolean detailFocused; SemanticAuditView(MonitorContext context) { + this(context, System::nanoTime); + } + + SemanticAuditView(MonitorContext context, LongSupplier nanoTime) { this.context = context; + this.nanoTime = nanoTime; } void reset() { @@ -83,6 +103,10 @@ final class SemanticAuditView { pendingDetails.cancel(true); } pid = null; + paused = false; + returnId = null; + evidenceIndex = 0; + lastRefresh = 0; pending = null; pendingDetails = null; requestedQuery = null; @@ -95,6 +119,8 @@ final class SemanticAuditView { selectedId = null; error = null; input = null; + filterChoices = null; + filterField = null; filter = ""; filters = Map.of(); scroll = 0; @@ -156,6 +182,7 @@ final class SemanticAuditView { if (pending != null && pending.isDone()) { JsonObject response = pending.getNow(null); pending = null; + lastRefresh = nanoTime.getAsLong(); if (queuedQuery != null) { JsonObject next = queuedQuery; queuedQuery = null; @@ -182,19 +209,20 @@ final class SemanticAuditView { if (index < 0) { selection.clearSelection(); if (selectedId != null) { - select(selectedId); + refreshSelected(selectedId); } else { details = null; } } else { selection.select(index); - select(rows.get(index).getString("eventId")); + refreshSelected(rows.get(index).getString("eventId")); } } } if (pendingDetails != null && pendingDetails.isDone()) { JsonObject response = pendingDetails.getNow(null); pendingDetails = null; + lastRefresh = nanoTime.getAsLong(); if (Objects.equals(requestedDetail, selectedId)) { if (response != null && !response.containsKey("error")) { details = response; @@ -211,8 +239,14 @@ final class SemanticAuditView { } boolean ensureLoaded() { + if (!Objects.equals(pid, currentPid())) { + reset(); + pid = currentPid(); + } refresh(); - if (page == null && pending == null && error == null) { + if (pending == null && error == null && (page == null + || pendingDetails == null && !paused && cursor == null && !inputActive() + && nanoTime.getAsLong() - lastRefresh >= 1_000_000_000L)) { load(); } return pending != null || selectedId != null && pendingDetails != null; @@ -234,12 +268,39 @@ final class SemanticAuditView { return rows().stream().filter(r -> Objects.equals(selectedId, r.getString("eventId"))).findFirst().orElse(null); } + private void refreshSelected(String id) { + if (Objects.equals(selectedId, id) && details != null) { + // Keep the inspector and scroll position while refreshing delivery/eviction of linked evidence. + if (pendingDetails == null) { + startDetails(id); + } + } else { + select(id); + } + } + void select(String id) { if (pid == null) { error = "Select a connected integration."; return; } + if (Objects.equals(selectedId, id) && details != null) { + return; + } selectedId = id; + int index = -1; + for (int i = 0; i < rows().size(); i++) { + if (Objects.equals(id, rows().get(i).getString("eventId"))) { + index = i; + break; + } + } + if (index < 0) { + selection.clearSelection(); + } else { + selection.select(index); + } + evidenceIndex = 0; details = null; scroll = 0; if (pendingDetails != null) { @@ -266,6 +327,7 @@ final class SemanticAuditView { } int index = selection.selected() == null ? 0 : selection.selected(); selection.select(Math.max(0, Math.min(rows().size() - 1, index + delta))); + returnId = null; select(rows().get(selection.selected()).getString("eventId")); } @@ -298,6 +360,7 @@ final class SemanticAuditView { filter = text; selection.clearSelection(); selectedId = null; + returnId = null; details = null; page = null; query(null); @@ -309,6 +372,7 @@ final class SemanticAuditView { return setFilter(value); } if ("audit.eventId".equals(field) && !value.isBlank() && value.length() <= 256) { + returnId = null; select(value); return true; } @@ -326,7 +390,7 @@ final class SemanticAuditView { } boolean inputActive() { - return input != null; + return input != null || filterChoices != null; } void paste(String value) { @@ -336,10 +400,21 @@ final class SemanticAuditView { } boolean escape() { + if (filterChoices != null) { + filterChoices = null; + filterField = null; + return true; + } if (input != null) { input = null; return true; } + if (returnId != null) { + String previous = returnId; + returnId = null; + select(previous); + return true; + } if (detailFocused) { detailFocused = false; return true; @@ -353,6 +428,24 @@ final class SemanticAuditView { boolean key(KeyEvent key) { refresh(); + if (filterChoices != null) { + if (filterChoices.handleKeyEvent(key) == AutocompletePopup.Result.CLOSED) { + var choice = filterChoices.consumeSelectedItem(); + if (choice != null) { + Map<String, String> values = new LinkedHashMap<>(filters); + if (choice.insertText().isEmpty()) { + values.remove(filterField); + } else { + values.put(filterField, choice.insertText()); + } + setFilter(values.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(" "))); + } + filterChoices = null; + filterField = null; + } + return true; + } if (input != null) { if (key.isCancel()) { input = null; @@ -365,6 +458,12 @@ final class SemanticAuditView { } return true; } + for (var shortcut : Map.of('c', "category", 'a', "action", 'e', "expert", 'N', "namespace").entrySet()) { + if (key.isChar(shortcut.getKey())) { + chooseFilter(shortcut.getValue()); + return true; + } + } if (key.isChar('/')) { input = new TextInputState(filter); return true; @@ -381,7 +480,35 @@ final class SemanticAuditView { query(null); return true; } - if (key.isKey(KeyCode.TAB) || key.isConfirm()) { + if (key.isChar(' ')) { + paused = !paused; + if (!paused && cursor == null) { + query(null); + } + return true; + } + if (key.isChar('[') || key.isChar(']')) { + int count = SemanticTab.objects(details, "evidence").size(); + if (count > 0) { + evidenceIndex = Math.floorMod(evidenceIndex + (key.isChar('[') ? -1 : 1), count); + scroll = 0; + } + return true; + } + if (key.isConfirm()) { + List<JsonObject> evidence = SemanticTab.objects(details, "evidence"); + if (!evidence.isEmpty()) { + JsonObject linked = evidence.get(Math.min(evidenceIndex, evidence.size() - 1)); + if (!Boolean.TRUE.equals(linked.get("unavailable"))) { + returnId = selectedId; + select(linked.getString("eventId")); + } + } else if (returnId != null) { + escape(); + } + return true; + } + if (key.isKey(KeyCode.TAB)) { detailFocused = !detailFocused; return true; } @@ -392,6 +519,43 @@ final class SemanticAuditView { return false; } + private void chooseFilter(String field) { + Set<String> values = new TreeSet<>(); + if ("category".equals(field)) { + values.addAll(List.of("decision", "evaluation", "request")); + } else if ("action".equals(field)) { + values.addAll(List.of("allow", "block", "review")); + } else if ("expert".equals(field) && page != null + && page.get("audit") instanceof JsonObject audit + && audit.get("experts") instanceof Map<?, ?> experts) { + experts.keySet().forEach(name -> values.add(name.toString())); + } + for (JsonObject row : rows()) { + if (row.get(field) != null) { + values.add(text(row, field)); + } + } + if (filters.containsKey(field)) { + values.add(filters.get(field)); + } + List<AutocompletePopup.CompletionItem> items = new ArrayList<>(); + items.add(new AutocompletePopup.CompletionItem( + "All", "Remove the " + field + " filter.", null, null, + false, null, null, false, "")); + for (String value : values) { + if (!value.isBlank() && value.length() <= 256 && value.chars().noneMatch(Character::isWhitespace)) { + items.add(new AutocompletePopup.CompletionItem( + value, + "Filter by exact " + field + ". Suggestions include values on this page; / accepts any exact value.", + null, null, false, null, null)); + } + } + filterChoices = new AutocompletePopup(items, "", "", true); + filterChoices.setTitlePrefix("Audit " + field); + filterChoices.setFullKeys(true); + filterField = field; + } + boolean detailFocused() { return detailFocused; } @@ -415,6 +579,10 @@ final class SemanticAuditView { state.put("evidence", SemanticTab.objects(details, "evidence")); state.put("filters", filters); state.put("filter", filter); + state.put("paused", paused); + state.put("live", !paused && cursor == null && error == null); + state.put("evidenceIndex", evidenceIndex); + state.put("filterField", filterField); state.put("pending", pending != null || pendingDetails != null); state.put("connected", currentPid() != null && Objects.equals(pid, currentPid())); state.put("error", error); @@ -426,106 +594,310 @@ final class SemanticAuditView { } void render(Frame frame, Rect area) { - refresh(); - List<Rect> panels - = Layout.vertical().constraints(Constraint.length(3), Constraint.percentage(42), Constraint.fill()).split(area); - JsonObject status = page == null ? null : page.getJsonObject("audit"); - String statusLine = status == null - ? "Audit history" : "Master: " + status.get("enabled") + " Experts: " + status.get("experts") - + " OpenTelemetry: " + status.get("openTelemetry") + " Reader: " + status.get("reader") - + " Dropped: " + status.get("dropped") + " Sink errors: " + status.get("sinkErrors"); - String note = error != null ? error : pending != null ? "Loading..." - : "Evicted: " + (page == null ? 0 : page.get("evicted")) - + (page != null && Boolean.TRUE.equals(page.get("cursorExpired")) - ? " Older cursor expired; refresh for retained history." : "") - + (filter.isBlank() ? "" : " " + filter); - frame.renderWidget(Paragraph.builder().text(statusLine + "\n" + note).build(), panels.get(0)); + ensureLoaded(); boolean wide = area.width() >= 140; + List<Rect> panels = Layout.vertical().constraints(Constraint.length(wide ? 4 : 6), + Constraint.length(Math.max(6, Math.min(14, area.height() * 42 / 100))), + Constraint.fill(), Constraint.length(1)).split(area); + JsonObject status = page == null ? null : page.getJsonObject("audit"); + renderHeader(frame, panels.get(0), status); + renderTable(frame, panels.get(1), wide); + renderDetails(frame, panels.get(2)); + renderHealth(frame, panels.get(3), status); + if (filterChoices != null) { + filterChoices.render(frame, area, 2, 0); + } + } + + private void renderHeader(Frame frame, Rect area, JsonObject status) { + List<Span> indicators = new ArrayList<>(); + indicators.add(Span.styled("Audit default: ", Theme.label())); + indicators.add(toggle(status == null ? null : status.get("enabled"))); + if (status != null && status.get("experts") instanceof Map<?, ?> experts) { + experts.forEach((expert, enabled) -> { + indicators.add(Span.styled(" " + expert + ": ", Theme.label())); + indicators.add(toggle(enabled)); + indicators.add(Span.styled(" (override)", Theme.muted())); + }); + } + String otel = text(status, "openTelemetry"); + indicators.add(Span.styled(" OpenTelemetry: ", Theme.label())); + indicators.add(toggle(switch (otel) { + case "active" -> Boolean.TRUE; + case "inactive" -> Boolean.FALSE; + default -> null; + })); + List<Line> lines = new ArrayList<>(); + lines.add(Line.from(indicators)); + lines.add(Line.empty()); + List<Span> query = new ArrayList<>(); + query.add(Span.styled("Filters: ", Theme.label())); + for (String field : List.of("category", "action", "expert", "namespace")) { + String shortcut = "namespace".equals(field) ? "N" : field.substring(0, 1); + String label = Character.toUpperCase(field.charAt(0)) + field.substring(1); + query.add(Span.raw(" [")); + query.add(Span.styled(shortcut, Theme.label().underlined())); + query.add(Span.raw(" " + label + ": " + filters.getOrDefault(field, "all") + " ▾] ")); + } + query.add(Span.styled(filters.containsKey("since") ? " Since " + filters.get("since") : " All retained time", + Theme.muted())); + query.add(Span.styled(" Backend: " + text(status, "reader"), Theme.info())); + query.add(Span.styled(" / edit", Theme.muted())); + lines.add(Line.from(query)); + List<String> other = new ArrayList<>(); + for (String field : List.of("routeId", "correlationId")) { + if (filters.containsKey(field)) { + other.add(field + "=" + filters.get(field)); + } + } + if (error != null) { + lines.add(Line.from(Span.styled(error + " · r to retry", Theme.error()))); + } else if (page != null && Boolean.TRUE.equals(page.get("cursorExpired"))) { + lines.add(Line.from(Span.styled("Older cursor expired; press g for retained history.", Theme.warning()))); + } else if (!other.isEmpty()) { + lines.add(Line.from(Span.styled(String.join(" ", other), Theme.muted()))); + } + SemanticDetails.paragraph(frame, area, lines, 0); + } + + private void renderTable(Frame frame, Rect area, boolean wide) { List<Row> content = new ArrayList<>(); for (JsonObject row : rows()) { - String timestamp = text(row, "timestamp"); - String time = timestamp.length() > 23 ? timestamp.substring(11, 23) + "Z" : timestamp; + Cell action = Cell.from(Span.styled(text(row, "action").toUpperCase(Locale.ROOT), actionStyle(row))); + Cell reason = Cell.from(Span.styled(text(row, "reasonCode"), + "failed".equals(row.get("status")) || "timeout".equals(row.get("status")) + ? Theme.error() : Theme.muted())); content.add(wide - ? Row.from(time, text(row, "action"), text(row, "category"), text(row, "operation"), text(row, "target"), - text(row, "namespace"), text(row, "reasonCode"), text(row, "correlationId")) - : Row.from(time, text(row, "action"), text(row, "category"), text(row, "expert"), text(row, "reasonCode"))); - } - Block tableBlock = block("Audit history [" + rows().size() + "]", !detailFocused); + ? Row.from(Cell.from(time(row, "HH:mm:ss.SSS")), action, Cell.from(text(row, "category")), + Cell.from(text(row, "operation")), Cell.from(text(row, "target")), + Cell.from(text(row, "namespace")), reason, Cell.from(text(row, "correlationId"))) + : Row.from(Cell.from(time(row, "HH:mm:ss.SSS")), action, Cell.from(text(row, "category")), + Cell.from(text(row, "expert")), reason)); + } + Block tableBlock = block("Audit records [" + rows().size() + "] · " + mode(), !detailFocused); + frame.renderWidget(tableBlock, area); + Rect inner = tableBlock.inner(area); + List<Rect> parts = Layout.vertical().constraints(Constraint.fill(), Constraint.length(1)).split(inner); if (content.isEmpty()) { - frame.renderWidget(Paragraph.builder().text("No retained records match this query. Capture may be disabled.") - .block(tableBlock).build(), panels.get(1)); + frame.renderWidget(Paragraph.builder().text(pending != null + ? "Loading audit history…" + : "No retained records match this query. Capture may be disabled.").build(), parts.get(0)); } else { Table table = Table.builder().rows(content) .header((wide - ? Row.from("TIMESTAMP", "ACTION", "CATEGORY", "OPERATION", "TARGET", "NAMESPACE", "REASON CODE", - "CORRELATION") - : Row.from("TIMESTAMP", "ACTION", "CATEGORY", "EXPERT", "REASON CODE")).style(Theme.label())) + ? Row.from("TIMESTAMP", "DECISION", "CATEGORY", "OPERATION", "TARGET", "NAMESPACE", "REASON CODE", + "CORRELATION ID") + : Row.from("TIMESTAMP", "DECISION", "CATEGORY", "EXPERT", "REASON CODE")).style(Theme.label())) .widths(wide - ? List.of(Constraint.length(14), Constraint.length(8), Constraint.length(11), Constraint.length(13), + ? List.of(Constraint.length(14), Constraint.length(9), Constraint.length(11), Constraint.length(13), Constraint.percentage(15), Constraint.percentage(10), Constraint.fill(), - Constraint.length(13)) - : List.of(Constraint.length(14), Constraint.length(8), Constraint.length(11), + Constraint.length(16)) + : List.of(Constraint.length(14), Constraint.length(9), Constraint.length(11), Constraint.percentage(20), Constraint.fill())) - .block(tableBlock).highlightSymbol("› ").highlightStyle(Theme.selectionBg()).build(); - frame.renderStatefulWidget(table, panels.get(1), selection); + .highlightSymbol("› ").highlightStyle(Theme.selectionBg()).build(); + frame.renderStatefulWidget(table, parts.get(0), selection); } - List<Line> lines = new ArrayList<>(); + String date = selected() == null ? "" : time(selected(), "dd MMM uuuu · XXX") + " · "; + SemanticDetails.paragraph(frame, parts.get(1), List.of(Line.from(Span.styled( + "Newest first · " + date + ZoneId.systemDefault() + (wide ? " ↑↓ select · Enter follows linked record" : ""), + Theme.muted()))), 0); + } + + private void renderDetails(Frame frame, Rect area) { JsonObject record = selected(); + Block border = block("Record · " + (selectedId == null ? "none selected" : selectedId), detailFocused); + frame.renderWidget(border, area); + Rect inner = SemanticDetails.inset(border.inner(area)); if (record == null) { - lines.add(Line.from("Select an audit record.")); + SemanticDetails.paragraph(frame, inner, List.of(Line.from(selectedId == null ? "Select an audit record." + : pendingDetails != null ? "Loading record…" : "Record unavailable in this backend.")), 0); + return; + } + boolean decision = "decision".equals(record.get("category")); + Line heading = Line.from(Span.styled(text(record, decision ? "action" : "status").toUpperCase(Locale.ROOT) + " ", + actionStyle(record).bold()), Span.styled(text(record, "target"), Theme.title()), + Span.styled(" " + text(record, "operation") + " · " + text(record, "namespace") + " · " + + text(record, "correlationId"), + Theme.muted())); + List<Line> left = new ArrayList<>(); + String section = switch (text(record, "category")) { + case "decision" -> "Route decision"; + case "request" -> "Evaluation request"; + default -> "Expert evaluation"; + }; + left.add(Line.from(Span.styled(section, Theme.title()))); + if (decision) { + add(left, "Policy", record, "policyId"); + add(left, "Policy version", record, "policyVersion"); + add(left, "Rule", record, "rule"); + add(left, "Route", record, "routeId"); + add(left, "Reason", record, "reasonCode"); + SemanticDetails.add(left, "Action", text(record, "action") + " · reported by the route"); } else { - for (String key : List.of("eventId", "category", "timestamp", "action", "operation", "target", "namespace", - "reasonCode", "correlationId", "policyId", "policyVersion", "rule", "expert", "definition", "origin", - "routeId", "invocationId", "batchId", "status")) { - if (record.get(key) != null) { - lines.add(Line.from(Span.styled(key + " ", Theme.label()), Span.raw(String.valueOf(record.get(key))))); - } + evaluationLines(left, record); + } + left.add(Line.empty()); + for (String field : List.of("timestamp", "startedAt", "exchangeId", "correlationId", "invocationId", "batchId")) { + add(left, switch (field) { + case "startedAt" -> "Started"; + case "exchangeId" -> "Exchange"; + case "correlationId" -> "Correlation"; + case "invocationId" -> "Invocation"; + case "batchId" -> "Batch"; + default -> "Recorded"; + }, record, field); + } + List<Line> right = new ArrayList<>(); + List<JsonObject> evidence = SemanticTab.objects(details, "evidence"); + if (!evidence.isEmpty()) { + JsonObject linked = evidence.get(Math.min(evidenceIndex, evidence.size() - 1)); + right.add(Line.from(Span.styled("Linked evaluation · " + text(linked, "eventId"), Theme.title()))); + if (evidence.size() > 1) { + right.add(Line.from(Span.styled((evidenceIndex + 1) + " / " + evidence.size() + " · [ ] change evidence", + Theme.muted()))); } - resultLines(lines, record); - for (JsonObject linked : SemanticTab.objects(details, "evidence")) { - lines.add(Line.empty()); - lines.add(Line.from(Span.styled("Evidence: " + text(linked, "eventId"), Style.EMPTY.fg(Theme.accent())))); - if (Boolean.TRUE.equals(linked.get("unavailable"))) { - lines.add(Line.from("Unavailable in this backend (disabled, evicted, or not delivered).")); - } else { - lines.add(Line - .from(text(linked, "expert") + " / " + text(linked, "operation") + " · " + text(linked, "status"))); - resultLines(lines, linked); - } + if (Boolean.TRUE.equals(linked.get("unavailable"))) { + right.add(Line.from(Span.styled("Unavailable (disabled, evicted, or not delivered).", Theme.warning()))); + } else { + evaluationLines(right, linked); } - lines.add(Line.empty()); - lines.add(Line.from("Input, headers, arbitrary metadata and submitted score descriptions are omitted.")); + } else if (decision) { + right.add(Line.from(Span.styled(pendingDetails != null ? "Loading linked evidence…" : "No linked evidence.", + Theme.muted()))); + } + if (returnId != null) { + right.add(Line.from(Span.styled("Esc / Enter returns to the decision.", Theme.info()))); + } + Line omitted = Line.from(Span.styled( + "Input captured only by expert opt-in · Exchange headers and arbitrary metadata omitted", Theme.muted())); + if (inner.width() < 100) { + List<Line> all = new ArrayList<>(List.of(heading, Line.empty())); + all.addAll(left); + all.add(Line.empty()); + all.addAll(right); + all.add(omitted); + SemanticDetails.paragraph(frame, inner, all, scroll); + } else { + List<Rect> parts = Layout.vertical().constraints(Constraint.length(2), Constraint.fill(), Constraint.length(1)) + .split(inner); + SemanticDetails.paragraph(frame, parts.get(0), List.of(heading), 0); + List<Rect> columns = Layout.horizontal().constraints(Constraint.percentage(48), Constraint.length(3), + Constraint.fill()).split(parts.get(1)); + SemanticDetails.paragraph(frame, columns.get(0), left, scroll); + frame.renderWidget(Block.builder().borders(EnumSet.of(Borders.LEFT)).borderStyle(Theme.border()).build(), + columns.get(1)); + SemanticDetails.paragraph(frame, columns.get(2), right, scroll); + SemanticDetails.paragraph(frame, parts.get(2), List.of(omitted), 0); } - Block detailsBlock = block("Record details", detailFocused); - frame.renderWidget(detailsBlock, panels.get(2)); - SemanticDetails.paragraph(frame, detailsBlock.inner(panels.get(2)), lines, scroll); } - private static void resultLines(List<Line> lines, JsonObject record) { - for (String key : List.of("startedAt", "provider", "model", "revision")) { - if (record.get(key) != null) { - lines.add(Line.from(Span.styled(key + " ", Theme.label()), Span.raw(String.valueOf(record.get(key))))); + private static void evaluationLines(List<Line> lines, JsonObject record) { + add(lines, "Definition", record, "definition"); + SemanticDetails.add(lines, "Expert", text(record, "expert") + " · " + text(record, "operation")); + if (record.containsKey("input")) { + SemanticDetails.parameter(lines, "", + Boolean.TRUE.equals(record.get("inputRedacted")) ? "Input (redacted): " : "Input: ", + record.get("input")); + } else { + SemanticDetails.add(lines, "Input", record.containsKey("inputOmitted") + ? "omitted · " + text(record, "inputOmitted") : "not captured"); + } + JsonObject semantics = record.getJsonObject("semantics"); + JsonObject result = record.getJsonObject("result"); + if (result != null) { + lines.add(Line.from(Span.styled("Result: ", Theme.label()), Span.styled(text(result, "value"), Theme.info().bold()), + Span.styled(" · " + text(semantics, "resultType").toLowerCase(Locale.ROOT), Theme.muted()))); + add(lines, "Meaning", semantics, "meaning"); + add(lines, "Probability", result, "probability"); + if (result.get("probabilities") instanceof Map<?, ?> probabilities) { + probabilities.forEach((label, value) -> SemanticDetails.add(lines, "P(" + label + ")", String.valueOf(value))); } + add(lines, "Probability meaning", semantics, "probabilityMeaning"); + add(lines, "Confidence", result, "confidence"); + add(lines, "Confidence meaning", semantics, "confidenceMeaning"); + } + add(lines, "Result unavailable", record, "resultOmitted"); + add(lines, "Status", record, "status"); + if (!"success".equals(record.get("status"))) { + add(lines, "Reason", record, "reasonCode"); + } + if (record.get("durationNanos") instanceof Number duration) { + SemanticDetails.add(lines, "Duration", SemanticResultView.number(duration.doubleValue() / 1_000_000) + " ms"); } - if (record.get("resultOmitted") != null) { - lines.add(Line.from("Result unavailable: " + text(record, "resultOmitted"))); + for (String field : List.of("origin", "provider", "model", "revision")) { + add(lines, Character.toUpperCase(field.charAt(0)) + field.substring(1), record, field); } - if (!(record.get("result") instanceof JsonObject result)) { + } + + private static void add(List<Line> lines, String label, JsonObject record, String field) { + if (record != null && record.get(field) != null) { + SemanticDetails.add(lines, label, text(record, field)); + } + } + + private void renderHealth(Frame frame, Rect area, JsonObject status) { + if (status == null) { return; } - JsonObject response = new JsonObject(result); - response.put("status", "success"); - response.put("elapsedMillis", record.get("durationNanos") instanceof Number n ? n.longValue() / 1000000 : 0); - JsonObject semantics = record.getJsonObject("semantics"); - JsonObject contract = new JsonObject(); - if (semantics != null) { - contract.put("resultMeaning", semantics.get("meaning")); - contract.put("probabilityMeaning", semantics.get("probabilityMeaning")); + List<Span> health = new ArrayList<>(); + if ("memory".equals(status.get("reader"))) { + health.add(Span.styled("Memory: " + text(status, "retained") + " / " + text(status, "capacity") + " retained · ", + Theme.muted())); + } + health.add(Span.styled(text(page, "evicted") + " evicted · " + text(status, "dropped") + " dropped ", Theme.muted())); + List<String> failures = new ArrayList<>(); + if (status.get("sinkErrors") instanceof Map<?, ?> sinks) { + sinks.forEach((name, count) -> { + if (count instanceof Number n && n.longValue() > 0) { + failures.add(name + ": " + n + " errors"); + } + }); + } + for (String field : List.of("dropped", "observerErrors")) { + if (status.get(field) instanceof Number n && n.longValue() > 0) { + failures.add(n + ("dropped".equals(field) ? " dropped" : " observer errors")); + } + } + if (error != null) { + failures.add("query failed · showing last response"); + } + health.add(Span.styled(failures.isEmpty() ? "● No delivery errors reported" : "● " + String.join(" · ", failures), + failures.isEmpty() ? Theme.success() : Theme.warning())); + health.add(Span.styled(" · " + mode() + (pending != null ? " · refreshing…" : ""), Theme.muted())); + SemanticDetails.paragraph(frame, area, List.of(Line.from(health)), 0); + } + + private String mode() { + if (paused) { + return "paused"; + } + if (cursor != null) { + return "older page"; + } + return error == null ? "live · refresh 1 s" : "refresh stopped"; + } + + private static Span toggle(Object value) { + return Span.styled(value == null ? "UNKNOWN" : Boolean.TRUE.equals(value) ? "ON" : "OFF", + Boolean.TRUE.equals(value) ? Theme.success() : Theme.muted()); + } + + private static Style actionStyle(JsonObject record) { + return switch (text(record, "action")) { + case "allow" -> Theme.success(); + case "block", "deny", "reject" -> Theme.error(); + case "review" -> Theme.warning(); + default -> Theme.muted(); + }; + } + + private static String time(JsonObject record, String pattern) { + try { + return DateTimeFormatter.ofPattern(pattern, Locale.ROOT).withZone(ZoneId.systemDefault()) + .format(Instant.parse(text(record, "timestamp"))); + } catch (RuntimeException e) { + return text(record, "timestamp"); } - JsonObject operation = new JsonObject(); - operation.put("contract", contract); - operation.put("resultType", semantics == null ? "" : semantics.get("resultType")); - lines.addAll(SemanticResultView.lines(response, operation, List.of())); } private static Block block(String title, boolean focused) { @@ -534,14 +906,21 @@ final class SemanticAuditView { } void footer(List<Span> spans) { - if (input != null) { + if (filterChoices != null) { + hint(spans, "↑↓", "select"); + hint(spans, "Enter", "apply"); + hint(spans, "Esc", "cancel"); + } else if (input != null) { spans.add(Span.raw(" /" + input.text() + "█ ")); hint(spans, "Enter", "apply"); hint(spans, "Esc", "cancel"); } else { hint(spans, "v", "view"); hint(spans, "/", "filter"); - hint(spans, "Tab", "pane"); + hint(spans, "↑↓", "select"); + hint(spans, "Tab", "detail"); + hint(spans, "Space", paused ? "resume" : "pause"); + hint(spans, "Enter", returnId == null ? "related record" : "decision"); hint(spans, "n", "older"); hint(spans, "g", "latest"); hint(spans, "r", "refresh"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java index 2ac39f71cb8a..c49b8ef6865c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java @@ -92,7 +92,8 @@ final class SemanticDetails { map.forEach((name, value) -> parameter(lines, " ", name + ": ", value)); } - private static void parameter(List<Line> lines, String indent, String label, Object value) { + static void parameter(List<Line> lines, String indent, String label, Object value) { + label = label.replaceAll("[\\p{Cntrl}\\p{Cf}]", ""); if (value instanceof Map<?, ?> map && !map.isEmpty()) { lines.add(Line.from(Span.styled(indent + label.stripTrailing(), Theme.label()))); map.forEach((name, child) -> parameter(lines, indent + " ", "• " + name + ": ", child)); @@ -105,7 +106,7 @@ final class SemanticDetails { // Put text under its label so wrapped prose keeps its place in the parameter tree. lines.add(Line.from(Span.styled(indent + label.stripTrailing(), Theme.label()))); for (String part : text.split("\\R", -1)) { - lines.add(Line.from(indent + " " + (part.isEmpty() ? "\"\"" : part))); + lines.add(Line.from(indent + " " + (part.isEmpty() ? "\"\"" : part.replaceAll("[\\p{Cntrl}\\p{Cf}]", "")))); } } else { lines.add(Line.from(Span.styled(indent + label, Theme.label()), Span.raw(String.valueOf(value)))); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md index b950b7140b85..5a8bd444c72b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md @@ -104,7 +104,7 @@ Auditing and OpenTelemetry are independent. - `/` edits exact `field=value` filters, separated by spaces. Available fields are `category`, `action`, `expert`, `routeId`, `namespace`, `correlationId` and `since`. Example: `expert=security since=2026-10-09T12:00:00Z`. -- Up/Down select a record. `Tab` or Enter focuses the inspector; arrows scroll its details. +- Up/Down select a record. `Tab` focuses the inspector; arrows scroll its details. - `n` loads older records; `g` returns to the latest page; `r` refreshes the current page. - Esc leaves details or clears the current filter. @@ -120,3 +120,20 @@ Pass `eventId` alone to inspect a record and its evidence. For pages, pass `cate `routeId`, `namespace`, `correlationId`, `since`, `limit` and a returned `cursor` as needed. `tui_get_table` reads the visible Audit screen; `tui_set_input` changes it explicitly. Audit refreshes are coalesced while a query is pending; changed filters use the latest submitted value. + +### Audit inspector and live history + +Audit shows the global default and per-expert overrides separately from OpenTelemetry. +The filter bar shows the current query; `/` edits exact `field=value` filters. With no `since`, +the query includes all retained time. Timestamps use the local timezone displayed below the table. +Route decisions and linked evaluations appear side by side on wide terminals and stack on narrow terminals. +`Tab` focuses details; arrows scroll. `[` / `]` choose evidence when there is more than one linked record. +`Enter` follows that evaluation; `Esc` or `Enter` returns to the decision. +The newest page refreshes every second. `Space` pauses/resumes; older pages do not auto-refresh. +The footer reports retention, evictions, dropped records and delivery errors. + +In Audit, `c`, `a`, `e` and `N` open searchable selectors for category, action, expert and +namespace. Use arrows and `Enter` to apply, or `Esc` to cancel. Suggestions include values +on the current page; `/` accepts arbitrary exact filters, including an ISO-8601 `since` timestamp. + +Audit input capture is disabled by default. Per-expert `audit.experts.<name>.input.enabled` retains the selected state; the inspector shows Input, Input (redacted), or an omission reason. Tab and arrows scroll longer input. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java index ccdc8d3933ed..03642931cf93 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java @@ -23,6 +23,7 @@ import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -91,11 +92,201 @@ class SemanticTabTest { tab.setInputValue("audit.view", ""); await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); String rendered = TuiTestHelper.renderToString(tab, 180, 45); - assertThat(rendered).contains("TIMESTAMP", "REASON CODE", "tools/call", "support-request", "Master: true"); + assertThat(rendered).contains("TIMESTAMP", "REASON CODE", "tools/call", "support-request", "Audit default: ON", + "security: ON (override)", "decisions: OFF (override)", "OpenTelemetry: OFF", "Filters:", + "Route decision", "Linked evaluation", "Definition: screenPrompt", "Result: true", "BLOCK", + "Policy: support-access", "Model: detector-v2", "Input captured only by expert opt-in", "Memory: 2 / 1000", + "No delivery errors reported", "refresh 1 s"); assertThat(TuiTestHelper.renderToString(tab, 80, 24)).contains("CATEGORY", "EXPERT"); } } + @Test + void auditRendersStoredInputAndOmissionReasonsInLinkedAndDirectRecords() throws Exception { + try (var runtime = new Runtime()) { + runtime.auditInput.put("input", Map.of("prompt", "hello\nworld\u001b[31m")); + runtime.auditInput.put("inputRedacted", true); + var tab = loaded(runtime); + tab.setInputValue("audit.view", ""); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(TuiTestHelper.renderToString(tab, 180, 55)).contains("Input (redacted)", "prompt", "hello", "world") + .doesNotContain("\u001b"); + tab.setInputValue("audit.eventId", "evaluation-1"); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(TuiTestHelper.renderToString(tab, 180, 55)).contains("Input (redacted)", "hello"); + runtime.auditInput.clear(); + runtime.auditInput.put("inputOmitted", "redaction_failed"); + tab.handleKeyEvent(KeyEvent.ofChar('r')); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(TuiTestHelper.renderToString(tab, 180, 55)).contains("Input: omitted", "redaction_failed"); + } + } + + @Test + void auditRefreshesLinkedEvidenceWithoutClearingTheInspector() throws Exception { + try (var runtime = new Runtime()) { + runtime.multipleAuditEvidence = true; + var tab = loaded(runtime); + tab.setInputValue("audit.view", ""); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("Unavailable"); + runtime.multipleAuditEvidence = false; + tab.handleKeyEvent(KeyEvent.ofChar('r')); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", "decision-2"); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("Result: true").doesNotContain("Unavailable"); + } + } + + @Test + void auditFilterSelectorsApplyOnlyTheChosenFieldAndCanBeCancelled() throws Exception { + try (var runtime = new Runtime()) { + var tab = loaded(runtime); + tab.setInputValue("audit.view", ""); + tab.setInputValue("audit.filter", "namespace=default"); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + tab.handleKeyEvent(KeyEvent.ofChar('c')); + assertThat(tab.getTableDataAsJson()).containsEntry("filterField", "category"); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("Audit category"); + for (char c : "decision".toCharArray()) { + tab.handleKeyEvent(KeyEvent.ofChar(c)); + } + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER)); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(new JsonObject(tab.getTableDataAsJson().getMap("filters"))) + .containsEntry("category", "decision").containsEntry("namespace", "default"); + tab.handleKeyEvent(KeyEvent.ofChar('a')); + tab.handleEscape(); + assertThat(new JsonObject(tab.getTableDataAsJson().getMap("filters"))).doesNotContainKey("action"); + tab.handleKeyEvent(KeyEvent.ofChar('c')); + for (char c : "All".toCharArray()) { + tab.handleKeyEvent(KeyEvent.ofChar(c)); + } + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER)); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(new JsonObject(tab.getTableDataAsJson().getMap("filters"))) + .containsOnlyKeys("namespace"); + } + } + + @Test + void auditSelectsAmongEvidenceAndDoesNotFollowUnavailableRecords() throws Exception { + try (var runtime = new Runtime()) { + runtime.multipleAuditEvidence = true; + var tab = loaded(runtime); + tab.setInputValue("audit.view", ""); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("evicted-1", "1 / 2", "Unavailable"); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER)); + assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", "decision-2"); + tab.handleKeyEvent(KeyEvent.ofChar(']')); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("2 / 2", "Result: true"); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER)); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", "evaluation-1"); + } + } + + @Test + void auditShowsDeliveryFailuresAndStaleDataAfterAQueryFailure() throws Exception { + try (var runtime = new Runtime()) { + runtime.auditHealth = new JsonObject(Map.of("sinkErrors", Map.of("log", 3), "dropped", 2)); + var tab = loaded(runtime); + tab.setInputValue("audit.view", ""); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("log: 3 errors", "2 dropped") + .doesNotContain("No delivery errors reported"); + runtime.auditError = true; + tab.handleKeyEvent(KeyEvent.ofChar('r')); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)) + .contains("audit_query_failed", "showing last response", "refresh stopped", "support-request"); + } + } + + @Test + void auditRendersWhileTheFirstQueryIsPending() throws Exception { + try (var runtime = new Runtime()) { + var tab = loaded(runtime); + runtime.pageEntered = new CountDownLatch(1); + runtime.pageRelease = new CountDownLatch(1); + tab.setInputValue("audit.view", ""); + assertThat(runtime.pageEntered.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)) + .contains("Audit default: UNKNOWN", "OpenTelemetry: UNKNOWN", "Loading audit history"); + assertThat(TuiTestHelper.renderToString(tab, 80, 24)).contains("Loading audit history"); + } + } + + @Test + void auditLiveRefreshWaitsForSlowDetailsBeforeStartingItsNextInterval() throws Exception { + try (var runtime = new Runtime()) { + AtomicLong clock = new AtomicLong(); + SemanticAuditView view = new SemanticAuditView(runtime, clock::get); + await().atMost(5, TimeUnit.SECONDS).until(() -> !view.ensureLoaded()); + runtime.detailsEntered = new CountDownLatch(1); + runtime.detailsRelease = new CountDownLatch(1); + view.select("evaluation-1"); + assertThat(runtime.detailsEntered.await(5, TimeUnit.SECONDS)).isTrue(); + clock.addAndGet(2_000_000_000L); + assertThat(view.ensureLoaded()).isTrue(); + assertThat(auditQueries(runtime)).isEqualTo(1); + runtime.detailsRelease.countDown(); + await().atMost(5, TimeUnit.SECONDS).until(() -> !view.ensureLoaded()); + assertThat(auditQueries(runtime)).isEqualTo(1); + clock.addAndGet(1_000_000_000L); + await().atMost(5, TimeUnit.SECONDS).until(() -> !view.ensureLoaded()); + assertThat(auditQueries(runtime)).isEqualTo(2); + } + } + + @Test + void auditLiveRefreshCanPauseAndNeverReplacesOlderPages() throws Exception { + try (var runtime = new Runtime()) { + runtime.pagedAudit = true; + AtomicLong clock = new AtomicLong(); + SemanticAuditView view = new SemanticAuditView(runtime, clock::get); + await().atMost(5, TimeUnit.SECONDS).until(() -> !view.ensureLoaded()); + assertThat(auditQueries(runtime)).isEqualTo(1); + clock.addAndGet(1_000_000_000L); + await().atMost(5, TimeUnit.SECONDS).until(() -> !view.ensureLoaded()); + assertThat(auditQueries(runtime)).isEqualTo(2); + view.key(KeyEvent.ofChar(' ')); + clock.addAndGet(10_000_000_000L); + assertThat(view.ensureLoaded()).isFalse(); + assertThat(view.snapshot()).containsEntry("paused", true).containsEntry("live", false); + assertThat(auditQueries(runtime)).isEqualTo(2); + view.key(KeyEvent.ofChar(' ')); + await().atMost(5, TimeUnit.SECONDS).until(() -> !view.ensureLoaded()); + assertThat(auditQueries(runtime)).isEqualTo(3); + view.key(KeyEvent.ofChar('n')); + await().atMost(5, TimeUnit.SECONDS).until(() -> !view.ensureLoaded()); + clock.addAndGet(10_000_000_000L); + assertThat(view.ensureLoaded()).isFalse(); + assertThat(auditQueries(runtime)).isEqualTo(4); + assertThat(view.snapshot()).containsEntry("live", false); + } + } + + @Test + void auditFollowsEvidenceAndReturnsToTheDecisionWithoutChangingFilters() throws Exception { + try (var runtime = new Runtime()) { + var tab = loaded(runtime); + tab.setInputValue("audit.view", ""); + tab.setInputValue("audit.filter", "category=decision namespace=default"); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER)); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", "evaluation-1") + .containsEntry("filter", "category=decision namespace=default"); + assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("Expert evaluation", "Result: true", + "Esc / Enter returns to the decision"); + tab.handleEscape(); + await().atMost(5, TimeUnit.SECONDS).until(() -> !tab.ensureDataLoaded()); + assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", "decision-2"); + } + } + @Test void auditFilterDiscardsPendingDetailsWhenTheNewPageIsEmpty() throws Exception { try (var runtime = new Runtime()) { @@ -1008,6 +1199,10 @@ class SemanticTabTest { volatile CountDownLatch pageEntered; volatile CountDownLatch pageRelease; volatile boolean pagedAudit; + volatile boolean multipleAuditEvidence; + volatile boolean auditError; + JsonObject auditHealth = new JsonObject(); + JsonObject auditInput = new JsonObject(); volatile CountDownLatch detailsRelease; volatile CountDownLatch detailsEntered; volatile boolean detailsFinished; @@ -1032,10 +1227,17 @@ class SemanticTabTest { firstFinished = true; } if ("semantic-audit".equals(request.getString("action"))) { + if (auditError) { + return new JsonObject(Map.of("error", "audit_query_failed")); + } JsonObject evaluation = new JsonObject( Map.of("eventId", "evaluation-1", "category", "evaluation", "expert", "security", "operation", "injection", "status", "success", "timestamp", "2026-10-09T14:20:29.411Z", "reasonCode", "evaluation_completed", "result", new JsonObject(Map.of("value", true)))); + evaluation.put("definition", "screenPrompt"); + evaluation.putAll(auditInput); + evaluation.put("semantics", + new JsonObject(Map.of("meaning", "Injection detected", "resultType", "BOOLEAN"))); evaluation.put("provider", "test"); evaluation.put("model", "detector-v2"); evaluation.put("revision", "abc123"); @@ -1043,10 +1245,18 @@ class SemanticTabTest { Map.of("eventId", "decision-2", "category", "decision", "action", "block", "operation", "tools/call", "target", "support-request", "reasonCode", "prompt_injection", "timestamp", "2026-10-09T14:20:29.418Z", "evidence", List.of("evaluation-1"))); + decision.put("policyId", "support-access"); + decision.put("namespace", "default"); JsonObject status = new JsonObject( - Map.of("enabled", true, "experts", new JsonObject(Map.of("security", true)), + Map.of("enabled", true, "experts", new JsonObject(Map.of("security", true, "decisions", false)), "reader", "memory", "dropped", 0, "sinkErrors", new JsonObject(), "openTelemetry", "inactive")); + status.put("retained", 2); + status.put("capacity", 1000); + status.putAll(auditHealth); + List<JsonObject> evidence = multipleAuditEvidence + ? List.of(new JsonObject(Map.of("eventId", "evicted-1", "unavailable", true)), evaluation) + : List.of(evaluation); if (request.getString("eventId") != null) { if (detailsRelease != null) { detailsEntered.countDown(); @@ -1058,7 +1268,7 @@ class SemanticTabTest { return new JsonObject( Map.of("record", "evaluation-1".equals(request.getString("eventId")) ? evaluation : decision, "evidence", - "evaluation-1".equals(request.getString("eventId")) ? List.of() : List.of(evaluation), + "evaluation-1".equals(request.getString("eventId")) ? List.of() : evidence, "audit", status)); } if (pageRelease != null) {
