feat(operation-trace): lead with the change, fix pipeline extension tracing

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/<uuid>/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.
This commit is contained in:
TyperBody
2026-09-28 01:03:02 +08:00
parent ed93e7f1ac
commit 9ecb469d13
12 changed files with 554 additions and 204 deletions
@@ -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(
{
+51 -25
View File
@@ -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/<uuid>/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))
@@ -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) == []
@@ -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 (
<div className="flex flex-wrap items-baseline gap-1.5 font-mono text-xs">
<span className="text-muted-foreground">{change.field}</span>
<span className="rounded bg-muted px-1.5 py-0.5 break-all">
{format(change.before)}
{render(change.before)}
</span>
<span aria-hidden="true" className="text-muted-foreground">
→
</span>
<span className="rounded bg-primary/10 px-1.5 py-0.5 break-all">
{format(change.after)}
{render(change.after)}
</span>
</div>
);
@@ -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 (
<div className="flex flex-col gap-0.5">
{shown.map((change, index) => (
<ChangeRow
key={`${change.field}-${index}`}
change={change}
redactedLabel={redactedLabel}
sizeLabel={sizeLabel}
collapsed={collapsed}
/>
))}
{hidden > 0 && (
<span className="font-mono text-[10px] text-muted-foreground">
{moreLabel(hidden)}
</span>
)}
</div>
);
}
/** 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({
))}
</SelectContent>
</Select>
{/* 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. */}
<Button
size="sm"
variant={mutationsOnly ? 'default' : 'outline'}
className="h-8"
onClick={() =>
setQuery((prev) => ({
...prev,
level: prev.level === 1 ? undefined : 1,
offset: 0,
}))
}
>
{t('operationTrace.mutationsOnly')}
</Button>
</div>
</div>
@@ -688,167 +853,186 @@ export default function OperationTracePanel({
: t('operationTrace.emptyFiltered')}
</p>
)}
{groupedRecords.map(({ key, record, count }) => {
const expanded = expandedId === record.id;
return (
<Item
key={key}
size="sm"
variant="muted"
className={`items-start rounded-lg ${
record.tampered
? 'border-destructive/60 bg-destructive/5'
: ''
}`}
>
<ItemMedia variant="icon">
{record.tampered ? (
<ShieldAlert className="size-4 text-destructive" />
) : (
<History className="size-4" />
)}
</ItemMedia>
<ItemContent className="min-w-0">
{/* 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. */}
<ItemTitle className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">
{t(record.action_i18n_key, {
defaultValue: record.action ?? '',
})}
</span>
{record.resource_type && (
<span className="text-xs text-muted-foreground">
{t(
`operationTrace.resourceTypes.${record.resource_type}`,
{
defaultValue: record.resource_type,
},
)}
</span>
)}
{record.resource_id && (
<span
className="max-w-[18rem] truncate font-mono text-xs text-muted-foreground"
title={record.resource_id}
>
{record.resource_id}
</span>
)}
{record.outcome !== 'ok' && (
<Badge variant={outcomeVariant(record.outcome)}>
{t(`operationTrace.outcomes.${record.outcome}`)}
</Badge>
)}
{record.tampered && (
<Badge variant="destructive">
<ShieldAlert className="size-3" />
{t('operationTrace.tamperedBadge')}
</Badge>
)}
{count > 1 && (
<Badge variant="outline" className="shrink-0">
×{count}
</Badge>
)}
</ItemTitle>
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
<span className="font-medium">
{record.actor_name ??
record.actor_account_uuid ??
t('operationTrace.systemActor')}
</span>
<span aria-hidden="true">·</span>
<span>
{record.actor_role
? t(`workspace.roles.${record.actor_role}`)
: t('operationTrace.systemActor')}
</span>
{record.http_method && (
<>
<span aria-hidden="true">·</span>
<span className="font-mono">
{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) => (
<div key={group.key} className="space-y-2">
<div className="flex items-center gap-2 pt-1">
<span className="text-[11px] font-medium text-muted-foreground">
{dayHeading(group.key, {
today: t('operationTrace.today'),
yesterday: t('operationTrace.yesterday'),
unknown: t('operationTrace.unknownDay'),
})}
</span>
<span className="h-px flex-1 bg-border" />
</div>
{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 (
<Item
key={key}
size="sm"
variant="muted"
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => 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' : ''}`}
>
<ItemMedia variant="icon">
{record.tampered ? (
<ShieldAlert className="size-4 text-destructive" />
) : (
<History className="size-4" />
)}
</ItemMedia>
<ItemContent className="min-w-0 gap-1.5">
{/* 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. */}
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
<span className="font-medium text-foreground">
{actor}
</span>
</>
)}
<span aria-hidden="true">·</span>
<span>{formatTimestamp(record.created_at)}</span>
{record.changes.length > 0 && (
<>
<span aria-hidden="true">·</span>
<button
type="button"
className="cursor-pointer underline underline-offset-2 hover:text-foreground"
onClick={() =>
setExpandedId(expanded ? null : record.id)
}
>
{expanded
? t('operationTrace.hideDetails')
: t('operationTrace.changesCount', {
count: record.changes.length,
})}
</button>
</>
)}
</ItemDescription>
{/* 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 && (
<p
className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground"
title={record.route}
>
{record.route}
</p>
)}
{/* The change digest is only meaningful once expanded, so
the collapsed row stays a single scannable line. */}
{expanded && record.changes.length > 0 && (
<div className="mt-2 space-y-1 rounded-md bg-background/60 p-2">
{record.changes.map((change, index) => (
<ChangeRow
key={`${record.id}-${index}-${change.field}`}
change={change}
redactedLabel={t('operationTrace.redacted')}
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{role}</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{formatTimestamp(record.created_at)}
</span>
</div>
{/* What happened, to which resource — always present,
followed on the next line by the concrete diff. */}
<ItemTitle className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">
{t(record.action_i18n_key, {
defaultValue: record.action ?? '',
})}
</span>
{record.resource_type && (
<span className="text-xs text-muted-foreground">
{t(
`operationTrace.resourceTypes.${record.resource_type}`,
{ defaultValue: record.resource_type },
)}
</span>
)}
{record.resource_id && (
<span
className="max-w-[18rem] truncate font-mono text-xs text-muted-foreground"
title={record.resource_id}
>
{record.resource_id}
</span>
)}
{record.outcome !== 'ok' && (
<Badge variant={outcomeVariant(record.outcome)}>
{t(`operationTrace.outcomes.${record.outcome}`)}
</Badge>
)}
{record.tampered && (
<Badge variant="destructive">
<ShieldAlert className="size-3" />
{t('operationTrace.tamperedBadge')}
</Badge>
)}
{count > 1 && (
<Badge variant="outline" className="shrink-0">
×{count}
</Badge>
)}
</ItemTitle>
{/* "What was changed into what" is the whole point of
the log and the data already arrived with the page,
so the diff is spelled out on the collapsed row. */}
{record.changes.length > 0 && (
<div className="rounded-md bg-background/60 p-2">
<ChangeList
changes={record.changes}
redactedLabel={t('operationTrace.redacted')}
sizeLabel={(size) =>
t('operationTrace.elidedValue', { count: size })
}
limit={
expanded ? undefined : COLLAPSED_CHANGE_LIMIT
}
moreLabel={(hidden) =>
t('operationTrace.moreChanges', {
count: hidden,
})
}
collapsed={!expanded}
/>
</div>
)}
{/* 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. */}
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<ChevronDown
className={`size-3 transition-transform ${
expanded ? 'rotate-180' : ''
}`}
aria-hidden="true"
/>
))}
</div>
)}
</ItemContent>
{/* 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. */}
<ItemActions className="max-sm:hidden">
<div className="flex flex-col items-end gap-0.5 text-right">
<span className="text-[10px] text-muted-foreground">
{record.duration_ms}ms
</span>
{record.record_hash && (
<span
className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground"
title={record.record_hash}
>
<Fingerprint className="size-3" />
{record.record_hash.slice(0, 8)}
{expanded
? t('operationTrace.hideDetails')
: t('operationTrace.showDetails')}
</span>
)}
</div>
</ItemActions>
</Item>
);
})}
{/* 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 && (
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 border-t border-border/60 pt-1.5 font-mono text-[10px] text-muted-foreground">
{record.route && (
<span className="truncate" title={record.route}>
{record.route}
</span>
)}
{record.http_method && (
<span>
{record.http_method}
{record.status_code !== null
? ` ${record.status_code}`
: ''}
</span>
)}
<span>{record.duration_ms}ms</span>
{record.record_hash && (
<span
className="flex items-center gap-1"
title={record.record_hash}
>
<Fingerprint className="size-3" />
{record.record_hash.slice(0, 16)}
</span>
)}
</div>
)}
</ItemContent>
</Item>
);
})}
</div>
))}
</div>
<div className="flex items-center justify-between">
+8
View File
@@ -2757,6 +2757,13 @@ const enUS = {
chainFailedCount: '{{count}} broken links',
scanTruncated: 'Only the latest {{count}} verified',
emptyFiltered: 'No records need verification under the current filters',
mutationsOnly: 'Changes only',
today: 'Today',
yesterday: 'Yesterday',
unknownDay: 'Unknown time',
showDetails: 'Details',
moreChanges: '+{{count}} more',
elidedValue: '{{count}} chars',
levels: {
off: {
label: 'Tracing off',
@@ -2821,6 +2828,7 @@ const enUS = {
knowledge_base_update: 'Change knowledge base',
mcp_view: 'View MCP server',
mcp_config: 'Change MCP server',
pipeline_extensions_update: 'Update pipeline extension bindings',
export: 'Export data',
execute: 'Execute task',
debug: 'Debug task',
+9
View File
@@ -2812,6 +2812,13 @@ const esES = {
chainFailedCount: '{{count}} enlaces rotos',
scanTruncated: 'Solo se verifican los últimos {{count}}',
emptyFiltered: 'Ningún registro requiere verificación',
mutationsOnly: 'Solo cambios',
today: 'Hoy',
yesterday: 'Ayer',
unknownDay: 'Fecha desconocida',
showDetails: 'Detalles',
moreChanges: '+{{count}} más',
elidedValue: '{{count}} caracteres',
levels: {
off: {
label: 'Sin trazabilidad',
@@ -2876,6 +2883,8 @@ const esES = {
knowledge_base_update: 'Cambiar base de conocimiento',
mcp_view: 'Ver servidor MCP',
mcp_config: 'Cambiar servidor MCP',
pipeline_extensions_update:
'Actualizar vinculaciones de extensiones del pipeline',
export: 'Exportar datos',
execute: 'Ejecutar tarea',
debug: 'Depurar tarea',
+8
View File
@@ -2775,6 +2775,13 @@ const jaJP = {
chainFailedCount: '{{count}} 件のリンク断絶',
scanTruncated: '最新 {{count}} 件のみ検証',
emptyFiltered: '現在のフィルターでは検証が必要な記録はありません',
mutationsOnly: '変更のみ',
today: '今日',
yesterday: '昨日',
unknownDay: '不明な日時',
showDetails: '詳細',
moreChanges: '他 {{count}} 件',
elidedValue: '{{count}} 文字',
levels: {
off: {
label: 'トレース無効',
@@ -2839,6 +2846,7 @@ const jaJP = {
knowledge_base_update: 'ナレッジベースを変更',
mcp_view: 'MCP サーバーを閲覧',
mcp_config: 'MCP サーバーを変更',
pipeline_extensions_update: 'パイプライン拡張の紐付けを変更',
export: 'データをエクスポート',
execute: 'タスクを実行',
debug: 'タスクをデバッグ',
+8
View File
@@ -2782,6 +2782,13 @@ const ruRU = {
chainFailedCount: 'Разрыв связи: {{count}}',
scanTruncated: 'Проверены только последние {{count}}',
emptyFiltered: 'По текущим фильтрам нет записей, требующих проверки',
mutationsOnly: 'Только изменения',
today: 'Сегодня',
yesterday: 'Вчера',
unknownDay: 'Неизвестное время',
showDetails: 'Подробнее',
moreChanges: 'ещё {{count}}',
elidedValue: '{{count}} символов',
levels: {
off: {
label: 'Трассировка выключена',
@@ -2846,6 +2853,7 @@ const ruRU = {
knowledge_base_update: 'Изменение базы знаний',
mcp_view: 'Просмотр сервера MCP',
mcp_config: 'Изменение сервера MCP',
pipeline_extensions_update: 'Изменение привязок расширений конвейера',
export: 'Экспорт данных',
execute: 'Выполнение задачи',
debug: 'Отладка задачи',
+8
View File
@@ -2709,6 +2709,13 @@ const thTH = {
chainFailedCount: 'ลิงก์ขาด {{count}} รายการ',
scanTruncated: 'ตรวจสอบเฉพาะ {{count}} รายการล่าสุด',
emptyFiltered: 'ไม่มีรายการที่ต้องตรวจสอบภายใต้ตัวกรองปัจจุบัน',
mutationsOnly: 'เฉพาะการแก้ไข',
today: 'วันนี้',
yesterday: 'เมื่อวาน',
unknownDay: 'ไม่ทราบเวลา',
showDetails: 'รายละเอียด',
moreChanges: 'อีก {{count}} รายการ',
elidedValue: '{{count}} ตัวอักษร',
levels: {
off: {
label: 'ปิดการติดตาม',
@@ -2773,6 +2780,7 @@ const thTH = {
knowledge_base_update: 'แก้ไขฐานความรู้',
mcp_view: 'ดูเซิร์ฟเวอร์ MCP',
mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP',
pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์',
export: 'ส่งออกข้อมูล',
execute: 'รันงาน',
debug: 'ดีบักงาน',
+8
View File
@@ -2744,6 +2744,13 @@ const viVN = {
chainFailedCount: '{{count}} liên kết bị đứt',
scanTruncated: 'Chỉ xác minh {{count}} bản ghi mới nhất',
emptyFiltered: 'Không có bản ghi nào cần xác minh với bộ lọc hiện tại',
mutationsOnly: 'Chỉ thay đổi',
today: 'Hôm nay',
yesterday: 'Hôm qua',
unknownDay: 'Không rõ thời gian',
showDetails: 'Chi tiết',
moreChanges: '+{{count}} mục',
elidedValue: '{{count}} ký tự',
levels: {
off: {
label: 'Tắt truy vết',
@@ -2808,6 +2815,7 @@ const viVN = {
knowledge_base_update: 'Thay đổi cơ sở tri thức',
mcp_view: 'Xem máy chủ MCP',
mcp_config: 'Thay đổi máy chủ MCP',
pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline',
export: 'Xuất dữ liệu',
execute: 'Chạy tác vụ',
debug: 'Gỡ lỗi tác vụ',
+8
View File
@@ -2607,6 +2607,13 @@ const zhHans = {
chainFailedCount: '{{count}} 条链路断裂',
scanTruncated: '仅校验最近 {{count}} 条',
emptyFiltered: '当前筛选下没有需要校验的记录',
mutationsOnly: '只看修改',
today: '今天',
yesterday: '昨天',
unknownDay: '未知时间',
showDetails: '详情',
moreChanges: '还有 {{count}} 项',
elidedValue: '{{count}} 字符',
levels: {
off: {
label: '关闭溯源',
@@ -2671,6 +2678,7 @@ const zhHans = {
knowledge_base_update: '修改知识库',
mcp_view: '查看 MCP 服务器',
mcp_config: '修改 MCP 服务器',
pipeline_extensions_update: '修改管道扩展绑定',
export: '导出数据',
execute: '执行任务',
debug: '调试任务',
+8
View File
@@ -2608,6 +2608,13 @@ const zhHant = {
chainFailedCount: '{{count}} 筆鏈結中斷',
scanTruncated: '僅校驗最近 {{count}} 筆',
emptyFiltered: '目前篩選下沒有需要校驗的記錄',
mutationsOnly: '只看修改',
today: '今天',
yesterday: '昨天',
unknownDay: '未知時間',
showDetails: '詳情',
moreChanges: '還有 {{count}} 項',
elidedValue: '{{count}} 字元',
levels: {
off: {
label: '關閉溯源',
@@ -2672,6 +2679,7 @@ const zhHant = {
knowledge_base_update: '修改知識庫',
mcp_view: '查看 MCP 伺服器',
mcp_config: '修改 MCP 伺服器',
pipeline_extensions_update: '修改管道擴展綁定',
export: '匯出資料',
execute: '執行任務',
debug: '偵錯任務',