mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 08:18:04 +00:00
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:
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 |
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user