mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user