feat(operation-trace): declutter the log list and add page navigation

The list was noisy and hard to read: a constant "verified" badge and the
"L1/L2" level badge on every row carried no information, the same "view X"
line repeated until it buried real changes, and each row rendered its full
payload diff inline.

- Drop the per-row level badge and the "verified" badge; keep only an
  exception badge, so a healthy row shows nothing extra.
- Collapse consecutive identical rows into one line with an "xN" count.
- Make the change digest opt-in per row (one expanded at a time) and show a
  "<n> changes" toggle instead of dumping the diff.
- Show the registered route under each row so "Resource / System" is no
  longer ambiguous about what actually happened.
- Replace the arrows-only pager with a page selector ("Page x / y") plus the
  arrows, for logs with hundreds of records.

Verified: tsc, eslint, prettier and check-i18n all pass.
This commit is contained in:
TyperBody
2026-09-27 02:32:12 +08:00
parent 884beea4b6
commit 67bc80e3bc
10 changed files with 266 additions and 132 deletions
@@ -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<number>(30);
const [maxRows, setMaxRows] = useState<number>(20000);
const [dedupeSeconds, setDedupeSeconds] = useState<number>(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<number | null>(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({
</div>
<div className="space-y-2">
{visibleRecords.length === 0 && (
{groupedRecords.length === 0 && (
<p className="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground">
{integrityFilter === 'all'
? t('operationTrace.empty')
: t('operationTrace.emptyFiltered')}
</p>
)}
{visibleRecords.map((record) => (
<Item
key={record.id}
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">
<ItemTitle className="flex flex-wrap items-center gap-1.5">
<span>
{t(record.action_i18n_key, {
defaultValue: record.action ?? '',
})}
</span>
<Badge variant={outcomeVariant(record.outcome)}>
{t(`operationTrace.outcomes.${record.outcome}`)}
</Badge>
<Badge variant="outline">L{record.level}</Badge>
{record.resource_type && (
<Badge variant="secondary">
{t(
`operationTrace.resourceTypes.${record.resource_type}`,
{
defaultValue: record.resource_type,
},
)}
</Badge>
)}
{record.resource_id && (
// Which concrete resource was touched (author/name,
// owner/repo, filename...). Rendered as data, not copy,
// so it is not localized.
<Badge
variant="outline"
className="max-w-[16rem] truncate font-mono"
title={record.resource_id}
>
{record.resource_id}
</Badge>
)}
{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 ? (
<Badge variant="destructive">
<ShieldAlert className="size-3" />
{t('operationTrace.tamperedBadge')}
</Badge>
<ShieldAlert className="size-4 text-destructive" />
) : (
<Badge variant="outline" className="text-emerald-600">
<ShieldCheck className="size-3" />
{t('operationTrace.verifiedBadge')}
</Badge>
<History className="size-4" />
)}
</ItemTitle>
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<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}</span>
</>
)}
{record.status_code !== null && (
<>
<span aria-hidden="true">·</span>
<span className="font-mono">{record.status_code}</span>
</>
)}
<span aria-hidden="true">·</span>
<span>
{record.created_at
? new Date(record.created_at).toLocaleString()
: ''}
</span>
</ItemDescription>
{record.summary && (
<p className="mt-1 text-xs text-muted-foreground">
{record.summary}
</p>
)}
{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')}
/>
))}
</div>
)}
</ItemContent>
<ItemActions className="max-sm:hidden">
<div className="flex flex-col items-end gap-1">
<span className="text-xs 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)}
</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}
{record.status_code !== null
? ` ${record.status_code}`
: ''}
</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>
)}
</div>
</ItemActions>
</Item>
))}
{/* 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')}
/>
))}
<div className="flex items-center gap-2 pt-1 text-[10px] text-muted-foreground">
<span>{record.duration_ms}ms</span>
{record.record_hash && (
<span
className="flex items-center gap-1 font-mono"
title={record.record_hash}
>
<Fingerprint className="size-3" />
{record.record_hash.slice(0, 8)}
</span>
)}
</div>
</div>
)}
</ItemContent>
</Item>
);
})}
</div>
<div className="flex items-center justify-between">
@@ -754,6 +836,32 @@ export default function OperationTracePanel({
>
<ChevronLeft className="size-4" />
</Button>
{/* Jump straight to a page instead of only stepping one at a
time; with hundreds of records the arrows alone are painful. */}
<Select
value={String(pageIndex)}
onValueChange={(value) =>
setQuery((prev) => ({
...prev,
offset: (Number(value) - 1) * PAGE_SIZE,
}))
}
disabled={loading || pageCount <= 1}
>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: pageCount }, (_, index) => (
<SelectItem key={index} value={String(index + 1)}>
{t('operationTrace.pageOf', {
page: index + 1,
total: pageCount,
})}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="outline"
@@ -59,6 +59,8 @@ export interface OperationLogRecord {
action_i18n_key: string;
resource_type: string | null;
resource_id: string | null;
/** Registered Core route identity, e.g. ``/api/v1/settings/governance``. */
route: string | null;
level: OperationLevel;
level_name: string;
outcome: 'ok' | 'denied' | 'error';
+3
View File
@@ -2548,6 +2548,7 @@ const enUS = {
noToolCalls: 'No tool calls recorded',
showDetails: 'Show details',
hideDetails: 'Hide details',
pageOf: 'Page {{page}} / {{total}}',
},
tokens: {
totalTokens: 'Total Tokens',
@@ -2728,6 +2729,8 @@ const enUS = {
maxRows: 'Max records',
dedupeWindow: 'Dedupe window (s)',
records: 'Operation records',
changesCount: '{{count}} change(s)',
hideDetails: 'Hide details',
levelUpdated: 'Capture level updated',
levelUpdateFailed: 'Failed to update capture level',
retentionUpdated: 'Retention updated',
+3
View File
@@ -2602,6 +2602,7 @@ const esES = {
noToolCalls: 'No se registraron llamadas de herramientas',
showDetails: 'Mostrar detalles',
hideDetails: 'Ocultar detalles',
pageOf: 'Página {{page}} / {{total}}',
},
tokens: {
totalTokens: 'Tokens totales',
@@ -2783,6 +2784,8 @@ const esES = {
maxRows: 'Registros máximos',
dedupeWindow: 'Ventana de deduplicación (s)',
records: 'Registros de operación',
changesCount: '{{count}} cambio(s)',
hideDetails: 'Ocultar detalles',
levelUpdated: 'Nivel de captura actualizado',
levelUpdateFailed: 'No se pudo actualizar el nivel de captura',
retentionUpdated: 'Retención actualizada',
+3
View File
@@ -2565,6 +2565,7 @@ const jaJP = {
noToolCalls: 'ツール呼び出しは記録されていません',
showDetails: '詳細を表示',
hideDetails: '詳細を非表示',
pageOf: '{{page}} / {{total}} ページ',
},
tokens: {
totalTokens: '総トークン数',
@@ -2746,6 +2747,8 @@ const jaJP = {
maxRows: '最大レコード数',
dedupeWindow: '重複排除ウィンドウ(秒)',
records: '操作レコード',
changesCount: '変更 {{count}} 件',
hideDetails: '詳細を隠す',
levelUpdated: 'キャプチャレベルを更新しました',
levelUpdateFailed: 'キャプチャレベルの更新に失敗しました',
retentionUpdated: '保持ポリシーを更新しました',
+3
View File
@@ -2573,6 +2573,7 @@ const ruRU = {
noToolCalls: 'Вызовы инструментов не записаны',
showDetails: 'Показать детали',
hideDetails: 'Скрыть детали',
pageOf: 'Стр. {{page}} / {{total}}',
},
tokens: {
totalTokens: 'Всего токенов',
@@ -2753,6 +2754,8 @@ const ruRU = {
maxRows: 'Максимум записей',
dedupeWindow: 'Окно дедупликации (с)',
records: 'Записи операций',
changesCount: 'Изменений: {{count}}',
hideDetails: 'Скрыть детали',
levelUpdated: 'Уровень записи обновлён',
levelUpdateFailed: 'Не удалось обновить уровень записи',
retentionUpdated: 'Хранение обновлено',
+3
View File
@@ -2499,6 +2499,7 @@ const thTH = {
noToolCalls: 'ยังไม่มีการบันทึกการเรียกใช้เครื่องมือ',
showDetails: 'แสดงรายละเอียด',
hideDetails: 'ซ่อนรายละเอียด',
pageOf: 'หน้า {{page}} / {{total}}',
},
tokens: {
totalTokens: 'Token ทั้งหมด',
@@ -2680,6 +2681,8 @@ const thTH = {
maxRows: 'จำนวนบันทึกสูงสุด',
dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)',
records: 'บันทึกการดำเนินการ',
changesCount: '{{count}} การเปลี่ยนแปลง',
hideDetails: 'ซ่อนรายละเอียด',
levelUpdated: 'อัปเดตระดับการบันทึกแล้ว',
levelUpdateFailed: 'อัปเดตระดับการบันทึกไม่สำเร็จ',
retentionUpdated: 'อัปเดตนโยบายการเก็บรักษาแล้ว',
+3
View File
@@ -2534,6 +2534,7 @@ const viVN = {
noToolCalls: 'Chưa ghi nhận lượt gọi công cụ',
showDetails: 'Hiện chi tiết',
hideDetails: 'Ẩn chi tiết',
pageOf: 'Trang {{page}} / {{total}}',
},
tokens: {
totalTokens: 'Tổng số Token',
@@ -2715,6 +2716,8 @@ const viVN = {
maxRows: 'Số bản ghi tối đa',
dedupeWindow: 'Khoảng khử trùng lặp (giây)',
records: 'Bản ghi thao tác',
changesCount: '{{count}} thay đổi',
hideDetails: 'Ẩn chi tiết',
levelUpdated: 'Đã cập nhật mức ghi nhận',
levelUpdateFailed: 'Cập nhật mức ghi nhận thất bại',
retentionUpdated: 'Đã cập nhật chính sách lưu trữ',
+3
View File
@@ -2399,6 +2399,7 @@ const zhHans = {
noToolCalls: '未记录工具调用',
showDetails: '查看详情',
hideDetails: '隐藏详情',
pageOf: '第 {{page}} / {{total}} 页',
},
tokens: {
totalTokens: '总 Token 数',
@@ -2578,6 +2579,8 @@ const zhHans = {
maxRows: '最大记录数',
dedupeWindow: '去重窗口(秒)',
records: '操作记录',
changesCount: '{{count}} 项变更',
hideDetails: '收起详情',
levelUpdated: '操作等级已更新',
levelUpdateFailed: '操作等级更新失败',
retentionUpdated: '保留策略已更新',
+3
View File
@@ -2400,6 +2400,7 @@ const zhHant = {
noToolCalls: '未記錄工具呼叫',
showDetails: '查看詳情',
hideDetails: '隱藏詳情',
pageOf: '第 {{page}} / {{total}} 頁',
},
tokens: {
totalTokens: '總 Token 數',
@@ -2579,6 +2580,8 @@ const zhHant = {
maxRows: '最大記錄數',
dedupeWindow: '去重視窗(秒)',
records: '操作記錄',
changesCount: '{{count}} 項變更',
hideDetails: '收合詳情',
levelUpdated: '操作等級已更新',
levelUpdateFailed: '操作等級更新失敗',
retentionUpdated: '保留策略已更新',