You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by ld...@apache.org on 2026/10/10 13:26:42 UTC
(camel) 03/08: CAMEL-25499: Improve the semantic audit inspector and navigation
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 <ld...@redhat.com>
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 <no...@openai.com>
---
.../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) {