diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx index 3b319c6cf..f3ba224be 100644 --- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx +++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx @@ -7,7 +7,6 @@ import { Loader2, RefreshCw, ShieldAlert, - ShieldCheck, SlidersHorizontal, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -18,7 +17,6 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Item, - ItemActions, ItemContent, ItemDescription, ItemMedia, @@ -102,6 +100,63 @@ function outcomeVariant( return 'secondary'; } +/** A record paired with how many times it repeated on the current page. */ +interface GroupedRecord { + key: string; + record: OperationLogRecord; + count: number; +} + +/** + * Collapse consecutive identical observations into a single row. + * + * An open WebUI or a refresh burst otherwise fills the log with the same + * "view X" line and buries the operations that actually changed something. + * The identity includes the summary and the change digest, so two rows are + * merged only when nothing about them differs; a differing change is kept as + * its own row. + */ +function groupRecords(records: OperationLogRecord[]): GroupedRecord[] { + const grouped: GroupedRecord[] = []; + for (const record of records) { + const key = [ + record.action, + record.resource_type, + record.resource_id, + record.actor_account_uuid, + record.outcome, + record.status_code, + record.summary ?? '', + record.changes.map((change) => change.field).join(','), + ].join('|'); + const last = grouped[grouped.length - 1]; + if (last && last.key === key) { + last.count += 1; + continue; + } + grouped.push({ key, record, count: 1 }); + } + return grouped; +} + +/** Compact timestamp: time-only for today, date+time otherwise. */ +function formatTimestamp(value: string | null): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const now = new Date(); + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + const time = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + return sameDay ? time : `${date.toLocaleDateString()} ${time}`; +} + export default function OperationTracePanel({ active, }: OperationTracePanelProps) { @@ -120,6 +175,9 @@ export default function OperationTracePanel({ const [retentionDays, setRetentionDays] = useState(30); const [maxRows, setMaxRows] = useState(20000); const [dedupeSeconds, setDedupeSeconds] = useState(60); + // Only one record shows its full change detail at a time, so the list stays + // scannable: the payload diff is long and is opt-in per row. + const [expandedId, setExpandedId] = useState(null); const role = currentWorkspace?.membership.role ?? null; const canConfigure = role === 'owner' || role === 'admin'; @@ -200,12 +258,15 @@ export default function OperationTracePanel({ const total = page?.total ?? 0; const canGoBack = offset > 0; const canGoForward = offset + PAGE_SIZE < total; + const pageCount = Math.max(Math.ceil(total / PAGE_SIZE), 1); + const pageIndex = Math.floor(offset / PAGE_SIZE) + 1; - // Render the server page as-is. Record ids are unique and stable, the - // backend already collapses repeated observations at write time, and a - // client-side dedupe would hide legitimately distinct rows (making the - // total badge disagree with the list) whenever a page is re-fetched. - const visibleRecords = page?.records ?? []; + // Collapse consecutive identical rows so a refresh burst reads as one line + // with a count instead of twenty identical entries. + const groupedRecords = useMemo( + () => groupRecords(page?.records ?? []), + [page], + ); const integrityFilter: OperationIntegrityFilter = query.integrity ?? 'all'; @@ -593,142 +654,163 @@ export default function OperationTracePanel({
- {visibleRecords.length === 0 && ( + {groupedRecords.length === 0 && (

{integrityFilter === 'all' ? t('operationTrace.empty') : t('operationTrace.emptyFiltered')}

)} - {visibleRecords.map((record) => ( - - - {record.tampered ? ( - - ) : ( - - )} - - - - - {t(record.action_i18n_key, { - defaultValue: record.action ?? '', - })} - - - {t(`operationTrace.outcomes.${record.outcome}`)} - - L{record.level} - {record.resource_type && ( - - {t( - `operationTrace.resourceTypes.${record.resource_type}`, - { - defaultValue: record.resource_type, - }, - )} - - )} - {record.resource_id && ( - // Which concrete resource was touched (author/name, - // owner/repo, filename...). Rendered as data, not copy, - // so it is not localized. - - {record.resource_id} - - )} + {groupedRecords.map(({ key, record, count }) => { + const expanded = expandedId === record.id; + return ( + + {record.tampered ? ( - - - {t('operationTrace.tamperedBadge')} - + ) : ( - - - {t('operationTrace.verifiedBadge')} - + )} - - - - {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} - - )} - {record.status_code !== null && ( - <> - - {record.status_code} - - )} - - - {record.created_at - ? new Date(record.created_at).toLocaleString() - : ''} - - - {record.summary && ( -

- {record.summary} -

- )} - {record.changes.length > 0 && ( -
- {record.changes.map((change, index) => ( - - ))} -
- )} -
- -
- - {record.duration_ms}ms - - {record.record_hash && ( - - - {record.record_hash.slice(0, 8)} + + + {/* 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} + {record.status_code !== null + ? ` ${record.status_code}` + : ''} + + + )} + + {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) => ( + + ))} +
+ {record.duration_ms}ms + {record.record_hash && ( + + + {record.record_hash.slice(0, 8)} + + )} +
+
+ )} + + + ); + })}
@@ -754,6 +836,32 @@ export default function OperationTracePanel({ > + {/* Jump straight to a page instead of only stepping one at a + time; with hundreds of records the arrows alone are painful. */} +