From e04598eef777746d1898d6ddc22eb527c9badcd3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 14:30:54 -0400 Subject: [PATCH] feat(ui): align logs page visual language with dashboard markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/components/logs/derive-trace-groups.ts | 96 ++++++++++- ui/src/components/logs/logs-header.tsx | 163 ++++++++++++------ ui/src/components/logs/logs-trace-row.tsx | 53 ++++-- 3 files changed, 247 insertions(+), 65 deletions(-) diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index 4638e6b1..d1e9059f 100644 --- a/ui/src/components/logs/derive-trace-groups.ts +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -6,10 +6,96 @@ const LEVEL_RANK: Record = { debug: 0, info: 1, warn: 2, erro export interface LeafItem { kind: 'leaf'; entry: LogsEntry; + /** + * When >1, this leaf represents N consecutive identical entries that have + * been coalesced. Row renderer shows a ×N badge so dashboard self-polling + * floods don't drown real signal. + */ + repeatCount?: number; + collapsedRange?: { fromTs: string; toTs: string }; } export type DerivedItem = LeafItem | TraceGroup; +/** + * 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(); - 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)); diff --git a/ui/src/components/logs/logs-header.tsx b/ui/src/components/logs/logs-header.tsx index 4eee6979..e403b905 100644 --- a/ui/src/components/logs/logs-header.tsx +++ b/ui/src/components/logs/logs-header.tsx @@ -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 ( -
-
-