From 9ecb469d131e9fa18b15acf24ae8bb4cd07ab310 Mon Sep 17 00:00:00 2001
From: TyperBody
Date: Mon, 28 Sep 2026 01:03:02 +0800
Subject: [PATCH] feat(operation-trace): lead with the change, fix pipeline
extension tracing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The panel answered "who, when, what" poorly: the actual before → after diff
was hidden behind a click, the actor was a muted line, and a whole row went to
the route while duration and hash sat in a permanent right column. The data
already arrives with the page, so the panel now reads top-down in the order an
operator actually looks:
* who and when first, then the action and resource, then the diff spelled out
inline (long values collapse to their size so a pipeline config does not
render as two near-identical blobs), with route / method / status / duration
/ evidence hash moved into the expanded diagnostics line;
* the whole card is the expand toggle with a rotating chevron, which removes
the ambiguity of a small text link whose expanded state looked identical;
* records are bucketed into today / yesterday / dated groups so a long history
reads as a timeline;
* a "changes only" toggle (reusing the level filter) answers "who changed
something" in one click -- in the live log 489 of 499 rows are pure views,
so this is the difference between a haystack and the ten rows that matter.
Tracing fixes found while reading the live log:
* PUT /pipelines//extensions was classified as a plugin config change
because the generic ('/extensions') rule ran first; it now maps to a
pipeline-specific action;
* that handler recorded no diff at all, so "modified extension config" could
never say what changed. It now captures the previous bindings and reports the
before → after.
Cache: the integrity result is now keyed by (Workspace, listing filters) so
each view reuses its own verification, and a delete invalidates all views for
the Workspace. Fourteen tests cover the projection, the verifier, the cache
lifecycle and the failure modes.
---
.../controller/groups/pipelines/pipelines.py | 34 ++
src/langbot/pkg/operation_trace/service.py | 76 ++-
.../test_operation_trace_incremental.py | 47 +-
.../OperationTracePanel.tsx | 536 ++++++++++++------
web/src/i18n/locales/en-US.ts | 8 +
web/src/i18n/locales/es-ES.ts | 9 +
web/src/i18n/locales/ja-JP.ts | 8 +
web/src/i18n/locales/ru-RU.ts | 8 +
web/src/i18n/locales/th-TH.ts | 8 +
web/src/i18n/locales/vi-VN.ts | 8 +
web/src/i18n/locales/zh-Hans.ts | 8 +
web/src/i18n/locales/zh-Hant.ts | 8 +
12 files changed, 554 insertions(+), 204 deletions(-)
diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
index 79a839bc3..dc11d75a4 100644
--- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
+++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
@@ -174,7 +174,41 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
+ quart.g.operation_log_resource_id = pipeline_uuid
json_data = await quart.request.json
+ # Capture "what was bound before" so the trace answers "what changed
+ # into what": without this the log could only say an extension was
+ # configured, never which binding was added or removed.
+ try:
+ previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
+ except Exception:
+ previous = None
+ # The request uses ``bound_*`` keys while the stored preferences use
+ # the canonical names, so both sides are normalized before diffing.
+ requested = {
+ canonical: json_data[field]
+ for field, canonical in {
+ 'bound_plugins': 'plugins',
+ 'bound_mcp_servers': 'mcp_servers',
+ 'bound_skills': 'skills',
+ 'bound_mcp_resources': 'mcp_resources',
+ 'enable_all_plugins': 'enable_all_plugins',
+ 'enable_all_mcp_servers': 'enable_all_mcp_servers',
+ 'enable_all_skills': 'enable_all_skills',
+ 'mcp_resource_agent_read_enabled': 'mcp_resource_agent_read_enabled',
+ }.items()
+ if field in json_data
+ }
+ if isinstance(previous, dict):
+ current = {
+ **normalize_extension_preferences(previous.get('extensions_preferences')),
+ **requested,
+ }
+ else:
+ current = requested
+ changes = settings_service.changed_fields(current, requested)
+ if changes:
+ quart.g.operation_log_changes = changes
try:
validate_extension_preferences(
{
diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py
index ec60a2efb..8df706640 100644
--- a/src/langbot/pkg/operation_trace/service.py
+++ b/src/langbot/pkg/operation_trace/service.py
@@ -409,6 +409,13 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = (
bucket='write',
resource_type='mcp_server',
),
+ # --- Pipelines -------------------------------------------------------
+ ActionRule(
+ action='pipeline_extensions_update',
+ category='resource',
+ bucket='write',
+ resource_type='pipeline',
+ ),
# --- Generic resource verbs -----------------------------------------
ActionRule(
action='export',
@@ -491,7 +498,11 @@ _ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str], ...]] = (
(('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade'),
(('/plugins/', '/logs'), 'plugin_view', 'plugin_view'),
(('/plugins',), 'plugin_view', 'plugin_view'),
- (('/extensions',), 'plugin_view', 'plugin_config'),
+ # The pipeline extension bindings (plugins / MCP servers / skills) live on
+ # ``/pipelines//extensions``, not on a plugin. Without this rule the
+ # generic fragment below would classify the change as a plugin config edit.
+ # The read keeps the generic ``view`` for backwards-compatible labelling.
+ (('/extensions',), 'view', 'pipeline_extensions_update'),
# --- Extension lifecycle: skills ------------------------------------
(('/skills/', '/install'), 'skill_view', 'skill_install'),
(('/skills',), 'skill_view', 'skill_view'),
@@ -977,6 +988,11 @@ class WorkspaceSettingsService:
self._policy_cache_ttl = 5.0
# Inserts since the last row-budget check, keyed by Workspace UUID.
self._insert_counters: dict[str, int] = {}
+ # Verification cache keyed by (Workspace UUID, listing filters). The
+ # verification result is shareable across whatever narrows the listing,
+ # so keying on the whole filter set (not just the Workspace) lets the
+ # panel's common views -- "everything" and "mutations only" -- both hit
+ # the cache instead of forcing a full rescan on every page turn.
# Single-writer queue. Trace recording must never run on the request
# path: when tracing is enabled the WebUI fires a burst of parallel
# requests (login alone touches a dozen endpoints) and doing several
@@ -1672,10 +1688,12 @@ class WorkspaceSettingsService:
if until is not None:
filters.append(model.created_at <= until)
- # Verify the filtered history once per request. This replaces the
- # previous per-page sum, which made the counters silently restart at
- # zero on every page after the first.
- summary = await self._integrity_summary(model, filters)
+ # Verify the filtered history once per request, keyed by the listing
+ # filters so a view (all / mutations only / a resource) can reuse an
+ # already computed result while still paging. ``integrity`` is not
+ # part of the key: it only narrows *which* records are returned, not
+ # what the verification finds, so its views share the result too.
+ summary = await self._integrity_summary(model, filters, resolved_integrity)
# ``total`` follows the active listing filter so the pagination badge
# and the pager stay consistent with what the operator asked to see.
@@ -1758,6 +1776,17 @@ class WorkspaceSettingsService:
'truncated': False,
}
+ def _invalidate_integrity_cache(self, workspace_uuid: str) -> None:
+ """Drop every cached verification for one Workspace.
+
+ The cache is keyed by ``(Workspace, listing filters)`` so each view has
+ its own entry; a deletion invalidates all of them, not just the
+ unfiltered one.
+ """
+
+ for key in [key for key in self._integrity_cache if key[0] == workspace_uuid]:
+ self._integrity_cache.pop(key, None)
+
@staticmethod
def _verified_lists(verified: dict[int, tuple[bool, bool]]) -> tuple[list[int], list[int], list[int]]:
"""Split a verification map into (tampered, hash-failed, chain-failed) ids."""
@@ -1790,17 +1819,20 @@ class WorkspaceSettingsService:
self,
model: typing.Any,
filters: list[typing.Any],
+ integrity_filter: str = INTEGRITY_FILTER_ALL,
) -> dict[str, typing.Any]:
"""Verify the filtered history and classify every failing record.
A result computed within :data:`INTEGRITY_CACHE_TTL_SECONDS` is served
- from the per-Workspace cache, which is what keeps the panel responsive:
- opening, refreshing and paging all land inside that window and pay
- nothing. A cache miss re-verifies the whole scan window from scratch --
- the scan projects only the hash columns and the verifier only computes
- two booleans, so the cost is bounded and, crucially, an edit to a row
- that was verified on a previous pass is still caught. The cache is
- skipped for filtered queries, which cannot share a full-history scan.
+ from the cache, which is what keeps the panel responsive: opening,
+ refreshing and paging all land inside that window and pay nothing. The
+ cache key is ``(Workspace, listing filters)`` -- an integrity drill-down
+ reuses the very result it is drilling into, and both "everything" and
+ "mutations only" are cached independently -- so a cache miss is rare.
+ A miss re-verifies the *whole* window from scratch: the scan projects
+ only the hash columns and the verifier only computes two booleans, so
+ the cost is bounded, and an edit to a row verified on a previous pass is
+ still caught.
"""
workspace_uuid = None
@@ -1810,17 +1842,13 @@ class WorkspaceSettingsService:
except AttributeError:
continue
break
- filter_key = tuple(str(condition) for condition in filters)
- cacheable = len(filters) == 1 and workspace_uuid is not None
+ cache_key = (workspace_uuid, tuple(str(condition) for condition in filters))
+ cacheable = workspace_uuid is not None
now = time.monotonic()
if cacheable:
- cached = self._integrity_cache.get(workspace_uuid)
- if (
- cached is not None
- and cached['filter_key'] == filter_key
- and now - cached['computed_at'] < INTEGRITY_CACHE_TTL_SECONDS
- ):
+ cached = self._integrity_cache.get(cache_key)
+ if cached is not None and now - cached['computed_at'] < INTEGRITY_CACHE_TTL_SECONDS:
return self._cached_integrity_result(cached)
started = time.monotonic()
@@ -1869,10 +1897,8 @@ class WorkspaceSettingsService:
'chain_failed_ids': chain_failed_ids,
}
if cacheable:
- self._integrity_cache[workspace_uuid] = {
- 'filter_key': filter_key,
+ self._integrity_cache[cache_key] = {
'verified': new_verified,
- 'oldest_row': previous_row,
'computed_at': now,
'truncated': truncated,
}
@@ -2143,7 +2169,7 @@ class WorkspaceSettingsService:
return 0
await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model).where(model.id.in_(list(oldest_ids))))
# Cached verification no longer matches the surviving prefix.
- self._integrity_cache.pop(workspace_uuid, None)
+ self._invalidate_integrity_cache(workspace_uuid)
return len(oldest_ids)
except Exception as exc: # pragma: no cover - defensive
self.ap.logger.debug(f'Operation log trim skipped: {exc}')
@@ -2186,7 +2212,7 @@ class WorkspaceSettingsService:
# Age-based retention removed the oldest rows, so any cached
# verification -- including its boundary baseline link -- no longer
# describes the surviving chain.
- self._integrity_cache.pop(workspace_uuid, None)
+ self._invalidate_integrity_cache(workspace_uuid)
total = await self.count_logs(workspace_uuid)
trimmed = await self._delete_oldest(workspace_uuid, max(total - budget, 0))
diff --git a/tests/unit_tests/test_operation_trace_incremental.py b/tests/unit_tests/test_operation_trace_incremental.py
index f01fa11f9..2f2f29bdd 100644
--- a/tests/unit_tests/test_operation_trace_incremental.py
+++ b/tests/unit_tests/test_operation_trace_incremental.py
@@ -90,6 +90,12 @@ async def _tail_hash(engine) -> str | None:
return result.scalar_one_or_none()
+def _cached_views(service) -> list:
+ """Return the cache keys currently held for the test Workspace."""
+
+ return [key for key in service._integrity_cache if key[0] == WORKSPACE]
+
+
def _counting_verifier(service, monkeypatch):
"""Replace the per-row verifier with one that records the ids it hashes."""
@@ -128,7 +134,9 @@ async def test_cached_read_within_ttl_reuses_without_rehashing(trace_env, monkey
assert first['summary']['scanned'] == 5
assert first['summary']['tampered'] == 0
assert len(calls) == 5
- assert set(service._integrity_cache[WORKSPACE]['verified']) == set(ids)
+ views = _cached_views(service)
+ assert len(views) == 1
+ assert set(service._integrity_cache[views[0]]['verified']) == set(ids)
# A warm read inside the TTL must not re-hash anything at all: this is the
# shield that makes a burst of panel opens, refreshes and page turns cheap.
@@ -227,10 +235,43 @@ async def test_age_prune_drops_the_stale_integrity_cache(trace_env):
# Warm the cache: its verified map now records the row retention will drop.
await service.query_logs(WORKSPACE)
- assert WORKSPACE in service._integrity_cache
+ assert _cached_views(service)
await service.prune(WORKSPACE, retention_days=1)
# Deleting the oldest rows invalidated the cached prefix.
- assert WORKSPACE not in service._integrity_cache
+ assert _cached_views(service) == []
assert 'expired' in (await service.prune(WORKSPACE, retention_days=1))
+
+
+async def test_each_listing_view_is_cached_independently(trace_env, monkeypatch):
+ service, engine = trace_env
+ await _append(service, engine, 4)
+
+ # Two different listing filters must not collide: each gets its own entry,
+ # and a view that was never scanned must still be verified from scratch.
+ all_view = [MODEL.workspace_uuid == WORKSPACE]
+ mutation_view = [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1]
+ await service._integrity_summary(MODEL, all_view)
+ await service._integrity_summary(MODEL, mutation_view)
+
+ views = _cached_views(service)
+ assert len(views) == 2
+ assert len({key[1] for key in views}) == 2
+
+
+async def test_invalidation_clears_every_cached_view(trace_env):
+ service, engine = trace_env
+ await _append(service, engine, 2)
+ old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1))
+ async with engine.begin() as connection:
+ await connection.execute(sqlalchemy.insert(MODEL).values(**old_row))
+
+ await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE])
+ await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1])
+ assert len(_cached_views(service)) == 2
+
+ await service.prune(WORKSPACE, retention_days=1)
+
+ # A deletion invalidates *all* views for the Workspace, not just one.
+ assert _cached_views(service) == []
diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx
index c2a4ab69d..530a033c1 100644
--- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx
+++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
+ ChevronDown,
ChevronLeft,
ChevronRight,
Fingerprint,
@@ -15,14 +16,7 @@ import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import {
- Item,
- ItemActions,
- ItemContent,
- ItemDescription,
- ItemMedia,
- ItemTitle,
-} from '@/components/ui/item';
+import { Item, ItemContent, ItemMedia, ItemTitle } from '@/components/ui/item';
import {
Select,
SelectContent,
@@ -61,33 +55,69 @@ const PAGE_SIZE = 20;
*/
const REDACTED_SENTINEL = '__redacted__';
+/**
+ * Longest value a collapsed row shows verbatim. Anything longer is replaced by
+ * its size, because a pipeline ``config`` or ``extensions_preferences`` renders
+ * as hundreds of characters that wrap onto several lines and read as two
+ * near-identical blobs side by side.
+ */
+const COLLAPSED_VALUE_CHARS = 64;
+
+/** Render a before/after value in full (used when a row is expanded). */
+function formatValue(value: unknown, redactedLabel: string): string {
+ if (value === null || value === undefined || value === '') return '—';
+ // The backend stores a machine sentinel for masked fields; the label is
+ // localized here so no interface text ships from the backend.
+ if (value === REDACTED_SENTINEL) return redactedLabel;
+ if (typeof value === 'object') return JSON.stringify(value);
+ return String(value);
+}
+
+/**
+ * Render a before/after value compactly: short values verbatim, long ones as
+ * their size. The two sizes almost always differ, so the change stays legible
+ * without the full payload competing with the rest of the row.
+ */
+function summarizeValue(
+ value: unknown,
+ redactedLabel: string,
+ sizeLabel: (count: number) => string,
+): string {
+ if (value === REDACTED_SENTINEL) return redactedLabel;
+ if (value === null || value === undefined || value === '') return '—';
+ const text =
+ typeof value === 'string' ? value : (JSON.stringify(value) ?? '');
+ if (text.length > COLLAPSED_VALUE_CHARS) return sizeLabel(text.length);
+ return text;
+}
+
/** Render one before → after pair in a compact, readable row. */
function ChangeRow({
change,
redactedLabel,
+ sizeLabel,
+ collapsed = false,
}: {
change: OperationChangeField;
redactedLabel: string;
+ sizeLabel: (count: number) => string;
+ collapsed?: boolean;
}) {
- const format = (value: unknown): string => {
- if (value === null || value === undefined || value === '') return '—';
- // The backend stores a machine sentinel for masked fields; the label is
- // localized here so no interface text ships from the backend.
- if (value === REDACTED_SENTINEL) return redactedLabel;
- if (typeof value === 'object') return JSON.stringify(value);
- return String(value);
- };
+ const render = (value: unknown): string =>
+ collapsed
+ ? summarizeValue(value, redactedLabel, sizeLabel)
+ : formatValue(value, redactedLabel);
return (
);
@@ -101,6 +131,58 @@ function outcomeVariant(
return 'secondary';
}
+/**
+ * How many changes a collapsed row spells out before summarizing the rest.
+ * Three covers the common case (a name + a couple of flags) so the vast
+ * majority of rows answer "what changed" without a click.
+ */
+const COLLAPSED_CHANGE_LIMIT = 3;
+
+/**
+ * Render a record's before → after diff inline.
+ *
+ * "What was changed into what" is the reason this log exists, and the diff
+ * already arrived with the page, so it is shown on the collapsed row instead
+ * of behind a click. Only the fields that differ are listed; the rest fold
+ * into a single "+N" note that the expanded row spells out in full.
+ */
+function ChangeList({
+ changes,
+ redactedLabel,
+ sizeLabel,
+ limit,
+ moreLabel,
+ collapsed = false,
+}: {
+ changes: OperationChangeField[];
+ redactedLabel: string;
+ sizeLabel: (count: number) => string;
+ limit?: number;
+ moreLabel: (hidden: number) => string;
+ collapsed?: boolean;
+}) {
+ const shown = limit === undefined ? changes : changes.slice(0, limit);
+ const hidden = changes.length - shown.length;
+ return (
+
+ );
+}
+
/** A record paired with how many times it repeated on the current page. */
interface GroupedRecord {
key: string;
@@ -184,6 +266,63 @@ function formatTimestamp(value: string | null): string {
return sameDay ? time : `${date.toLocaleDateString()} ${time}`;
}
+/**
+ * Local calendar day a record belongs to. "today"/"yesterday" are named so the
+ * timeline reads relatively at the top and absolutely further back.
+ */
+function dayKey(value: string | null): string {
+ const date = value ? new Date(value) : null;
+ if (!date || Number.isNaN(date.getTime())) return 'unknown';
+ const iso = (candidate: Date) =>
+ `${candidate.getFullYear()}-${candidate.getMonth()}-${candidate.getDate()}`;
+ const now = new Date();
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
+ if (iso(date) === iso(now)) return 'today';
+ if (iso(date) === iso(yesterday)) return 'yesterday';
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
+ date.getDate(),
+ ).padStart(2, '0')}`;
+}
+
+/** A day's worth of records, so a long history reads as a timeline. */
+interface DayGroup {
+ key: string;
+ records: GroupedRecord[];
+}
+
+/** Header for a day group; absolute dates pass through unchanged. */
+function dayHeading(
+ key: string,
+ labels: { today: string; yesterday: string; unknown: string },
+): string {
+ if (key === 'today') return labels.today;
+ if (key === 'yesterday') return labels.yesterday;
+ if (key === 'unknown') return labels.unknown;
+ return key;
+}
+
+/**
+ * Bucket records by calendar day, preserving the newest-first order.
+ *
+ * The backend returns a flat stream newest-first; without day headers an
+ * operator paging through hundreds of rows cannot tell where "today" ends and
+ * "last week" begins, which is the first thing they look for.
+ */
+function groupByDay(records: GroupedRecord[]): DayGroup[] {
+ const groups: DayGroup[] = [];
+ for (const record of records) {
+ const key = dayKey(record.record.created_at);
+ const last = groups[groups.length - 1];
+ if (last && last.key === key) {
+ last.records.push(record);
+ continue;
+ }
+ groups.push({ key, records: [record] });
+ }
+ return groups;
+}
+
export default function OperationTracePanel({
active,
}: OperationTracePanelProps) {
@@ -295,7 +434,15 @@ export default function OperationTracePanel({
[page],
);
+ // A timeline reads top-down: "today", then "yesterday", then dated buckets.
+ const dayGroups = useMemo(() => groupByDay(groupedRecords), [groupedRecords]);
+
const integrityFilter: OperationIntegrityFilter = query.integrity ?? 'all';
+ // "Mutations only" is the default answer to "who changed something"; the
+ // level filter already exists, so this is a one-click view rather than new
+ // query API surface. Level >= 1 drops the pure "view" observations that
+ // otherwise dominate an L2 (read-level) log.
+ const mutationsOnly = query.level === 1;
// Clicking a counter turns it into a drill-down: the panel is the only place
// the operator can learn *which* records failed verification, so a badge that
@@ -677,6 +824,24 @@ export default function OperationTracePanel({
))}
+ {/* The log is an answer to "who changed something", so the pure
+ "view" observations can be hidden in one click. It reuses the
+ level filter (mutations are level >= 1) rather than adding new
+ query API. */}
+
@@ -688,167 +853,186 @@ export default function OperationTracePanel({
: t('operationTrace.emptyFiltered')}
)}
- {groupedRecords.map(({ key, record, count }) => {
- const expanded = expandedId === record.id;
- return (
-
-
- {record.tampered ? (
-
- ) : (
-
- )}
-
-
- {/* Primary line: only what changed hands — the action, the
- resource, and an exception badge. The level ("L2") and
- the "verified" badge were constant for almost every row
- and only added noise, so they are gone. */}
-
-
- {t(record.action_i18n_key, {
- defaultValue: record.action ?? '',
- })}
-
- {record.resource_type && (
-
- {t(
- `operationTrace.resourceTypes.${record.resource_type}`,
- {
- defaultValue: record.resource_type,
- },
- )}
-
- )}
- {record.resource_id && (
-
- {record.resource_id}
-
- )}
- {record.outcome !== 'ok' && (
-
- {t(`operationTrace.outcomes.${record.outcome}`)}
-
- )}
- {record.tampered && (
-
-
- {t('operationTrace.tamperedBadge')}
-
- )}
- {count > 1 && (
-
- ×{count}
-
- )}
-
-
-
- {record.actor_name ??
- record.actor_account_uuid ??
- t('operationTrace.systemActor')}
-
- ·
-
- {record.actor_role
- ? t(`workspace.roles.${record.actor_role}`)
- : t('operationTrace.systemActor')}
-
- {record.http_method && (
- <>
- ·
-
- {record.http_method}
- {/* A successful 2xx is the default; only surface the
- status when it carries a signal (an error). */}
- {record.status_code !== null &&
- record.status_code >= 400
- ? ` ${record.status_code}`
- : ''}
+ {dayGroups.map((group) => (
+
+ {group.records.map(({ key, record, count }) => {
+ const expanded = expandedId === record.id;
+ const actor =
+ record.actor_name ??
+ record.actor_account_uuid ??
+ t('operationTrace.systemActor');
+ const role = record.actor_role
+ ? t(`workspace.roles.${record.actor_role}`)
+ : t('operationTrace.systemActor');
+ return (
+ setExpandedId(expanded ? null : record.id)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setExpandedId(expanded ? null : record.id);
+ }
+ }}
+ className={`items-start rounded-lg ${
+ record.tampered
+ ? 'border-destructive/60 bg-destructive/5'
+ : ''
+ } ${expanded ? 'ring-1 ring-border' : ''}`}
+ >
+
+ {record.tampered ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Identity first: who did it and when. That is the
+ question the log is opened to answer, so it leads
+ instead of hiding in a muted description line. */}
+
+
+ {actor}
- >
- )}
- ·
- {formatTimestamp(record.created_at)}
- {record.changes.length > 0 && (
- <>
- ·
-
- >
- )}
-
- {/* The registered route is the concrete answer to "what is
- this entry?": a bare "Resource / System" label is opaque
- without the endpoint it came from. */}
- {record.route && (
-
- {record.route}
-
- )}
- {/* The change digest is only meaningful once expanded, so
- the collapsed row stays a single scannable line. */}
- {expanded && record.changes.length > 0 && (
-
+ )}
+ {/* The whole card is the toggle, so the chevron is the
+ only affordance needed; clicking anywhere expands
+ it. That removes the ambiguity of a small text link
+ whose expanded state looked almost identical. */}
+
+
- ))}
-
- )}
-
- {/* Restore the always-visible right column: each row is
- anchored by its duration and tamper-evidence hash, so the
- digest stays one click away instead of being the only way
- to see it. */}
-
-
-
-
- );
- })}
+ {/* Diagnostics are opt-in: route, method, status,
+ duration and the evidence hash matter when an
+ operator investigates one entry, not while scanning
+ the timeline. */}
+ {expanded && (
+