Merge pull request #1151 from kaitranntt/kai/feat/1138-final-touches

feat(ui): logs page final-touches audit fixes (#1138)
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-30 16:44:36 -04:00
commit 92761903eb
16 files changed
+712 -142

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+115 -32
View File
@@ -6,55 +6,138 @@ const LEVEL_RANK: Record<LogsLevel, number> = { 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<string, LogsEntry[]>();
const leaves: LeafItem[] = [];
const items: DerivedItem[] = [];
const traceIndex = new Map<string, number>(); // 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);
+13 -11
View File
@@ -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 },
];
+71 -21
View File
@@ -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<Set<string>>(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<Set<string>>(new Set());
const [userCollapsed, setUserCollapsed] = useState<Set<string>>(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'
)}
>
<span role="columnheader" aria-hidden="true" />
<span role="columnheader">Time</span>
<span role="columnheader">Level</span>
<span role="columnheader">Stage</span>
<span role="columnheader">Module</span>
<span role="columnheader">Message</span>
<span role="columnheader" className="text-right">
+30
View File
@@ -44,6 +44,9 @@ export interface LogsFiltersProps {
onRequestIdChange?: (v: string) => void;
timeWindow?: LogsTimeWindow;
onTimeWindowChange?: (v: LogsTimeWindow) => void;
/** When true, hides entries from `web-server:*` sources. Default ON. */
hideDashboardInternals?: boolean;
onHideDashboardInternalsChange?: (next: boolean) => void;
onClearAll?: () => void;
}
@@ -88,6 +91,8 @@ export function LogsFilters({
onRequestIdChange,
timeWindow = 'all',
onTimeWindowChange,
hideDashboardInternals = true,
onHideDashboardInternalsChange,
onClearAll,
}: LogsFiltersProps) {
const [advancedOpen, setAdvancedOpen] = useState(false);
@@ -312,6 +317,31 @@ export function LogsFilters({
</Select>
</div>
) : null}
{onHideDashboardInternalsChange ? (
<div className="flex items-start justify-between gap-3 rounded border border-border/60 bg-muted/20 p-2">
<div className="space-y-0.5">
<Label
htmlFor="logs-hide-internals"
className="block text-[12px] font-medium text-foreground"
>
Hide dashboard internals
</Label>
<p className="text-[11px] text-muted-foreground">
Suppress <code className="rounded bg-background px-1">web-server:*</code>{' '}
self-polling.
</p>
</div>
<input
id="logs-hide-internals"
type="checkbox"
role="switch"
checked={hideDashboardInternals}
onChange={(e) => onHideDashboardInternalsChange(e.target.checked)}
className={cn('mt-0.5 h-4 w-4 cursor-pointer accent-foreground', FOCUS_RING)}
aria-label="Hide dashboard internals"
/>
</div>
) : null}
</CollapsibleContent>
</Collapsible>
+110 -53
View File
@@ -1,29 +1,40 @@
import { RefreshCw, Settings, ScrollText, HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { FOCUS_RING } from './tokens';
import { FOCUS_RING, MONO_NUMERIC } from './tokens';
export interface LogsHeaderStats {
entries: number;
traces: number;
errors: number;
}
export interface LogsHeaderProps {
isFetching: boolean;
hasError: boolean;
capturedCount: number;
stats?: LogsHeaderStats;
onRefresh: () => void;
onOpenSettings: () => void;
onOpenShortcuts: () => void;
}
/**
* Calm 48px header for the logs surface.
* - Title + icon on the left
* - Live connection pill in the middle (idle/syncing/error)
* - Refresh + settings buttons on the right
* Logs page header.
*
* No glows, no decorative captions, no scale animations.
* Two rows:
* 1. Title bar (`LOGS.STREAM` ornamental marker matching dashboard's
* `HEALTH.X` design language) + status pill + actions.
* 2. Stat strip — entries / traces / errors counters mirroring the home
* page's `LIVE Account Monitor` card grid.
*
* Visual unity is the goal here, not minimalism for its own sake.
*/
export function LogsHeader({
isFetching,
hasError,
capturedCount,
stats,
onRefresh,
onOpenSettings,
onOpenShortcuts,
@@ -35,56 +46,102 @@ export function LogsHeader({
status === 'error' ? 'bg-red-500' : status === 'syncing' ? 'bg-amber-500' : 'bg-emerald-500';
return (
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4">
<div className="flex items-center gap-3">
<ScrollText className="h-4 w-4 text-foreground/70" aria-hidden="true" />
<h1 className="text-sm font-semibold tracking-tight text-foreground">Logs</h1>
<span
role="status"
className="ml-3 inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] font-medium text-foreground/70"
>
<span className={cn('h-1.5 w-1.5 rounded-full', dotClass)} aria-hidden="true" />
{statusLabel}
</span>
<span className="text-[11px] font-medium tabular-nums text-muted-foreground">
{capturedCount} entries
</span>
<div className="shrink-0 border-b border-border bg-background">
<div className="flex h-14 items-center justify-between gap-4 px-4">
<div className="flex items-center gap-3">
<ScrollText className="h-4 w-4 text-foreground/70" aria-hidden="true" />
<span className="text-[11px] font-mono uppercase tracking-[0.18em] text-muted-foreground">
LOGS.STREAM
</span>
<h1 className="text-base font-semibold tracking-tight text-foreground">Live activity</h1>
<span
role="status"
aria-live="polite"
className="ml-2 inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[12px] font-medium text-foreground/80"
>
<span className={cn('h-1.5 w-1.5 rounded-full', dotClass)} aria-hidden="true" />
{statusLabel}
</span>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
aria-label="Refresh logs"
className={cn('h-8 gap-2 px-2.5 text-xs font-medium', FOCUS_RING)}
>
<RefreshCw
className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')}
aria-hidden="true"
/>
Refresh
</Button>
<Button
variant="ghost"
size="icon"
onClick={onOpenShortcuts}
aria-label="Show keyboard shortcuts"
title="Keyboard shortcuts (?)"
className={cn('h-8 w-8', FOCUS_RING)}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onOpenSettings}
aria-label="Open logging settings"
className={cn('h-8 w-8', FOCUS_RING)}
>
<Settings className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
aria-label="Refresh logs"
className={cn('h-8 gap-2 px-2 text-xs font-medium', FOCUS_RING)}
>
<RefreshCw
className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')}
aria-hidden="true"
/>
Refresh
</Button>
<Button
variant="ghost"
size="icon"
onClick={onOpenShortcuts}
aria-label="Show keyboard shortcuts"
title="Keyboard shortcuts (?)"
className={cn('h-8 w-8', FOCUS_RING)}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onOpenSettings}
aria-label="Open logging settings"
className={cn('h-8 w-8', FOCUS_RING)}
>
<Settings className="h-4 w-4" aria-hidden="true" />
</Button>
{/* Stat strip — visual unity with home page's `LIVE Account Monitor`. */}
<div className="flex items-center gap-6 border-t border-border/60 bg-muted/20 px-4 py-2">
<Stat label="Entries" value={capturedCount} fallback={`${capturedCount}`} />
{stats ? (
<>
<Stat label="Traces" value={stats.traces} fallback={`${stats.traces}`} />
<Stat
label="Errors"
value={stats.errors}
fallback={`${stats.errors}`}
tone={stats.errors > 0 ? 'error' : 'neutral'}
/>
</>
) : null}
</div>
</div>
);
}
interface StatProps {
label: string;
value: number;
fallback: string;
tone?: 'neutral' | 'error';
}
function Stat({ label, value, fallback, tone = 'neutral' }: StatProps) {
return (
<div className="flex items-baseline gap-2">
<span className="text-[10px] font-mono uppercase tracking-[0.14em] text-muted-foreground">
{label}
</span>
<span
className={cn(
'text-[14px] font-semibold tabular-nums',
tone === 'error' ? 'text-red-600 dark:text-red-400' : 'text-foreground',
MONO_NUMERIC
)}
title={String(value)}
>
{fallback}
</span>
</div>
);
}
+98 -17
View File
@@ -1,8 +1,10 @@
import { memo } from 'react';
import { memo, useState, type KeyboardEvent, type MouseEvent } from 'react';
import { Copy } from 'lucide-react';
import type { LogsEntry } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { LogLevelBadge } from './log-level-badge';
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
import { getDisplayLatency, getDisplayModule, getDisplayRequestId } from './utils';
export interface LogsRowProps {
entry: LogsEntry;
@@ -14,6 +16,8 @@ export interface LogsRowProps {
indent?: number;
/** Optional stage chip text (e.g. for child rows of a trace). */
stageHint?: string;
/** Optional repeat counter — when the row coalesces N identical consecutive entries. */
repeatCount?: number;
}
function formatHms(timestamp: string): string {
@@ -27,6 +31,17 @@ function formatHms(timestamp: string): string {
});
}
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Insecure context or clipboard permission denied. Caller should NOT
// claim success in the UI when this returns false.
return false;
}
}
function LogsRowImpl({
entry,
isSelected,
@@ -35,68 +50,133 @@ function LogsRowImpl({
onSelect,
indent = 0,
stageHint,
repeatCount,
}: LogsRowProps) {
const [justCopied, setJustCopied] = useState(false);
const moduleLabel = getDisplayModule(entry, sourceLabel);
const latencyLabel = getDisplayLatency(entry);
const shortRequestId = getDisplayRequestId(entry, { short: true });
const handleSelect = () => onSelect(entry.id);
const handleKey = (e: KeyboardEvent<HTMLDivElement>) => {
// Ignore keys that bubbled from a nested interactive element (e.g. the
// copy-requestId button). Without this guard, pressing Enter on the
// copy button would also select the row and shift the detail panel.
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect();
}
};
const handleCopyRequestId = async (e: MouseEvent) => {
e.stopPropagation();
if (!entry.requestId) return;
const ok = await copyText(entry.requestId);
if (!ok) return;
setJustCopied(true);
window.setTimeout(() => setJustCopied(false), 1200);
};
// Row is a focusable div (not a button) so it can host a real <button>
// for the copy-requestId affordance — nesting interactive elements
// inside a <button> is invalid HTML and breaks keyboard focus order.
return (
<button
type="button"
<div
role="row"
tabIndex={0}
aria-selected={isSelected}
data-selected={isSelected}
onClick={() => onSelect(entry.id)}
onClick={handleSelect}
onKeyDown={handleKey}
style={{ paddingLeft: 12 + indent }}
className={cn(
'flex w-full items-center gap-3 border-b border-border/50 pr-3 text-left',
'group flex w-full cursor-pointer items-center gap-3 border-b border-border/50 pr-3 text-left',
ROW_DENSITY[density],
ROW_INTERACTIVE,
FOCUS_RING,
isSelected && 'bg-muted/60 shadow-[inset_2px_0_0_var(--ring)]'
)}
>
{/* Leading 16px slot mirrors the trace-row chevron column so leaf
rows align under the same column edges as trace rows. */}
<span className="w-4 shrink-0" aria-hidden="true" />
<span
role="cell"
className={cn('w-[88px] shrink-0 truncate text-[11px] text-muted-foreground', MONO_NUMERIC)}
className={cn('w-[88px] shrink-0 truncate text-[12px] text-muted-foreground', MONO_NUMERIC)}
>
{formatHms(entry.timestamp)}
</span>
<span role="cell" className="flex w-[64px] shrink-0 items-center">
<LogLevelBadge level={entry.level} />
</span>
{/* Always reserve the stage column so leaf, trace head, and trace
child rows all align under the same column edges in the header
grid template. Children with `stageHint` show a chip; rows
without one render an empty span at the same width. */}
{stageHint ? (
<span
role="cell"
className="w-[72px] shrink-0 truncate rounded border border-border bg-muted/30 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"
className="w-[72px] shrink-0 truncate rounded border border-border bg-muted/30 px-1.5 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground"
>
{stageHint}
</span>
) : null}
) : (
<span role="cell" className="w-[72px] shrink-0" aria-hidden="true" />
)}
<span
role="cell"
className="w-[140px] shrink-0 truncate text-[11px] font-medium text-foreground/80"
className="w-[140px] shrink-0 truncate text-[12px] font-medium text-foreground/80"
>
{entry.module ?? sourceLabel}
{moduleLabel}
</span>
<span role="cell" className="min-w-0 flex-1 truncate text-foreground/90">
<span role="cell" className="min-w-0 flex-1 truncate text-[13px] text-foreground/90">
{entry.message}
{repeatCount && repeatCount > 1 ? (
<span
className="ml-2 inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground"
title={`${repeatCount} consecutive identical entries`}
>
×{repeatCount}
</span>
) : null}
</span>
<span
role="cell"
className={cn(
'hidden w-[72px] shrink-0 truncate text-right text-[11px] text-muted-foreground sm:inline-block',
'hidden w-[72px] shrink-0 truncate text-right text-[12px] text-muted-foreground sm:inline-block',
MONO_NUMERIC
)}
>
{entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : ''}
{latencyLabel === '—' ? '' : latencyLabel}
</span>
<span
role="cell"
className={cn(
'hidden w-[88px] shrink-0 truncate text-right text-[11px] text-muted-foreground/80 lg:inline-block',
'hidden w-[112px] shrink-0 items-center justify-end gap-1 text-right text-[12px] text-muted-foreground/80 lg:inline-flex',
MONO_NUMERIC
)}
>
{entry.requestId ? entry.requestId.slice(-8) : ''}
{entry.requestId ? (
<>
<span className="truncate">{shortRequestId}</span>
<button
type="button"
aria-label={justCopied ? 'Copied requestId' : 'Copy requestId'}
title={justCopied ? 'Copied' : 'Copy requestId'}
onClick={handleCopyRequestId}
className={cn(
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground/50 opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100',
FOCUS_RING,
justCopied && 'text-emerald-600 opacity-100'
)}
>
<Copy className="h-3 w-3" aria-hidden="true" />
</button>
</>
) : null}
</span>
</button>
</div>
);
}
@@ -107,6 +187,7 @@ export const LogsRow = memo(LogsRowImpl, (prev, next) => {
prev.density === next.density &&
prev.indent === next.indent &&
prev.stageHint === next.stageHint &&
prev.sourceLabel === next.sourceLabel
prev.sourceLabel === next.sourceLabel &&
prev.repeatCount === next.repeatCount
);
});
+15
View File
@@ -137,6 +137,18 @@ export function LogsShell({ workspace, updateConfig }: LogsShellProps) {
[workspace.entriesQuery.data]
);
// Derive header stat strip values once per data refetch.
const headerStats = useMemo(() => {
const data = workspace.entriesQuery.data ?? [];
const requestIds = new Set<string>();
let errors = 0;
for (const entry of data) {
if (entry.requestId) requestIds.add(entry.requestId);
if (entry.level === 'error') errors += 1;
}
return { entries: data.length, traces: requestIds.size, errors };
}, [workspace.entriesQuery.data]);
const focusSearch = useCallback(() => {
const input = document.getElementById('logs-search') as HTMLInputElement | null;
if (input) {
@@ -180,6 +192,8 @@ export function LogsShell({ workspace, updateConfig }: LogsShellProps) {
onRequestIdChange={workspace.setRequestIdFilter}
timeWindow={workspace.timeWindow}
onTimeWindowChange={workspace.setTimeWindow}
hideDashboardInternals={workspace.hideDashboardInternals}
onHideDashboardInternalsChange={workspace.setHideDashboardInternals}
onClearAll={workspace.clearAdvancedFilters}
/>
);
@@ -228,6 +242,7 @@ export function LogsShell({ workspace, updateConfig }: LogsShellProps) {
isFetching={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching}
hasError={Boolean(workspace.entriesQuery.error)}
capturedCount={workspace.entriesQuery.data?.length ?? 0}
stats={headerStats}
onRefresh={handleRefresh}
onOpenSettings={openSettings}
onOpenShortcuts={() => setShortcutsOpen(true)}
+20 -7
View File
@@ -2,6 +2,7 @@ import { memo } from 'react';
import { ChevronDown, ChevronRight, GitBranch } from 'lucide-react';
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { deriveStageHint } from './derive-trace-groups';
import { LogLevelBadge } from './log-level-badge';
import { LogsRow } from './logs-row';
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
@@ -62,11 +63,14 @@ function LogsTraceRowImpl({
FOCUS_RING
)}
>
<Chevron className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
{/* Reserve the same 16px slot as leaf rows so all columns align. */}
<span className="flex w-4 shrink-0 items-center justify-center" aria-hidden="true">
<Chevron className="h-3.5 w-3.5 text-muted-foreground" />
</span>
<span
role="cell"
className={cn(
'w-[88px] shrink-0 truncate text-[11px] text-muted-foreground',
'w-[88px] shrink-0 truncate text-[12px] text-muted-foreground',
MONO_NUMERIC
)}
>
@@ -75,20 +79,22 @@ function LogsTraceRowImpl({
<span role="cell" className="flex w-[64px] shrink-0 items-center">
<LogLevelBadge level={group.maxLevel} />
</span>
{/* Empty stage column for grid alignment with header + leaf rows. */}
<span role="cell" className="w-[72px] shrink-0" aria-hidden="true" />
<span
role="cell"
className="flex w-[140px] shrink-0 items-center gap-1.5 truncate text-[11px] font-medium text-foreground/80"
className="flex w-[140px] shrink-0 items-center gap-1.5 truncate text-[12px] font-medium text-foreground/80"
>
<GitBranch className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
{group.module}
</span>
<span role="cell" className="min-w-0 flex-1 truncate text-foreground/90">
<span role="cell" className="min-w-0 flex-1 truncate text-[13px] text-foreground/90">
<span className="text-muted-foreground">trace · {group.children.length} stages</span>
</span>
<span
role="cell"
className={cn(
'hidden w-[72px] shrink-0 truncate text-right text-[11px] text-muted-foreground sm:inline-block',
'hidden w-[72px] shrink-0 truncate text-right text-[12px] text-muted-foreground sm:inline-block',
MONO_NUMERIC
)}
>
@@ -97,13 +103,20 @@ function LogsTraceRowImpl({
<span
role="cell"
className={cn(
'hidden w-[88px] shrink-0 truncate text-right text-[11px] text-muted-foreground lg:inline-block',
'hidden w-[112px] shrink-0 truncate text-right text-[12px] text-muted-foreground lg:inline-block',
MONO_NUMERIC
)}
>
{group.requestId.slice(-8)}
</span>
</button>
{/* Children render unconditionally — every stage is preserved so
retries, duplicated stage logs, and multi-attempt traces stay
individually inspectable. The user-facing noise problem this
PR addresses lives at the standalone-leaf level (dashboard
self-polling spam) where leaf-coalesce in deriveTraceGroups
handles it; opting in to see internals is a deliberate debug
choice, so collapsing trace stages there would hide intent. */}
{isExpanded
? group.children.map((child) => (
<LogsRow
@@ -114,7 +127,7 @@ function LogsTraceRowImpl({
sourceLabel={sourceLabel}
onSelect={onSelect}
indent={20}
stageHint={child.stage}
stageHint={deriveStageHint(child)}
/>
))
: null}
+35 -1
View File
@@ -1,4 +1,4 @@
import type { LogsLevel } from '@/lib/api-client';
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
// NOTE: This module contains utility functions that are not directly i18n-aware.
// String literals here ("No activity yet", "Error", etc.) are used as fallbacks
// and defaults in non-component contexts. Components consuming these values
@@ -80,3 +80,37 @@ export function getLevelLabel(level: LogsLevel) {
return 'Debug';
}
}
// Field accessors shared by list row + detail panel.
// Without these, list and detail diverged — list fell back to source/sourceLabel
// while detail showed `—` for the same entry. Single source of truth.
export function getDisplayModule(entry: LogsEntry, sourceLabel?: string): string {
return entry.module ?? sourceLabel ?? entry.source ?? '—';
}
export function getDisplayStage(entry: LogsEntry): string {
return entry.stage ?? '—';
}
export function getDisplayRequestId(entry: LogsEntry, options: { short?: boolean } = {}): string {
if (!entry.requestId) return '—';
return options.short ? entry.requestId.slice(-8) : entry.requestId;
}
export function getDisplayLatency(entry: LogsEntry): string {
if (entry.latencyMs === undefined || entry.latencyMs === null) return '—';
return `${entry.latencyMs}ms`;
}
/**
* Default filter pattern: dashboard self-polling sources start with `web-server:`.
* UI applies as a default exclusion to keep the logs view focused on signal,
* not the dashboard observing itself.
*/
export const DASHBOARD_INTERNALS_PATTERN = /^web-server:/i;
export function isDashboardInternal(entry: LogsEntry): boolean {
if (!entry.source) return false;
return DASHBOARD_INTERNALS_PATTERN.test(entry.source);
}
+11
View File
@@ -116,6 +116,10 @@ export function useLogsWorkspace() {
const [limit, setLimit] = useState(DEFAULT_LIMIT);
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [isPaused, setIsPaused] = useState(false);
// Default ON: dashboard self-polling generates 100s of identical entries
// per refresh which drown out real provider activity. Users can opt in to
// see internals via the advanced filter toggle.
const [hideDashboardInternals, setHideDashboardInternals] = useState(true);
const frozenIdsRef = useRef<Set<string>>(new Set());
const deferredSearch = useDeferredValue(search.trim());
@@ -151,6 +155,7 @@ export function useLogsWorkspace() {
debouncedRequestId,
timeWindow,
limit,
hideDashboardInternals ? 'hide-internals' : 'show-internals',
mockEnabled ? 'mock' : 'live',
],
queryFn: async () => {
@@ -191,6 +196,9 @@ export function useLogsWorkspace() {
const ts = Date.parse(entry.timestamp);
if (Number.isFinite(ts) && now - ts > cutoffMs) return false;
}
// Hide dashboard self-polling unless user opted in. Preserves the
// signal-to-noise ratio for fresh-load investigations.
if (hideDashboardInternals && /^web-server:/i.test(entry.source)) return false;
return true;
});
const head = filtered[0];
@@ -265,6 +273,7 @@ export function useLogsWorkspace() {
setStageFilter('');
setRequestIdFilter('');
setTimeWindow('all');
setHideDashboardInternals(true);
}, []);
return {
@@ -285,6 +294,8 @@ export function useLogsWorkspace() {
setRequestIdFilter,
timeWindow,
setTimeWindow,
hideDashboardInternals,
setHideDashboardInternals,
limit,
setLimit,
selectedEntryId: activeSelectedEntryId,
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import type { LogsEntry } from '@/lib/api-client';
import {
deriveStageHint,
deriveTraceGroups,
type LeafItem,
} from '@/components/logs/derive-trace-groups';
import type { TraceGroup } from '@/components/logs/logs-trace-row';
// Minimal LogsEntry factory — fields the helpers actually look at.
function entry(overrides: Partial<LogsEntry> & Pick<LogsEntry, 'id' | 'timestamp'>): LogsEntry {
return {
level: 'info',
source: 'web-server:http',
event: 'event.default',
message: 'default message',
...overrides,
} as LogsEntry;
}
function leafEntries(
...overrides: Array<Partial<LogsEntry> & Pick<LogsEntry, 'id' | 'timestamp'>>
) {
return overrides.map(entry);
}
describe('deriveStageHint', () => {
it('returns explicit stage when present', () => {
expect(deriveStageHint(entry({ id: '1', timestamp: 't', stage: 'route' }))).toBe('route');
});
it('falls back to last `.`-segment of event when stage is missing', () => {
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'request.dispatched' }))).toBe(
'dispatched'
);
});
it('caps the derived hint at 12 chars', () => {
expect(
deriveStageHint(entry({ id: '1', timestamp: 't', event: 'foo.absurdlylongsegmentname' }))
).toBe('absurdlylong');
});
it('returns undefined when neither stage nor event is meaningful', () => {
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: '' }))).toBeUndefined();
});
it('treats single-segment event as its own hint', () => {
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'startup' }))).toBe('startup');
});
});
describe('deriveTraceGroups', () => {
it('returns empty array for empty input', () => {
expect(deriveTraceGroups([])).toEqual([]);
});
it('emits a single leaf for a no-requestId entry', () => {
const e = entry({ id: '1', timestamp: '2026-01-01T00:00:00Z' });
const result = deriveTraceGroups([e]);
expect(result).toHaveLength(1);
expect(result[0]?.kind).toBe('leaf');
expect((result[0] as LeafItem).entry).toBe(e);
expect((result[0] as LeafItem).repeatCount).toBeUndefined();
});
it('groups entries sharing a requestId into one trace', () => {
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1', stage: 'intake' },
{ id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1', stage: 'route' }
)
);
expect(result).toHaveLength(1);
expect(result[0]?.kind).toBe('trace');
expect((result[0] as TraceGroup).children).toHaveLength(2);
});
it('sorts trace children by ts ascending and pins group ts to oldest child', () => {
// Input order intentionally reverses ts so we can assert the sort.
const result = deriveTraceGroups(
leafEntries(
{ id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1' },
{ id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1' }
)
);
const trace = result[0] as TraceGroup;
expect(trace.children.map((c) => c.id)).toEqual(['1', '2']);
expect(trace.ts).toBe('2026-01-01T00:00:01Z');
});
it('coalesces two adjacent identical leaves into a single ×N row', () => {
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: 't1', event: 'poll', message: 'tick' },
{ id: '2', timestamp: 't2', event: 'poll', message: 'tick' }
)
);
expect(result).toHaveLength(1);
const leaf = result[0] as LeafItem;
expect(leaf.repeatCount).toBe(2);
expect(leaf.collapsedRange?.fromTs).toBe('t1');
expect(leaf.collapsedRange?.toTs).toBe('t2');
});
it('does NOT coalesce identical leaves split by an unrelated trace (round-3 fix)', () => {
// Two identical no-requestId entries with a trace between them: the run
// must break — they were not adjacent in the real stream.
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: 't1', event: 'poll', message: 'tick' },
{ id: '2', timestamp: 't2', requestId: 'req-1' },
{ id: '3', timestamp: 't3', event: 'poll', message: 'tick' }
)
);
// Expected: 1 trace + 2 distinct leaves (no ×N).
const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[];
expect(leaves).toHaveLength(2);
expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true);
});
it('keeps adjacent leaves with different stages as separate rows (round-6 fix)', () => {
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: 't1', event: 'e', message: 'm', stage: 'route' },
{ id: '2', timestamp: 't2', event: 'e', message: 'm', stage: 'dispatch' }
)
);
expect(result).toHaveLength(2);
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
});
it('keeps adjacent leaves with different messages as separate rows', () => {
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: 't1', event: 'login', message: 'alice' },
{ id: '2', timestamp: 't2', event: 'login', message: 'bob' }
)
);
expect(result).toHaveLength(2);
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
});
it('display-sorts items reverse-chronologically', () => {
// Use distinct events so leaves don't coalesce — testing display sort,
// not coalesce.
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'a' },
{ id: '2', timestamp: '2026-01-01T00:00:03Z', event: 'b' },
{ id: '3', timestamp: '2026-01-01T00:00:02Z', event: 'c' }
)
);
const ids = result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'));
expect(ids).toEqual(['2', '3', '1']);
});
it('computes max level across trace children', () => {
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: 't1', requestId: 'r', level: 'debug' },
{ id: '2', timestamp: 't2', requestId: 'r', level: 'error' },
{ id: '3', timestamp: 't3', requestId: 'r', level: 'info' }
)
);
expect((result[0] as TraceGroup).maxLevel).toBe('error');
});
it('sums latencyMs across trace children', () => {
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: 't1', requestId: 'r', latencyMs: 100 },
{ id: '2', timestamp: 't2', requestId: 'r', latencyMs: 50 },
{ id: '3', timestamp: 't3', requestId: 'r' /* no latency */ }
)
);
expect((result[0] as TraceGroup).totalLatencyMs).toBe(150);
});
it('preserves original input order when computing leaf adjacency, even if display sort reorders later', () => {
// Stream: [A, B, A] — A entries identical but split by B. A run of 1+1+1.
const result = deriveTraceGroups(
leafEntries(
{ id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'A' },
{ id: '2', timestamp: '2026-01-01T00:00:02Z', event: 'B' },
{ id: '3', timestamp: '2026-01-01T00:00:03Z', event: 'A' }
)
);
const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[];
expect(leaves).toHaveLength(3);
// None should have repeatCount because A and A weren't adjacent.
expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true);
});
});