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 (
{change.field} - {format(change.before)} + {render(change.before)} - {format(change.after)} + {render(change.after)}
); @@ -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 ( +
+ {shown.map((change, index) => ( + + ))} + {hidden > 0 && ( + + {moreLabel(hidden)} + + )} +
+ ); +} + /** 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) => ( +
+
+ + {dayHeading(group.key, { + today: t('operationTrace.today'), + yesterday: t('operationTrace.yesterday'), + unknown: t('operationTrace.unknownDay'), + })} + + +
+ {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 && ( -
- {record.changes.map((change, index) => ( - · + {role} + · + + {formatTimestamp(record.created_at)} + +
+ {/* What happened, to which resource — always present, + followed on the next line by the concrete diff. */} + + + {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} + + )} + + {/* "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 && ( +
+ + t('operationTrace.elidedValue', { count: size }) + } + limit={ + expanded ? undefined : COLLAPSED_CHANGE_LIMIT + } + moreLabel={(hidden) => + t('operationTrace.moreChanges', { + count: hidden, + }) + } + collapsed={!expanded} + /> +
+ )} + {/* 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. */} - -
- - {record.duration_ms}ms - - {record.record_hash && ( - - - {record.record_hash.slice(0, 8)} + {expanded + ? t('operationTrace.hideDetails') + : t('operationTrace.showDetails')} - )} -
-
-
- ); - })} + {/* 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 && ( +
+ {record.route && ( + + {record.route} + + )} + {record.http_method && ( + + {record.http_method} + {record.status_code !== null + ? ` ${record.status_code}` + : ''} + + )} + {record.duration_ms}ms + {record.record_hash && ( + + + {record.record_hash.slice(0, 16)} + + )} +
+ )} + + + ); + })} +
+ ))}
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 454260b1c..574201209 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 13365952f..0875caa77 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index da11202d0..d1788fe67 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: 'タスクをデバッグ', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 8b1009cdb..d42db13ea 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -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: 'Отладка задачи', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index dd3d22e13..7542b0f81 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -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: 'ดีบักงาน', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 488325746..bd4814195 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -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ụ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d700edfc4..f1a5c0346 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '调试任务', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 52ff94e55..47067ffec 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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: '偵錯任務',