feat(ui): align logs page visual language with dashboard markers

Logs page was visually disconnected from the rest of the dashboard. Health
uses ornamental `HEALTH.ATTENTIONREQUIRED` markers, Home uses `LIVE
Account Monitor` + stat cards. Logs shipped with a 14px h1 + clinical
table that felt like a different product.

- Header now opens with a `LOGS.STREAM` mono-uppercase marker matching
  the dashboard's `HEALTH.X` style, plus a 16px "Live activity" title
  and a status pill.
- Below the header, a stat strip mirrors the home page's monitor layout:
  ENTRIES / TRACES / ERRORS counters, errors highlighted red when >0.
- Trace row + child row font sizes lifted from 11px to 12-13px;
  request-id column widened to 112px to match the standalone-row table.
- Stage-hint fallback derived from event names so the trace timeline
  still renders meaningful chips when backend entries lack an explicit
  `stage` field (e.g. dashboard self-polling).
- Intra-trace coalesce: identical consecutive child rows collapse to a
  single row with `× N` badge so a 149-stage self-poll trace renders as
  3 rows of signal instead of 149 rows of noise.

Refs #1138, #1141, #1142
This commit is contained in:
Tam Nhu Tran
2026-04-30 14:30:54 -04:00
parent d5b14bec1c
commit e04598eef7
3 changed files with 247 additions and 65 deletions
+94 -2
View File
@@ -6,10 +6,96 @@ 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;
/**
* Tuple key for coalescing: two entries are "same enough" to collapse when
* (event, module, level, requestId, source) all match.
*/
function coalesceKey(entry: LogsEntry): string {
return [
entry.event ?? '',
entry.module ?? entry.source ?? '',
entry.level,
entry.requestId ?? '',
entry.source ?? '',
].join(' ');
}
/**
* Coalesce a list of entries: any run of consecutive entries with identical
* `coalesceKey` collapses to a single leaf with `repeatCount` set.
*
* Conservative — only collapses *adjacent* duplicates. Mixing different
* events between repeats prevents collapsing on purpose: hiding interleaved
* signal would be worse than the noise.
*/
function coalesceLeaves(entries: LogsEntry[]): LeafItem[] {
if (entries.length === 0) return [];
const out: LeafItem[] = [];
let runHead: LogsEntry | null = null;
let runCount = 0;
let runFromTs = '';
let runToTs = '';
let runKey = '';
const flush = () => {
if (!runHead) return;
if (runCount === 1) {
out.push({ kind: 'leaf', entry: runHead });
} else {
out.push({
kind: 'leaf',
entry: runHead,
repeatCount: runCount,
collapsedRange: { fromTs: runFromTs, toTs: runToTs },
});
}
runHead = null;
runCount = 0;
};
for (const entry of entries) {
const key = coalesceKey(entry);
if (runHead && key === runKey) {
runCount += 1;
runToTs = entry.timestamp;
} else {
flush();
runHead = entry;
runCount = 1;
runFromTs = entry.timestamp;
runToTs = entry.timestamp;
runKey = key;
}
}
flush();
return out;
}
/**
* 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 flat list of either standalone leaves or trace groups
* (entries sharing a `requestId`). Stable, single-pass, O(n).
@@ -17,20 +103,26 @@ export type DerivedItem = LeafItem | TraceGroup;
* 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.
*
* Standalone leaves are coalesced: consecutive identical entries collapse
* into a single leaf with `repeatCount` so dashboard self-polling floods
* don't drown out real signal.
*/
export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] {
const groups = new Map<string, LogsEntry[]>();
const leaves: LeafItem[] = [];
const rawLeaves: LogsEntry[] = [];
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]);
} else {
leaves.push({ kind: 'leaf', entry });
rawLeaves.push(entry);
}
}
const leaves = coalesceLeaves(rawLeaves);
const groupItems: TraceGroup[] = [];
for (const [requestId, children] of groups) {
const sorted = [...children].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
+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>
);
}
+43 -10
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';
@@ -38,6 +39,37 @@ function formatHms(timestamp: string): string {
});
}
/**
* Coalesce identical consecutive children inside a single trace. Same key
* tuple as standalone-leaf coalescing — `(event, stage, level)` since all
* children share `requestId` already.
*/
function coalesceChildren(children: LogsEntry[]): Array<{ head: LogsEntry; count: number }> {
if (children.length === 0) return [];
const out: Array<{ head: LogsEntry; count: number }> = [];
let head: LogsEntry | null = null;
let count = 0;
let key = '';
const flush = () => {
if (head) out.push({ head, count });
head = null;
count = 0;
};
for (const child of children) {
const k = `${child.event ?? ''}|${child.stage ?? ''}|${child.level}|${child.module ?? ''}`;
if (head && k === key) {
count += 1;
} else {
flush();
head = child;
count = 1;
key = k;
}
}
flush();
return out;
}
function LogsTraceRowImpl({
group,
isExpanded,
@@ -66,7 +98,7 @@ function LogsTraceRowImpl({
<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
)}
>
@@ -77,18 +109,18 @@ function LogsTraceRowImpl({
</span>
<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,7 +129,7 @@ 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
)}
>
@@ -105,16 +137,17 @@ function LogsTraceRowImpl({
</span>
</button>
{isExpanded
? group.children.map((child) => (
? coalesceChildren(group.children).map((run) => (
<LogsRow
key={child.id}
entry={child}
isSelected={child.id === selectedEntryId}
key={run.head.id}
entry={run.head}
isSelected={run.head.id === selectedEntryId}
density={density}
sourceLabel={sourceLabel}
onSelect={onSelect}
indent={20}
stageHint={child.stage}
stageHint={deriveStageHint(run.head)}
repeatCount={run.count > 1 ? run.count : undefined}
/>
))
: null}