diff --git a/.github/pr-assets/1151/screenshots/01-after-overview-mock.png b/.github/pr-assets/1151/screenshots/01-after-overview-mock.png new file mode 100644 index 00000000..b03657ff Binary files /dev/null and b/.github/pr-assets/1151/screenshots/01-after-overview-mock.png differ diff --git a/.github/pr-assets/1151/screenshots/02-after-real-backend-clean.png b/.github/pr-assets/1151/screenshots/02-after-real-backend-clean.png new file mode 100644 index 00000000..799e9b6d Binary files /dev/null and b/.github/pr-assets/1151/screenshots/02-after-real-backend-clean.png differ diff --git a/.github/pr-assets/1151/screenshots/03-hide-internals-toggle.png b/.github/pr-assets/1151/screenshots/03-hide-internals-toggle.png new file mode 100644 index 00000000..ee22279f Binary files /dev/null and b/.github/pr-assets/1151/screenshots/03-hide-internals-toggle.png differ diff --git a/.github/pr-assets/1151/screenshots/04-internals-shown-with-coalesce.png b/.github/pr-assets/1151/screenshots/04-internals-shown-with-coalesce.png new file mode 100644 index 00000000..176ffd9b Binary files /dev/null and b/.github/pr-assets/1151/screenshots/04-internals-shown-with-coalesce.png differ diff --git a/.github/pr-assets/1151/screenshots/05-after-mobile.png b/.github/pr-assets/1151/screenshots/05-after-mobile.png new file mode 100644 index 00000000..d630b041 Binary files /dev/null and b/.github/pr-assets/1151/screenshots/05-after-mobile.png differ diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index 4638e6b1..a533fc38 100644 --- a/ui/src/components/logs/derive-trace-groups.ts +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -6,55 +6,138 @@ const LEVEL_RANK: Record = { debug: 0, info: 1, warn: 2, erro export interface LeafItem { kind: 'leaf'; entry: LogsEntry; + /** + * When >1, this leaf represents N consecutive identical entries that have + * been coalesced. Row renderer shows a ×N badge so dashboard self-polling + * floods don't drown real signal. + */ + repeatCount?: number; + collapsedRange?: { fromTs: string; toTs: string }; } export type DerivedItem = LeafItem | TraceGroup; /** - * Pure helper: derive a flat list of either standalone leaves or trace groups - * (entries sharing a `requestId`). Stable, single-pass, O(n). + * Tuple key for coalescing standalone leaves. Includes `message` and + * `source` so two adjacent logs that share event/module/level but report + * different content (or come from a different service) stay distinct + * (e.g. `User logged in: alice` and `User logged in: bob`). Excludes + * `latencyMs` and `metadata` because those drift per request even on + * truly redundant polls — including them would prevent any coalescing. * - * Sort within a group is `ts asc` (defensive — backend may not guarantee it). - * Group ts = min child ts, used to slot the group amongst standalone leaves. - * Standalone leaves keep their original `ts` slot — never silently dropped. + * NB: this only applies to *leaves* (entries without `requestId`). Trace + * children render uncoalesced so retries and duplicated-stage emissions + * stay individually inspectable. + */ +function coalesceKey(entry: LogsEntry): string { + return [ + entry.event ?? '', + entry.message ?? '', + entry.stage ?? '', + entry.module ?? entry.source ?? '', + entry.level, + entry.requestId ?? '', + entry.source ?? '', + ].join(' '); +} + +/** + * For an entry without an explicit `stage` field, derive a short chip label + * from the event name so the trace timeline still renders meaningful badges + * instead of empty pills. Last `.`-segment, capped at 12 chars. + */ +export function deriveStageHint(entry: LogsEntry): string | undefined { + if (entry.stage && entry.stage.length > 0) return entry.stage; + if (entry.event && entry.event.length > 0) { + const last = entry.event.split('.').pop() ?? entry.event; + return last.slice(0, 12); + } + return undefined; +} + +/** + * Pure helper: derive a list of either standalone leaves or trace groups + * (entries sharing a `requestId`). + * + * Single-pass, O(n). Walks `entries` in input order so leaf coalescing + * sees the *real* adjacency: a leaf only extends the previous leaf when + * no other entry (trace child included) appeared between them. This + * preserves signal — two identical no-requestId entries split by an + * unrelated trace stay as two separate rows, not a fake `×2`. + * + * Trace groups gather all children sharing a requestId regardless of + * interleaving; they're sorted by `ts asc` before display, with the + * group's positional `ts` set to the oldest child so it slots correctly + * in the reverse-chronological display sort below. Children are NOT + * coalesced — every stage is preserved for individual inspection. */ export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] { - const groups = new Map(); - const leaves: LeafItem[] = []; + const items: DerivedItem[] = []; + const traceIndex = new Map(); // requestId -> index in items + let lastLeafIdx = -1; + let lastLeafKey = ''; + for (const entry of entries) { if (entry.requestId) { - const bucket = groups.get(entry.requestId); - if (bucket) bucket.push(entry); - else groups.set(entry.requestId, [entry]); + // Trace child. Append to existing group or create one. + const existingIdx = traceIndex.get(entry.requestId); + if (existingIdx !== undefined) { + (items[existingIdx] as TraceGroup).children.push(entry); + } else { + const grp: TraceGroup = { + kind: 'trace', + requestId: entry.requestId, + module: entry.module ?? entry.source, + source: entry.source, + ts: entry.timestamp, + maxLevel: entry.level, + totalLatencyMs: 0, + children: [entry], + }; + items.push(grp); + traceIndex.set(entry.requestId, items.length - 1); + } + // A trace entry breaks any leaf-run adjacency. + lastLeafIdx = -1; + lastLeafKey = ''; } else { - leaves.push({ kind: 'leaf', entry }); + // Standalone leaf. + const key = coalesceKey(entry); + if (lastLeafIdx >= 0 && key === lastLeafKey) { + const prev = items[lastLeafIdx] as LeafItem; + prev.repeatCount = (prev.repeatCount ?? 1) + 1; + prev.collapsedRange = { + fromTs: prev.collapsedRange?.fromTs ?? prev.entry.timestamp, + toTs: entry.timestamp, + }; + } else { + const leaf: LeafItem = { kind: 'leaf', entry }; + items.push(leaf); + lastLeafIdx = items.length - 1; + lastLeafKey = key; + } } } - const groupItems: TraceGroup[] = []; - for (const [requestId, children] of groups) { - const sorted = [...children].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - let maxLevel: LogsLevel = 'debug'; - let total = 0; - for (const c of sorted) { - if (LEVEL_RANK[c.level] > LEVEL_RANK[maxLevel]) maxLevel = c.level; - if (typeof c.latencyMs === 'number') total += c.latencyMs; + // Finalize trace groups: sort children, compute aggregates, set group ts. + for (const item of items) { + if (item.kind === 'trace') { + item.children.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + let maxLevel: LogsLevel = 'debug'; + let total = 0; + for (const c of item.children) { + if (LEVEL_RANK[c.level] > LEVEL_RANK[maxLevel]) maxLevel = c.level; + if (typeof c.latencyMs === 'number') total += c.latencyMs; + } + item.maxLevel = maxLevel; + item.totalLatencyMs = total; + const head = item.children[0]; + if (head) item.ts = head.timestamp; } - const head = sorted[0]; - if (!head) continue; - groupItems.push({ - kind: 'trace', - requestId, - module: head.module ?? head.source, - source: head.source, - ts: head.timestamp, - maxLevel, - totalLatencyMs: total, - children: sorted, - }); } - return [...groupItems, ...leaves].sort((a, b) => { + // Display sort: newest first. + return items.sort((a, b) => { const at = a.kind === 'trace' ? a.ts : a.entry.timestamp; const bt = b.kind === 'trace' ? b.ts : b.entry.timestamp; return bt.localeCompare(at); diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx index 9f977127..09c68564 100644 --- a/ui/src/components/logs/logs-detail-panel.tsx +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -7,7 +7,13 @@ import type { LogsEntry } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { LogLevelBadge } from './log-level-badge'; import { LogsEmpty } from './logs-empty'; -import { formatJson } from './utils'; +import { + formatJson, + getDisplayLatency, + getDisplayModule, + getDisplayRequestId, + getDisplayStage, +} from './utils'; import { FOCUS_RING, MONO_NUMERIC } from './tokens'; export interface LogsDetailPanelProps { @@ -26,19 +32,15 @@ interface OverviewRow { } function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] { + // Use shared accessors so this panel and the list row never diverge. return [ { label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true }, { label: 'Level', value: entry.level }, - { label: 'Module', value: entry.module ?? '—' }, - { label: 'Stage', value: entry.stage ?? '—' }, - { label: 'Request ID', value: entry.requestId ?? '—', mono: true }, - { - label: 'Latency', - value: - entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : '—', - mono: true, - }, - { label: 'Source', value: sourceLabel ?? entry.source }, + { label: 'Module', value: getDisplayModule(entry, sourceLabel) }, + { label: 'Stage', value: getDisplayStage(entry) }, + { label: 'Request ID', value: getDisplayRequestId(entry), mono: true }, + { label: 'Latency', value: getDisplayLatency(entry), mono: true }, + { label: 'Source', value: sourceLabel ?? entry.source ?? '—' }, { label: 'Run ID', value: entry.runId ?? '—', mono: true }, { label: 'Process ID', value: entry.processId ?? '—', mono: true }, ]; diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx index b03687e8..f8fe46dc 100644 --- a/ui/src/components/logs/logs-entry-list.tsx +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -24,8 +24,13 @@ export interface LogsEntryListProps { density?: RowDensity; } +// Column layout shared by the header and every row variant: +// chevron(16) · time(88) · level(64) · stage(72) · module(140) · message(flex) · latency(72) · request(112) +// The dedicated `stage` column is rendered as an empty span on rows that +// have no stage hint so column edges align across leaf, trace head, and +// trace child rows alike. const COLS_TEMPLATE = - 'grid grid-cols-[88px_64px_140px_minmax(0,1fr)_72px_88px] items-center gap-3 px-3'; + 'grid grid-cols-[16px_88px_64px_72px_140px_minmax(0,1fr)_72px_112px] items-center gap-3 px-3'; export function LogsEntryList({ entries, @@ -38,30 +43,72 @@ export function LogsEntryList({ density = 'cozy', }: LogsEntryListProps) { const items = useMemo(() => deriveTraceGroups(entries), [entries]); - const [expanded, setExpanded] = useState>(new Set()); + // Two-set tristate: `userExpanded` = explicitly opened, `userCollapsed` + // = explicitly closed. Auto-expand (when selection lives inside a trace) + // only fires when the id is in neither set, so a user click on an + // auto-expanded chevron actually collapses — previously it added to + // `expanded` while auto-expand kept showing it, so no visible change. + const [userExpanded, setUserExpanded] = useState>(new Set()); + const [userCollapsed, setUserCollapsed] = useState>(new Set()); - const toggle = useCallback((requestId: string) => { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(requestId)) next.delete(requestId); - else next.add(requestId); - return next; - }); - }, []); - - // Auto-expand the trace whose child is currently selected. - const effectiveExpanded = useMemo(() => { - if (!selectedEntryId) return expanded; + // Compute the auto-expand id (if any) — derived inside toggle and the + // effective set so a single source of truth drives both render and + // click logic. + const autoExpandedId = useMemo(() => { + if (!selectedEntryId) return null; const owning = items.find( (it) => it.kind === 'trace' && it.children.some((c) => c.id === selectedEntryId) ); - if (owning && owning.kind === 'trace' && !expanded.has(owning.requestId)) { - const next = new Set(expanded); - next.add(owning.requestId); - return next; + return owning && owning.kind === 'trace' ? owning.requestId : null; + }, [items, selectedEntryId]); + + const toggle = useCallback( + (requestId: string) => { + const isAutoExpanded = autoExpandedId === requestId; + const isExplicitlyOpen = userExpanded.has(requestId); + const isExplicitlyClosed = userCollapsed.has(requestId); + const isCurrentlyOpen = isExplicitlyOpen || (isAutoExpanded && !isExplicitlyClosed); + + if (isCurrentlyOpen) { + // Close: clear user-open, mark user-closed (beats auto-expand). + setUserExpanded((prev) => { + if (!prev.has(requestId)) return prev; + const next = new Set(prev); + next.delete(requestId); + return next; + }); + setUserCollapsed((prev) => { + if (prev.has(requestId)) return prev; + const next = new Set(prev); + next.add(requestId); + return next; + }); + } else { + // Open: clear user-closed, mark user-open. + setUserExpanded((prev) => { + if (prev.has(requestId)) return prev; + const next = new Set(prev); + next.add(requestId); + return next; + }); + setUserCollapsed((prev) => { + if (!prev.has(requestId)) return prev; + const next = new Set(prev); + next.delete(requestId); + return next; + }); + } + }, + [autoExpandedId, userExpanded, userCollapsed] + ); + + const effectiveExpanded = useMemo(() => { + const result = new Set(userExpanded); + if (autoExpandedId && !userCollapsed.has(autoExpandedId)) { + result.add(autoExpandedId); } - return expanded; - }, [expanded, items, selectedEntryId]); + return result; + }, [userExpanded, userCollapsed, autoExpandedId]); const renderItem = useCallback( (_index: number, item: DerivedItem) => { @@ -85,6 +132,7 @@ export function LogsEntryList({ density={density} sourceLabel={sourceLabels[item.entry.source] ?? item.entry.source} onSelect={onSelect} + repeatCount={item.repeatCount} /> ); }, @@ -98,11 +146,13 @@ export function LogsEntryList({ role="row" className={cn( COLS_TEMPLATE, - 'h-8 shrink-0 border-b border-border bg-muted/30 text-[10px] font-medium uppercase tracking-wide text-muted-foreground' + 'h-9 shrink-0 border-b border-border bg-muted/30 text-[12px] font-medium uppercase tracking-wide text-muted-foreground' )} > +