diff --git a/ui/src/components/logs/__fixtures__/structured-log-entries.ts b/ui/src/components/logs/__fixtures__/structured-log-entries.ts new file mode 100644 index 00000000..d0a635d7 --- /dev/null +++ b/ui/src/components/logs/__fixtures__/structured-log-entries.ts @@ -0,0 +1,167 @@ +import type { LogsEntry, LogsLevel } from '@/lib/api-client'; + +/** + * Deterministic fixture for the redesigned logs surface. + * + * Mix: + * - 30 traces of 3-6 stages each (~140 entries) sharing a `requestId`. + * - ~60 standalone entries (no `requestId`) including pre-intake / system rows. + * - All 4 levels present; partial traces (incomplete stage list) included. + * + * Used in dev mode via `?mock=logs` URL flag (see `use-logs.ts`). Tree-shaken + * from production builds because the import site is guarded by + * `import.meta.env.DEV`. + */ + +const STAGES = ['intake', 'auth', 'route', 'handler', 'provider', 'response'] as const; + +const MODULES = [ + 'api.gateway', + 'auth.oauth', + 'cliproxy.router', + 'profile.runtime', + 'config.loader', + 'doctor.healthcheck', + 'cli.exec', + 'dashboard.api', +] as const; + +const SOURCES = ['ccs-cli', 'cliproxy', 'dashboard'] as const; + +const EVENTS = [ + 'request.received', + 'request.dispatched', + 'provider.invoked', + 'provider.responded', + 'response.flushed', + 'profile.activated', + 'config.read', + 'token.refreshed', +] as const; + +// Deterministic PRNG so the fixture is stable across reloads. +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const rng = mulberry32(1138_1142); + +function pick(items: readonly T[]): T { + return items[Math.floor(rng() * items.length)] as T; +} + +function pickLevel(weights = { debug: 0.5, info: 0.3, warn: 0.15, error: 0.05 }): LogsLevel { + const r = rng(); + let acc = 0; + for (const [level, w] of Object.entries(weights) as Array<[LogsLevel, number]>) { + acc += w; + if (r <= acc) return level; + } + return 'debug'; +} + +const BASE_TS = Date.parse('2026-04-30T17:00:00Z'); + +let entryCounter = 0; +function nextId(prefix: string): string { + entryCounter += 1; + return `${prefix}-${entryCounter.toString(36)}`; +} + +function buildTrace(traceIndex: number): LogsEntry[] { + const requestId = `req_${(traceIndex + 1).toString().padStart(4, '0')}`; + const traceStart = BASE_TS - traceIndex * 47_000 - Math.floor(rng() * 1500); + const stageCount = 3 + Math.floor(rng() * 4); // 3..6 + const moduleName = pick(MODULES); + const source = pick(SOURCES); + const stages = STAGES.slice(0, stageCount); + // ~12% of traces are partial -- drop a middle stage to simulate gaps + const partial = rng() < 0.12 && stages.length > 3; + const effectiveStages = partial + ? stages.filter((_, idx) => idx !== Math.floor(stages.length / 2)) + : stages; + + let cursor = traceStart; + return effectiveStages.map((stage, idx) => { + const latencyMs = 5 + Math.floor(rng() * 220); + cursor += latencyMs + Math.floor(rng() * 6); + const level = idx === effectiveStages.length - 1 && rng() < 0.18 ? 'error' : pickLevel(); + const event = pick(EVENTS); + const ts = new Date(cursor).toISOString(); + return { + id: nextId('trace'), + timestamp: ts, + level, + source, + event, + message: `${moduleName} ${stage} ${level === 'error' ? 'failed' : 'ok'}`, + processId: 4000 + (traceIndex % 8), + runId: `run_${(traceIndex % 12).toString().padStart(3, '0')}`, + context: undefined, + requestId, + module: moduleName, + stage, + latencyMs, + metadata: { + attempt: idx + 1, + traceIndex, + cacheHit: rng() < 0.3, + upstream: source, + }, + error: + level === 'error' + ? { + message: `${moduleName}.${stage} timed out after ${latencyMs}ms`, + code: 'ERR_TIMEOUT', + stack: `at ${moduleName}.${stage}\n at handler.run`, + } + : undefined, + } satisfies LogsEntry; + }); +} + +function buildStandalone(index: number): LogsEntry { + const ts = new Date(BASE_TS - index * 11_000 - Math.floor(rng() * 4000)).toISOString(); + const level = pickLevel({ debug: 0.55, info: 0.3, warn: 0.1, error: 0.05 }); + const moduleName = pick(MODULES); + const source = pick(SOURCES); + const event = pick(EVENTS); + return { + id: nextId('solo'), + timestamp: ts, + level, + source, + event, + message: `${moduleName} ${event.replace('.', ' ')}`, + processId: 4000 + (index % 8), + runId: null, + context: undefined, + requestId: undefined, + module: moduleName, + stage: undefined, + latencyMs: rng() < 0.6 ? Math.floor(rng() * 80) : undefined, + metadata: { standalone: true, source }, + error: undefined, + } satisfies LogsEntry; +} + +const traces: LogsEntry[] = []; +for (let i = 0; i < 30; i += 1) { + traces.push(...buildTrace(i)); +} + +const standalones: LogsEntry[] = []; +for (let i = 0; i < 60; i += 1) { + standalones.push(buildStandalone(i)); +} + +export const STRUCTURED_LOG_ENTRIES: LogsEntry[] = [...traces, ...standalones].sort((a, b) => + b.timestamp.localeCompare(a.timestamp) +); diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts new file mode 100644 index 00000000..4638e6b1 --- /dev/null +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -0,0 +1,62 @@ +import type { LogsEntry, LogsLevel } from '@/lib/api-client'; +import type { TraceGroup } from './logs-trace-row'; + +const LEVEL_RANK: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + +export interface LeafItem { + kind: 'leaf'; + entry: LogsEntry; +} + +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). + * + * 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. + */ +export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] { + const groups = new Map(); + const leaves: LeafItem[] = []; + 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 }); + } + } + + 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; + } + 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) => { + const at = a.kind === 'trace' ? a.ts : a.entry.timestamp; + const bt = b.kind === 'trace' ? b.ts : b.entry.timestamp; + return bt.localeCompare(at); + }); +} diff --git a/ui/src/components/logs/live-tail-controls.tsx b/ui/src/components/logs/live-tail-controls.tsx new file mode 100644 index 00000000..d5fe8ce7 --- /dev/null +++ b/ui/src/components/logs/live-tail-controls.tsx @@ -0,0 +1,64 @@ +import { Pause, Play, ArrowDownToLine } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { FOCUS_RING } from './tokens'; + +export interface LiveTailControlsProps { + isPaused: boolean; + pendingCount: number; + onTogglePause: () => void; + onJumpToBottom?: () => void; +} + +export function LiveTailControls({ + isPaused, + pendingCount, + onTogglePause, + onJumpToBottom, +}: LiveTailControlsProps) { + return ( +
+
+ + {isPaused && pendingCount > 0 ? ( + + ) : null} +
+ {onJumpToBottom ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/logs/log-level-badge.tsx b/ui/src/components/logs/log-level-badge.tsx index e4de4bee..ba8f0cba 100644 --- a/ui/src/components/logs/log-level-badge.tsx +++ b/ui/src/components/logs/log-level-badge.tsx @@ -1,23 +1,21 @@ import type { LogsLevel } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { getLevelLabel } from './utils'; - -const LEVEL_STYLES: Record = { - error: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300', - warn: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300', - info: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300', - debug: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300', -}; +import { LEVEL_TOKENS } from './tokens'; export function LogLevelBadge({ level, className }: { level: LogsLevel; className?: string }) { + const token = LEVEL_TOKENS[level]; return ( + ); diff --git a/ui/src/components/logs/logs-config-card.tsx b/ui/src/components/logs/logs-config-card.tsx index 22893bdb..4c955fdc 100644 --- a/ui/src/components/logs/logs-config-card.tsx +++ b/ui/src/components/logs/logs-config-card.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Save, Settings2, ShieldAlert, RotateCcw, Activity } from 'lucide-react'; +import { RotateCcw, Save } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -12,29 +12,25 @@ import { } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client'; -import { cn } from '@/lib/utils'; -// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready -function parseInteger(value: string, fallback: number) { +function parseInteger(value: string, fallback: number): number { const parsed = Number.parseInt(value, 10); - if (Number.isNaN(parsed)) { - return fallback; - } - + if (Number.isNaN(parsed)) return fallback; return Math.max(0, parsed); } -export function LogsConfigCard({ - config, - onSave, - isPending, -}: { +export interface LogsConfigCardProps { config: LogsConfig; onSave: (payload: UpdateLogsConfigPayload) => void; isPending: boolean; -}) { - // TODO i18n: uncomment when keys for Commit Changes, Rollback Draft, etc. are added - // const { t } = useTranslation(); +} + +/** + * Logging policy form. Calm chrome, designed to live inside a `Sheet`. + * Renamed semantically to "form" -- the export name remains `LogsConfigCard` + * for consumer compatibility. + */ +export function LogsConfigCard({ config, onSave, isPending }: LogsConfigCardProps) { const [draft, setDraft] = useState(config); useEffect(() => { @@ -44,244 +40,123 @@ export function LogsConfigCard({ const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]); return ( -
-
-
-
- -
+
{ + e.preventDefault(); + onSave(draft); + }} + > +
+
-

- Logging Policy -

-

- Retention and privacy + +

Capture structured log entries.

+
+ setDraft((c) => ({ ...c, enabled: checked }))} + /> +
+ +
+
+ +

+ Hide payload values until explicitly revealed.

+ setDraft((c) => ({ ...c, redact: checked }))} + />
-
-
-
-
- - Active Status - -
- - {config.enabled ? 'Live' : 'Off'} - -
-
-
- - Redaction - - - {config.redact ? 'Enforced' : 'Plain'} - -
-
+
+ + +
+
-
-
- -

- Enable structured logging -

-
- - setDraft((current) => ({ ...current, enabled: checked })) - } - className="data-[state=checked]:bg-primary" - /> -
- -
-
- -

- Sanitize payload data -

-
- - setDraft((current) => ({ ...current, redact: checked })) - } - className="data-[state=checked]:bg-primary" - /> -
+ + + setDraft((c) => ({ + ...c, + rotate_mb: parseInteger(e.target.value, c.rotate_mb), + })) + } + className="h-9" + />
- -
-
-
- - -
- -
- -
-
- - - setDraft((current) => ({ - ...current, - rotate_mb: parseInteger(event.target.value, current.rotate_mb), - })) - } - /> -
-
- - - setDraft((current) => ({ - ...current, - retain_days: parseInteger(event.target.value, current.retain_days), - })) - } - /> -
-
-
- -
- - +
+ + + setDraft((c) => ({ + ...c, + retain_days: parseInteger(e.target.value, c.retain_days), + })) + } + className="h-9" + />
-
-
- - - Operational Logic v3.4 - -
- {isDirty && ( -
-
- - Pending - -
- )} +
+ +
-
+ ); } diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx index 65839b89..9f977127 100644 --- a/ui/src/components/logs/logs-detail-panel.tsx +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -1,271 +1,269 @@ -import { - FileJson, - Info, - ShieldCheck, - Terminal, - Fingerprint, - Database, - Cpu, - Activity, - type LucideIcon, -} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { Copy, Eye, EyeOff, GitBranch } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; 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 { useTranslation } from 'react-i18next'; +import { FOCUS_RING, MONO_NUMERIC } from './tokens'; -function MetaRow({ - label, - value, - icon: Icon, -}: { +export interface LogsDetailPanelProps { + entry: LogsEntry | null; + sourceLabel?: string; + /** Optional: when provided, "Show trace" button surfaces & calls this. */ + onShowTrace?: (requestId: string) => void; + /** When true, redact metadata leaves until user reveals. */ + redact?: boolean; +} + +interface OverviewRow { label: string; - value: string | number; - icon?: LucideIcon; + value: string | number | null | undefined; + mono?: boolean; +} + +function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] { + 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: 'Run ID', value: entry.runId ?? '—', mono: true }, + { label: 'Process ID', value: entry.processId ?? '—', mono: true }, + ]; +} + +function MetaTree({ + value, + redact, + depth = 0, +}: { + value: unknown; + redact: boolean; + depth?: number; }) { + const [revealed, setRevealed] = useState(false); + if (depth > 8) return ; + + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + if (redact && typeof value === 'string' && value.length > 0 && !revealed) { + return ( + + redacted + + + ); + } + return ( + + + {String(value)} + + {redact && typeof value === 'string' && revealed ? ( + + ) : null} + + ); + } + if (Array.isArray(value)) { + return ( +
    + {value.slice(0, 50).map((v, i) => ( +
  • + [{i}]{' '} + +
  • + ))} + {value.length > 50 ? ( +
  • … +{value.length - 50} more
  • + ) : null} +
+ ); + } + // object + const obj = value as Record; + const keys = Object.keys(obj); return ( -
-
-
- {Icon && ( - - )} -

- {label} -

-
-
-
-

- {value} -

-
+
    + {keys.map((k) => ( +
  • + {k}:{' '} + +
  • + ))} +
); } +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + // ignore — copy is best effort + } +} + export function LogsDetailPanel({ entry, sourceLabel, -}: { - entry: LogsEntry | null; - sourceLabel?: string; -}) { - const { t } = useTranslation(); - + onShowTrace, + redact = false, +}: LogsDetailPanelProps) { + const overviewRows = useMemo( + () => (entry ? buildOverviewRows(entry, sourceLabel) : []), + [entry, sourceLabel] + ); if (!entry) { - return ( -
-
-
-
- -
-
-
-

- {/* TODO i18n: missing key for "Inspector Standby" */} - Inspector Standby -

-

- Select a telemetry node from the active data queue to perform deep analysis of its - operational context. -

-
-
- ); + return ; } + const rawJson = formatJson(entry); + return ( -
- {/* Tactical Inspector Header */} -
- {/* Pattern Overlay */} -
- -
-
-
- -
-
- - - {sourceLabel ?? entry.source} - -
-
-
- - - {new Date(entry.timestamp).toLocaleTimeString(undefined, { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - })} - -
-
- -
-
-
-

- Event -

-
-

- {entry.event} -

-
-

- {entry.message} -

-
-
+
+
+
+ + {entry.event}
-
- - -
- - - - - {t('logsDetailPanel.details')} - - - - {/* TODO i18n: missing key for "Raw Context" */} - Raw Context - - - - {entry.message}

+
+ + {entry.requestId ? ( + + ) : null} + {entry.requestId && onShowTrace ? ( + + ) : null} +
+ -
- {/* Background Scanline */} -
+ + + + Overview + + + Context + + + Raw + + -
-
-
- -
-
-

- Automated Summary -

-

- Quick interpretation -

-
-
-

- This telemetry node was captured from{' '} - - {sourceLabel ?? entry.source} - - operating at the{' '} - - {entry.level} - {' '} - threshold. The operational payload indicates an event state of{' '} - {entry.event}. + + +

+ {overviewRows.map((row) => ( + + ))} + {entry.error ? ( +
+

+ {entry.error.code ?? 'Error'}: {entry.error.message}

+ {entry.error.stack ? ( +
+                      {entry.error.stack}
+                    
+ ) : null}
-
- + ) : null} + + + - + +
+ {entry.metadata && Object.keys(entry.metadata).length > 0 ? ( + + ) : ( +

No structured metadata.

+ )} +
+
+
+ + + +
-              
- {/* Copy HUD */} -
-
- JSON.RAW.MODE -
-
- - -
-                    {formatJson({
-                      id: entry.id,
-                      timestamp: entry.timestamp,
-                      level: entry.level,
-                      source: entry.source,
-                      event: entry.event,
-                      message: entry.message,
-                      processId: entry.processId,
-                      runId: entry.runId,
-                      context: entry.context ?? {},
-                    })}
-                  
-
-
- - -
- - -
-
-
-
- - Node Verified - -
-
- - {entry.id.slice(0, 8)} - -
-
- - CCS-TEC-v3 - -
-
+ {rawJson} + + + +
); } + +function DetailRow({ label, value, mono }: OverviewRow) { + return ( + <> +
{label}
+
+ {value ?? '—'} +
+ + ); +} diff --git a/ui/src/components/logs/logs-empty.tsx b/ui/src/components/logs/logs-empty.tsx new file mode 100644 index 00000000..864a8405 --- /dev/null +++ b/ui/src/components/logs/logs-empty.tsx @@ -0,0 +1,55 @@ +import { Inbox, FilterX, MousePointer, EyeOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export type LogsEmptyVariant = 'selection' | 'noData' | 'noResults' | 'selectionOutOfScope'; + +export interface LogsEmptyProps { + variant: LogsEmptyVariant; + onClearFilters?: () => void; +} + +const COPY: Record = { + selection: { + title: 'No entry selected', + body: 'Pick a row in the list to inspect its context. Use j/k to move and Enter to focus the detail.', + icon: MousePointer, + }, + noData: { + title: 'No log activity yet', + body: 'Once requests flow through the system, structured entries will appear here.', + icon: Inbox, + }, + noResults: { + title: 'No entries match your filters', + body: 'Adjust source, level, search, or time window to see more entries.', + icon: FilterX, + }, + selectionOutOfScope: { + title: 'Selected entry not visible under current filter', + body: 'Clear your filter or select another row to inspect details.', + icon: EyeOff, + }, +}; + +export function LogsEmpty({ variant, onClearFilters }: LogsEmptyProps) { + const { title, body, icon: Icon } = COPY[variant]; + const showClear = variant === 'selectionOutOfScope' || variant === 'noResults'; + + return ( +
+
+
+

{title}

+

{body}

+ {showClear && onClearFilters ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx index d54ce5e9..b03687e8 100644 --- a/ui/src/components/logs/logs-entry-list.tsx +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -1,9 +1,31 @@ -import { Activity, ArrowRight, Inbox, Loader2 } from 'lucide-react'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; +import { Virtuoso } from 'react-virtuoso'; import type { LogsEntry } from '@/lib/api-client'; import { cn } from '@/lib/utils'; -import { LogLevelBadge } from './log-level-badge'; -import { useTranslation } from 'react-i18next'; +import { LogsRow } from './logs-row'; +import { LogsTraceRow } from './logs-trace-row'; +import { type RowDensity } from './tokens'; +import { LogsEmpty } from './logs-empty'; +import { deriveTraceGroups, type DerivedItem } from './derive-trace-groups'; + +export interface LogsEntryListProps { + entries: LogsEntry[]; + selectedEntryId: string | null; + onSelect: (entryId: string) => void; + sourceLabels: Record; + isLoading: boolean; + isFetching: boolean; + /** + * Slot for live-tail controls (pause/resume + pending pill). + * Phase-04 owns the controls; phase-03 just provides the slot. + */ + liveTailSlot?: ReactNode; + /** Density toggle (phase-04 may surface a switch). Defaults to cozy. */ + density?: RowDensity; +} + +const COLS_TEMPLATE = + 'grid grid-cols-[88px_64px_140px_minmax(0,1fr)_72px_88px] items-center gap-3 px-3'; export function LogsEntryList({ entries, @@ -11,218 +33,116 @@ export function LogsEntryList({ onSelect, sourceLabels, isLoading, - isFetching, -}: { - entries: LogsEntry[]; - selectedEntryId: string | null; - onSelect: (entryId: string) => void; - sourceLabels: Record; - isLoading: boolean; - isFetching: boolean; -}) { - const { t } = useTranslation(); + isFetching: _isFetching, + liveTailSlot, + density = 'cozy', +}: LogsEntryListProps) { + const items = useMemo(() => deriveTraceGroups(entries), [entries]); + const [expanded, setExpanded] = useState>(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; + 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 expanded; + }, [expanded, items, selectedEntryId]); + + const renderItem = useCallback( + (_index: number, item: DerivedItem) => { + if (item.kind === 'trace') { + return ( + + ); + } + return ( + + ); + }, + [density, effectiveExpanded, onSelect, selectedEntryId, sourceLabels, toggle] + ); return ( -
-
-
-
-
-

- Live Entry Stream -

-
-
-
- - - Live telemetry - -
-
-
- {isFetching && ( -
- - - Syncing - -
- )} - - NODE.01 - -
+
+ {/* Sticky column header outside Virtuoso scroll body */} +
+ Time + Level + Module + Message + + Latency + + + Request +
-
-
{t('logsConfig.time')}
-
- {t('logsConfig.level')} + {liveTailSlot ? ( +
+ {liveTailSlot}
-
- {t('logsConfig.source')} -
-
{t('logsConfig.message')}
-
- {t('logsConfig.proc')} -
-
- {t('logsConfig.run')} -
-
- {t('logsConfig.open')} -
-
+ ) : null} -
+
{isLoading ? (
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((item) => ( + {Array.from({ length: 14 }).map((_, idx) => (
))}
- ) : entries.length === 0 ? ( -
-
-
-
- -
-
-
-

- No matching entries -

-

- Your current source, level, or search filters are hiding the stream. Adjust them to - bring entries back into view. -

-
-
+ ) : items.length === 0 ? ( + ) : ( - -
- {entries.map((entry) => { - const isSelected = entry.id === selectedEntryId; - - return ( - - ); - })} -
-
+ (atBottom ? 'auto' : false)} + computeItemKey={(_idx, item) => + item.kind === 'trace' ? `t:${item.requestId}` : `l:${item.entry.id}` + } + /> )}
- -
-
- - Node: CCS-CORE - -
- - Status: Operational - -
-
- - Entries: {entries.length} - -
-
); } diff --git a/ui/src/components/logs/logs-error.tsx b/ui/src/components/logs/logs-error.tsx new file mode 100644 index 00000000..f96d1f3a --- /dev/null +++ b/ui/src/components/logs/logs-error.tsx @@ -0,0 +1,27 @@ +import { AlertTriangle, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export interface LogsErrorProps { + error: Error | { message?: string } | null; + onRetry: () => void; +} + +export function LogsError({ error, onRetry }: LogsErrorProps) { + const message = error?.message ?? 'Unknown error fetching logs.'; + return ( +
+
+
+

Could not load logs

+

{message}

+ +
+ ); +} diff --git a/ui/src/components/logs/logs-filters.tsx b/ui/src/components/logs/logs-filters.tsx index d3b73b3d..f1c4436c 100644 --- a/ui/src/components/logs/logs-filters.tsx +++ b/ui/src/components/logs/logs-filters.tsx @@ -1,26 +1,28 @@ -import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react'; +import { useState } from 'react'; +import { ChevronDown, Search, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import type { LogsSource } from '@/lib/api-client'; import { cn } from '@/lib/utils'; -import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs'; -import { getLogLevelOptions } from '@/hooks/use-logs'; -import { useTranslation } from 'react-i18next'; +import { + getLogLevelOptions, + getLogsTimeWindowOptions, + type LogsLevelFilter, + type LogsSourceFilter, + type LogsTimeWindow, +} from '@/hooks/use-logs'; +import { FOCUS_RING } from './tokens'; -export function LogsFilters({ - sources, - selectedSource, - onSourceChange, - selectedLevel, - onLevelChange, - search, - onSearchChange, - limit, - onLimitChange, - onRefresh, - isRefreshing, -}: { +export interface LogsFiltersProps { sources: LogsSource[]; selectedSource: LogsSourceFilter; onSourceChange: (value: LogsSourceFilter) => void; @@ -32,169 +34,325 @@ export function LogsFilters({ onLimitChange: (value: number) => void; onRefresh: () => void; isRefreshing: boolean; -}) { - const { t } = useTranslation(); + + /** Phase-04 advanced filters (optional for back-compat). */ + moduleFilter?: string; + onModuleChange?: (v: string) => void; + stageFilter?: string; + onStageChange?: (v: string) => void; + requestIdFilter?: string; + onRequestIdChange?: (v: string) => void; + timeWindow?: LogsTimeWindow; + onTimeWindowChange?: (v: LogsTimeWindow) => void; + onClearAll?: () => void; +} + +interface ChipProps { + label: string; + onRemove: () => void; +} + +function Chip({ label, onRemove }: ChipProps) { + return ( + + {label} + + + ); +} + +export function LogsFilters({ + sources, + selectedSource, + onSourceChange, + selectedLevel, + onLevelChange, + search, + onSearchChange, + limit, + onLimitChange, + onRefresh: _onRefresh, + isRefreshing: _isRefreshing, + moduleFilter = '', + onModuleChange, + stageFilter = '', + onStageChange, + requestIdFilter = '', + onRequestIdChange, + timeWindow = 'all', + onTimeWindowChange, + onClearAll, +}: LogsFiltersProps) { + const [advancedOpen, setAdvancedOpen] = useState(false); const levels = getLogLevelOptions(); const limits = [50, 100, 150, 250]; + const timeWindows = getLogsTimeWindowOptions(); + + const activeChips: Array<{ key: string; label: string; clear: () => void }> = []; + if (selectedLevel !== 'all') { + activeChips.push({ + key: 'level', + label: `level: ${selectedLevel}`, + clear: () => onLevelChange('all'), + }); + } + if (selectedSource !== 'all') { + const label = sources.find((s) => s.source === selectedSource)?.label ?? selectedSource; + activeChips.push({ + key: 'source', + label: `source: ${label}`, + clear: () => onSourceChange('all'), + }); + } + if (search.trim()) { + activeChips.push({ + key: 'search', + label: `search: ${search}`, + clear: () => onSearchChange(''), + }); + } + if (moduleFilter) { + activeChips.push({ + key: 'module', + label: `module: ${moduleFilter}`, + clear: () => onModuleChange?.(''), + }); + } + if (stageFilter) { + activeChips.push({ + key: 'stage', + label: `stage: ${stageFilter}`, + clear: () => onStageChange?.(''), + }); + } + if (requestIdFilter) { + activeChips.push({ + key: 'requestId', + label: `requestId: ${requestIdFilter}`, + clear: () => onRequestIdChange?.(''), + }); + } + if (timeWindow !== 'all') { + activeChips.push({ + key: 'timeWindow', + label: `time: ${timeWindow}`, + clear: () => onTimeWindowChange?.('all'), + }); + } return ( -
-
-
-
-
- -
- -
-
-
- -
+
+ {/* Primary row */} +
+ +
+
-
-
- - -
-
- - -
- {sources.map((source) => ( - - ))} -
+ Level + + +
+
+ +
- {/* Threshold Control */} -
-
- - -
-
- {levels.map((option) => ( - + + + {onModuleChange ? ( +
+ + onModuleChange(e.target.value)} + placeholder="e.g. cliproxy.router" + className="h-9" /> - +
+ ) : null} + {onStageChange ? ( +
+ + onStageChange(e.target.value)} + placeholder="e.g. handler" + className="h-9" + /> +
+ ) : null} + {onRequestIdChange ? ( +
+ + onRequestIdChange(e.target.value)} + placeholder="req_…" + className="h-9 font-mono" + /> +
+ ) : null} + {onTimeWindowChange ? ( +
+ + +
+ ) : null} +
+ + +
+ +
+ {limits.map((option) => ( + ))}
- {/* Operational Deck */} -
-
-
-
-

- Operational Window -

-

- Tail Capacity -

-
-
- -
-
- -
- {limits.map((option) => ( - - ))} -
- - + {activeChips.length > 0 ? ( +
+ Active: + {activeChips.map((c) => ( + + ))} + {onClearAll ? ( + + ) : null}
-
+ ) : null}
); } diff --git a/ui/src/components/logs/logs-header.tsx b/ui/src/components/logs/logs-header.tsx new file mode 100644 index 00000000..4eee6979 --- /dev/null +++ b/ui/src/components/logs/logs-header.tsx @@ -0,0 +1,90 @@ +import { RefreshCw, Settings, ScrollText, HelpCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { FOCUS_RING } from './tokens'; + +export interface LogsHeaderProps { + isFetching: boolean; + hasError: boolean; + capturedCount: number; + 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 + * + * No glows, no decorative captions, no scale animations. + */ +export function LogsHeader({ + isFetching, + hasError, + capturedCount, + onRefresh, + onOpenSettings, + onOpenShortcuts, +}: LogsHeaderProps) { + const status = hasError ? 'error' : isFetching ? 'syncing' : 'live'; + const statusLabel = + status === 'error' ? 'Disconnected' : status === 'syncing' ? 'Syncing' : 'Live'; + const dotClass = + status === 'error' ? 'bg-red-500' : status === 'syncing' ? 'bg-amber-500' : 'bg-emerald-500'; + + return ( +
+
+
+ +
+ + + +
+
+ ); +} diff --git a/ui/src/components/logs/logs-overview-cards.tsx b/ui/src/components/logs/logs-overview-cards.tsx deleted file mode 100644 index 8cdea69b..00000000 --- a/ui/src/components/logs/logs-overview-cards.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Activity, Archive, Database, RadioTower } from 'lucide-react'; -import type { LogsConfig, LogsEntry, LogsSource } from '@/lib/api-client'; -import { cn } from '@/lib/utils'; -import { formatCount, formatLogTimestamp, formatRelativeLogTime } from './utils'; -// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready - -function MetricCard({ - label, - value, - detail, - icon: Icon, - accent, -}: { - label: string; - value: string; - detail: string; - icon: typeof Activity; - accent: string; -}) { - return ( -
-
-
-

{label}

-

{value}

-

{detail}

-
-
- -
-
-
- ); -} - -export function LogsOverviewCards({ - config, - sources, - entries, - latestTimestamp, -}: { - config: LogsConfig; - sources: LogsSource[]; - entries: LogsEntry[]; - latestTimestamp: string | null; -}) { - // TODO i18n: uncomment when keys for Pipeline/Retention/Coverage/Visible Entries are added - // const { t } = useTranslation(); - const nativeSources = sources.filter((source) => source.kind === 'native').length; - const legacySources = sources.length - nativeSources; - const errorCount = entries.filter((entry) => entry.level === 'error').length; - - return ( -
- {/* TODO i18n: missing keys for Pipeline/Retention/Coverage/Visible Entries labels and detail strings */} - - - 0 ? ` • ${legacySources} legacy` : ''}`} - icon={Database} - accent="bg-sky-500/10 text-sky-700 dark:text-sky-300" - /> - -
- Last ingested event:{' '} - {formatLogTimestamp(latestTimestamp)} -
-
- ); -} diff --git a/ui/src/components/logs/logs-page-skeleton.tsx b/ui/src/components/logs/logs-page-skeleton.tsx index 1e8fcf47..ae2df11f 100644 --- a/ui/src/components/logs/logs-page-skeleton.tsx +++ b/ui/src/components/logs/logs-page-skeleton.tsx @@ -1,58 +1,45 @@ -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; import { useTranslation } from 'react-i18next'; +import { Skeleton } from '@/components/ui/skeleton'; +/** + * Logs surface initial-load skeleton. + * Shape mirrors the new shell: 48px header, 40px tab bar, 3-pane body. + * `aria-busy` lives here; phase-06 layers full a11y polish. + */ export function LogsPageSkeleton() { const { t } = useTranslation(); - return ( -
- - - - - - - - -
- {[1, 2, 3, 4].map((item) => ( - - - - - - - ))} +
+
+ +
- -
- - - -
- {[1, 2, 3, 4].map((item) => ( - - ))} -
-
- - - - -
- - - - - - - - {[1, 2, 3, 4].map((item) => ( - - ))} - - +
+ +
+
+
+ + + + +
+
+ {Array.from({ length: 12 }).map((_, idx) => ( + + ))} +
+
+ + + + +
); diff --git a/ui/src/components/logs/logs-row.tsx b/ui/src/components/logs/logs-row.tsx new file mode 100644 index 00000000..8889fe43 --- /dev/null +++ b/ui/src/components/logs/logs-row.tsx @@ -0,0 +1,112 @@ +import { memo } from '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'; + +export interface LogsRowProps { + entry: LogsEntry; + isSelected: boolean; + density: RowDensity; + sourceLabel: string; + onSelect: (entryId: string) => void; + /** Optional indent (px) when row sits inside an expanded trace. */ + indent?: number; + /** Optional stage chip text (e.g. for child rows of a trace). */ + stageHint?: string; +} + +function formatHms(timestamp: string): string { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString(undefined, { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function LogsRowImpl({ + entry, + isSelected, + density, + sourceLabel, + onSelect, + indent = 0, + stageHint, +}: LogsRowProps) { + return ( + + ); +} + +export const LogsRow = memo(LogsRowImpl, (prev, next) => { + return ( + prev.entry.id === next.entry.id && + prev.isSelected === next.isSelected && + prev.density === next.density && + prev.indent === next.indent && + prev.stageHint === next.stageHint && + prev.sourceLabel === next.sourceLabel + ); +}); diff --git a/ui/src/components/logs/logs-shell.tsx b/ui/src/components/logs/logs-shell.tsx new file mode 100644 index 00000000..2e985696 --- /dev/null +++ b/ui/src/components/logs/logs-shell.tsx @@ -0,0 +1,343 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; +import { LogsConfigCard } from '@/components/logs/logs-config-card'; +import { LogsDetailPanel } from '@/components/logs/logs-detail-panel'; +import { LogsEntryList } from '@/components/logs/logs-entry-list'; +import { LogsEmpty } from '@/components/logs/logs-empty'; +import { LogsError } from '@/components/logs/logs-error'; +import { LogsFilters } from '@/components/logs/logs-filters'; +import { LogsHeader } from '@/components/logs/logs-header'; +import { LiveTailControls } from '@/components/logs/live-tail-controls'; +import { LogsShortcutsDialog } from '@/components/logs/logs-shortcuts-dialog'; +import { useLogsKeyboardNav } from '@/components/logs/use-logs-keyboard-nav'; +import { + getSourceLabelMap, + type useLogsWorkspace, + type useUpdateLogsConfig, +} from '@/hooks/use-logs'; + +const LAYOUT_KEY = 'ccs.logs.layout.v1'; +const DESKTOP_BREAKPOINT = 1200; + +type Workspace = ReturnType; +type UpdateConfig = ReturnType; + +interface LayoutSizes { + filters: number; + list: number; + detail: number; +} + +const DEFAULT_SIZES: LayoutSizes = { filters: 22, list: 52, detail: 26 }; + +function readSavedSizes(): LayoutSizes { + if (typeof window === 'undefined') return DEFAULT_SIZES; + try { + const raw = window.localStorage.getItem(LAYOUT_KEY); + if (!raw) return DEFAULT_SIZES; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.filters === 'number' && + typeof parsed.list === 'number' && + typeof parsed.detail === 'number' + ) { + return { filters: parsed.filters, list: parsed.list, detail: parsed.detail }; + } + } catch { + // ignore + } + return DEFAULT_SIZES; +} + +function useDesktopLayout(): boolean { + const [isDesktop, setIsDesktop] = useState(() => + typeof window === 'undefined' ? true : window.innerWidth >= DESKTOP_BREAKPOINT + ); + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT}px)`); + const sync = () => setIsDesktop(window.innerWidth >= DESKTOP_BREAKPOINT); + sync(); + mq.addEventListener('change', sync); + return () => mq.removeEventListener('change', sync); + }, []); + return isDesktop; +} + +type SheetKey = 'settings' | 'detail' | null; + +export interface LogsShellProps { + workspace: Workspace; + updateConfig: UpdateConfig; +} + +export function LogsShell({ workspace, updateConfig }: LogsShellProps) { + const isDesktop = useDesktopLayout(); + const sourceLabels = useMemo( + () => getSourceLabelMap(workspace.sourcesQuery.data ?? []), + [workspace.sourcesQuery.data] + ); + const [activeSheet, setActiveSheet] = useState(null); + const [sizes, setSizes] = useState(readSavedSizes); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const searchInputRef = useRef(null); + + const config = workspace.configQuery.data; + + const handleRefresh = useCallback(() => { + void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()]); + }, [workspace.entriesQuery, workspace.sourcesQuery]); + + const onLayout = useCallback((next: number[]) => { + if (next.length !== 3) return; + const [filters, list, detail] = next as [number, number, number]; + const updated = { filters, list, detail }; + setSizes(updated); + try { + window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(updated)); + } catch { + // ignore + } + }, []); + + const openSettings = useCallback(() => setActiveSheet('settings'), []); + const openDetailSheet = useCallback(() => setActiveSheet('detail'), []); + const closeSheet = useCallback(() => setActiveSheet(null), []); + + // On mobile, selecting an entry opens the detail bottom-sheet. + // We wrap `setSelectedEntryId` so it stays an event-driven side effect. + const baseSetSelectedEntryId = workspace.setSelectedEntryId; + const handleSelectEntry = useCallback( + (id: string | null) => { + baseSetSelectedEntryId(id); + if (!isDesktop && id) setActiveSheet('detail'); + }, + [baseSetSelectedEntryId, isDesktop] + ); + + const handleShowTrace = useCallback( + (requestId: string) => { + workspace.setRequestIdFilter(requestId); + workspace.setSearch(''); + }, + [workspace] + ); + + const entryIds = useMemo( + () => (workspace.entriesQuery.data ?? []).map((e) => e.id), + [workspace.entriesQuery.data] + ); + + const focusSearch = useCallback(() => { + const input = document.getElementById('logs-search') as HTMLInputElement | null; + if (input) { + input.focus(); + input.select(); + } else if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, []); + + useLogsKeyboardNav({ + entryIds, + selectedId: workspace.selectedEntryId, + onSelect: workspace.setSelectedEntryId, + onTogglePause: workspace.liveTail.togglePause, + onFocusSearch: focusSearch, + onOpenShortcuts: () => setShortcutsOpen(true), + onCloseDetail: !isDesktop && activeSheet === 'detail' ? closeSheet : undefined, + }); + + if (!config) return null; + + const filtersNode = ( + + ); + + const liveTailSlot = ( + + ); + + const listNode = workspace.entriesQuery.error ? ( + void workspace.entriesQuery.refetch()} + /> + ) : ( + + ); + + const detailNode = workspace.isSelectionOutOfScope ? ( + + ) : ( + + ); + + return ( +
+ setShortcutsOpen(true)} + /> + + +
+ + + Stream + + + Upstream errors + + +
+ + + {isDesktop ? ( + + +
+ {filtersNode} +
+
+ + +
+ {listNode} +
+
+ + +
+ {detailNode} +
+
+
+ ) : ( +
+
{filtersNode}
+
{listNode}
+
+ )} +
+ + +
+
+

CLIProxy upstream errors

+

+ Historical failure stream from the upstream proxy. Distinct from the structured + stream. +

+
+ +
+
+
+ + {/* Logging settings sheet (right). Phase-05 may replace body with extracted form. */} + (open ? openSettings() : closeSheet())} + > + + + Logging settings + + Configure retention, redaction, and sampling for the logs surface. + + +
+ updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
+
+
+ + + + {/* Mobile detail sheet (bottom). Desktop renders detail in panel directly. */} + {!isDesktop ? ( + (open ? openDetailSheet() : closeSheet())} + > + + + Entry detail + Selected log entry context and raw payload. + +
{detailNode}
+
+
+ ) : null} +
+ ); +} diff --git a/ui/src/components/logs/logs-shortcuts-dialog.tsx b/ui/src/components/logs/logs-shortcuts-dialog.tsx new file mode 100644 index 00000000..eead14f9 --- /dev/null +++ b/ui/src/components/logs/logs-shortcuts-dialog.tsx @@ -0,0 +1,55 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +export interface LogsShortcutsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const SHORTCUTS: Array<{ keys: string[]; label: string }> = [ + { keys: ['j', '↓'], label: 'Next entry' }, + { keys: ['k', '↑'], label: 'Previous entry' }, + { keys: ['Enter'], label: 'Focus detail' }, + { keys: ['Esc'], label: 'Close detail (mobile)' }, + { keys: ['/'], label: 'Focus search' }, + { keys: ['Space'], label: 'Pause / resume tail' }, + { keys: ['?'], label: 'Show this help' }, +]; + +function Kbd({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function LogsShortcutsDialog({ open, onOpenChange }: LogsShortcutsDialogProps) { + return ( + + + + Keyboard shortcuts + Logs surface keybindings. + +
    + {SHORTCUTS.map((s) => ( +
  • + {s.label} + + {s.keys.map((k) => ( + {k} + ))} + +
  • + ))} +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-trace-row.tsx b/ui/src/components/logs/logs-trace-row.tsx new file mode 100644 index 00000000..a0efee60 --- /dev/null +++ b/ui/src/components/logs/logs-trace-row.tsx @@ -0,0 +1,125 @@ +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 { LogLevelBadge } from './log-level-badge'; +import { LogsRow } from './logs-row'; +import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens'; + +export interface TraceGroup { + kind: 'trace'; + requestId: string; + module: string; + source: string; + ts: string; // min child ts + maxLevel: LogsLevel; + totalLatencyMs: number; + children: LogsEntry[]; +} + +export interface LogsTraceRowProps { + group: TraceGroup; + isExpanded: boolean; + selectedEntryId: string | null; + density: RowDensity; + sourceLabel: string; + onToggle: (requestId: string) => void; + onSelect: (entryId: string) => void; +} + +function formatHms(timestamp: string): string { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString(undefined, { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function LogsTraceRowImpl({ + group, + isExpanded, + selectedEntryId, + density, + sourceLabel, + onToggle, + onSelect, +}: LogsTraceRowProps) { + const Chevron = isExpanded ? ChevronDown : ChevronRight; + return ( +
+ + {isExpanded + ? group.children.map((child) => ( + + )) + : null} +
+ ); +} + +export const LogsTraceRow = memo(LogsTraceRowImpl); diff --git a/ui/src/components/logs/tokens.ts b/ui/src/components/logs/tokens.ts new file mode 100644 index 00000000..6efc1a96 --- /dev/null +++ b/ui/src/components/logs/tokens.ts @@ -0,0 +1,67 @@ +import type { LogsLevel } from '@/lib/api-client'; + +/** + * Calm-density design tokens for the logs surface. + * + * Strings only -- no runtime cost. Components import these instead of + * duplicating Tailwind utilities. Color values use neutral red/amber/blue/zinc + * scales tuned for AA contrast on `bg-background` in both light and dark + * themes. + */ + +export interface LevelToken { + bg: string; + fg: string; + border: string; + dot: string; +} + +export const LEVEL_TOKENS: Record = { + error: { + bg: 'bg-red-500/10', + fg: 'text-red-700 dark:text-red-300', + border: 'border-red-500/30', + dot: 'bg-red-500', + }, + warn: { + bg: 'bg-amber-500/10', + fg: 'text-amber-800 dark:text-amber-200', + border: 'border-amber-500/30', + dot: 'bg-amber-500', + }, + info: { + bg: 'bg-sky-500/10', + fg: 'text-sky-800 dark:text-sky-200', + border: 'border-sky-500/30', + dot: 'bg-sky-500', + }, + debug: { + bg: 'bg-zinc-500/10', + fg: 'text-zinc-700 dark:text-zinc-300', + border: 'border-zinc-500/30', + dot: 'bg-zinc-500', + }, +}; + +export const ROW_DENSITY = { + compact: 'h-8 text-[12px]', + cozy: 'h-10 text-[13px]', +} as const; + +export type RowDensity = keyof typeof ROW_DENSITY; + +export const PANEL_CHROME = 'border-border bg-background'; + +export const PANEL_CHROME_RIGHT = 'border-r border-border bg-background'; +export const PANEL_CHROME_LEFT = 'border-l border-border bg-background'; + +/** Mono numerics for timestamp / latency columns. */ +export const MONO_NUMERIC = 'font-mono tabular-nums'; + +/** Subtle row hover/selected feedback. */ +export const ROW_INTERACTIVE = + 'transition-colors hover:bg-muted/40 data-[selected=true]:bg-muted/60'; + +/** Calm focus ring used across logs interactive elements. */ +export const FOCUS_RING = + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background'; diff --git a/ui/src/components/logs/use-logs-keyboard-nav.ts b/ui/src/components/logs/use-logs-keyboard-nav.ts new file mode 100644 index 00000000..1c94f0db --- /dev/null +++ b/ui/src/components/logs/use-logs-keyboard-nav.ts @@ -0,0 +1,97 @@ +import { useEffect } from 'react'; + +export interface UseLogsKeyboardNavOptions { + /** Currently visible entry ids in display order (post-filter, post-grouping flatten). */ + entryIds: string[]; + selectedId: string | null; + onSelect: (id: string) => void; + onTogglePause: () => void; + onFocusSearch: () => void; + onOpenShortcuts: () => void; + /** Detail-sheet close (mobile only). Optional. */ + onCloseDetail?: () => void; + enabled?: boolean; +} + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return target.isContentEditable; +} + +/** + * Centralised keyboard navigation for the logs surface. + * Bound on `window`. Bail out when typing in form fields or the user has + * any modifier (cmd/ctrl/alt) other than shift on `?`. + */ +export function useLogsKeyboardNav({ + entryIds, + selectedId, + onSelect, + onTogglePause, + onFocusSearch, + onOpenShortcuts, + onCloseDetail, + enabled = true, +}: UseLogsKeyboardNavOptions): void { + useEffect(() => { + if (!enabled) return; + const handler = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + + // `/` always focuses search, even from inputs (consistent with most apps) + if (event.key === '/') { + event.preventDefault(); + onFocusSearch(); + return; + } + if (event.key === '?') { + event.preventDefault(); + onOpenShortcuts(); + return; + } + if (isTypingTarget(event.target)) return; + + if (event.key === 'Escape' && onCloseDetail) { + onCloseDetail(); + return; + } + + if (event.key === ' ') { + event.preventDefault(); + onTogglePause(); + return; + } + + if (event.key === 'j' || event.key === 'ArrowDown') { + event.preventDefault(); + if (entryIds.length === 0) return; + const idx = selectedId ? entryIds.indexOf(selectedId) : -1; + const next = entryIds[Math.min(idx + 1, entryIds.length - 1)] ?? entryIds[0]; + if (next) onSelect(next); + return; + } + + if (event.key === 'k' || event.key === 'ArrowUp') { + event.preventDefault(); + if (entryIds.length === 0) return; + const idx = selectedId ? entryIds.indexOf(selectedId) : 0; + const next = entryIds[Math.max(idx - 1, 0)] ?? entryIds[0]; + if (next) onSelect(next); + return; + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [ + enabled, + entryIds, + selectedId, + onSelect, + onTogglePause, + onFocusSearch, + onOpenShortcuts, + onCloseDetail, + ]); +} diff --git a/ui/src/hooks/use-logs.ts b/ui/src/hooks/use-logs.ts index 356ebb3a..254b9285 100644 --- a/ui/src/hooks/use-logs.ts +++ b/ui/src/hooks/use-logs.ts @@ -1,5 +1,5 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useDeferredValue, useMemo, useState } from 'react'; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { @@ -9,20 +9,120 @@ import { type UpdateLogsConfigPayload, } from '@/lib/api-client'; +// ---- dev-only fixture injection (`?mock=logs`) ----------------------------- +// Tree-shaken from production: the fixture import lives behind +// `import.meta.env.DEV` and the URL flag check. +const KNOWN_LOGS_ENTRY_KEYS = new Set([ + 'id', + 'timestamp', + 'level', + 'source', + 'event', + 'message', + 'processId', + 'runId', + 'context', + 'requestId', + 'module', + 'stage', + 'latencyMs', + 'metadata', + 'error', +]); + +let _shapeWarned = false; +function assertLogsEntryShape(entry: LogsEntry): void { + if (!import.meta.env.DEV || _shapeWarned) return; + const missing: string[] = []; + if (!entry.id) missing.push('id'); + if (!entry.timestamp) missing.push('timestamp'); + if (!entry.level) missing.push('level'); + const unknown = Object.keys(entry).filter((k) => !KNOWN_LOGS_ENTRY_KEYS.has(k)); + if (missing.length || unknown.length) { + _shapeWarned = true; + + console.warn('[logs] LogsEntry shape drift detected -- backend contract changed?', { + missing, + unknown, + }); + } +} + +function isMockLogsEnabled(): boolean { + if (!import.meta.env.DEV) return false; + if (typeof window === 'undefined') return false; + try { + return new URLSearchParams(window.location.search).get('mock') === 'logs'; + } catch { + return false; + } +} + export type LogsLevelFilter = 'all' | LogsLevel; export type LogsSourceFilter = 'all' | string; +export type LogsTimeWindow = 'all' | '5m' | '15m' | '1h' | '24h'; const CONFIG_QUERY_KEY = ['logs', 'config'] as const; const SOURCES_QUERY_KEY = ['logs', 'sources'] as const; const DEFAULT_LIMIT = 150; +const POLL_INTERVAL_MS = 10_000; +const TEXT_DEBOUNCE_MS = 250; + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(t); + }, [value, delayMs]); + return debounced; +} + +function useDocumentVisible(): boolean { + const [visible, setVisible] = useState(() => + typeof document === 'undefined' ? true : document.visibilityState !== 'hidden' + ); + useEffect(() => { + if (typeof document === 'undefined') return; + const sync = () => setVisible(document.visibilityState !== 'hidden'); + document.addEventListener('visibilitychange', sync); + return () => document.removeEventListener('visibilitychange', sync); + }, []); + return visible; +} + +function windowToCutoffMs(window: LogsTimeWindow): number | null { + switch (window) { + case '5m': + return 5 * 60_000; + case '15m': + return 15 * 60_000; + case '1h': + return 60 * 60_000; + case '24h': + return 24 * 60 * 60_000; + default: + return null; + } +} export function useLogsWorkspace() { const [selectedSource, setSelectedSource] = useState('all'); const [selectedLevel, setSelectedLevel] = useState('all'); const [search, setSearch] = useState(''); + const [moduleFilter, setModuleFilter] = useState(''); + const [stageFilter, setStageFilter] = useState(''); + const [requestIdFilter, setRequestIdFilter] = useState(''); + const [timeWindow, setTimeWindow] = useState('all'); const [limit, setLimit] = useState(DEFAULT_LIMIT); const [selectedEntryId, setSelectedEntryId] = useState(null); + const [isPaused, setIsPaused] = useState(false); + const frozenIdsRef = useRef>(new Set()); + const deferredSearch = useDeferredValue(search.trim()); + const debouncedModule = useDebouncedValue(moduleFilter.trim(), TEXT_DEBOUNCE_MS); + const debouncedStage = useDebouncedValue(stageFilter.trim(), TEXT_DEBOUNCE_MS); + const debouncedRequestId = useDebouncedValue(requestIdFilter.trim(), TEXT_DEBOUNCE_MS); + const documentVisible = useDocumentVisible(); const configQuery = useQuery({ queryKey: CONFIG_QUERY_KEY, @@ -36,37 +136,118 @@ export function useLogsWorkspace() { refetchInterval: 15_000, }); + const mockEnabled = isMockLogsEnabled(); + const refetchInterval = isPaused || !documentVisible ? false : POLL_INTERVAL_MS; + const entriesQuery = useQuery({ - queryKey: ['logs', 'entries', selectedSource, selectedLevel, deferredSearch, limit], - queryFn: async () => - ( - await api.logs.getEntries({ + queryKey: [ + 'logs', + 'entries', + selectedSource, + selectedLevel, + deferredSearch, + debouncedModule, + debouncedStage, + debouncedRequestId, + timeWindow, + limit, + mockEnabled ? 'mock' : 'live', + ], + queryFn: async () => { + let entries: LogsEntry[]; + if (mockEnabled && import.meta.env.DEV) { + const { STRUCTURED_LOG_ENTRIES } = + await import('@/components/logs/__fixtures__/structured-log-entries'); + entries = STRUCTURED_LOG_ENTRIES.filter((entry) => { + if (selectedSource !== 'all' && entry.source !== selectedSource) return false; + if (selectedLevel !== 'all' && entry.level !== selectedLevel) return false; + if (deferredSearch) { + const needle = deferredSearch.toLowerCase(); + const hay = + `${entry.message} ${entry.event} ${entry.module ?? ''} ${entry.requestId ?? ''}`.toLowerCase(); + if (!hay.includes(needle)) return false; + } + return true; + }).slice(0, limit); + } else { + const result = await api.logs.getEntries({ source: selectedSource === 'all' ? undefined : selectedSource, level: selectedLevel === 'all' ? undefined : selectedLevel, search: deferredSearch || undefined, limit, - }) - ).entries, + }); + entries = result.entries; + } + + // Client-side fallback for advanced filters (backend may not yet implement). + const cutoffMs = windowToCutoffMs(timeWindow); + const now = Date.now(); + const filtered = entries.filter((entry) => { + if (debouncedModule && !(entry.module ?? '').includes(debouncedModule)) return false; + if (debouncedStage && (entry.stage ?? '') !== debouncedStage) return false; + if (debouncedRequestId && !(entry.requestId ?? '').includes(debouncedRequestId)) + return false; + if (cutoffMs !== null) { + const ts = Date.parse(entry.timestamp); + if (Number.isFinite(ts) && now - ts > cutoffMs) return false; + } + return true; + }); + const head = filtered[0]; + if (head) assertLogsEntryShape(head); + return filtered; + }, placeholderData: keepPreviousData, - refetchInterval: 10_000, + refetchInterval, + refetchOnWindowFocus: true, }); - const activeSelectedEntryId = useMemo(() => { - const nextEntries = entriesQuery.data ?? []; - if (nextEntries.length === 0) { - return null; + // Live-tail pending count: rotation-safe id-set diff. + const entriesData = entriesQuery.data; + const backendEntries = useMemo(() => entriesData ?? [], [entriesData]); + const pendingCount = useMemo(() => { + if (!isPaused) return 0; + const frozen = frozenIdsRef.current; + let count = 0; + for (const e of backendEntries) { + if (!frozen.has(e.id)) count += 1; } + return count; + }, [backendEntries, isPaused]); - if (selectedEntryId && nextEntries.some((entry) => entry.id === selectedEntryId)) { + const pause = useCallback(() => { + frozenIdsRef.current = new Set(backendEntries.map((e) => e.id)); + setIsPaused(true); + }, [backendEntries]); + + const resume = useCallback(() => { + frozenIdsRef.current = new Set(); + setIsPaused(false); + void entriesQuery.refetch(); + }, [entriesQuery]); + + const togglePause = useCallback(() => { + if (isPaused) resume(); + else pause(); + }, [isPaused, pause, resume]); + + // Selection: keep selectedId across refetch, fall back to first entry. + const activeSelectedEntryId = useMemo(() => { + if (backendEntries.length === 0) return null; + if (selectedEntryId && backendEntries.some((entry) => entry.id === selectedEntryId)) { return selectedEntryId; } - - return nextEntries[0]?.id ?? null; - }, [entriesQuery.data, selectedEntryId]); + return backendEntries[0]?.id ?? null; + }, [backendEntries, selectedEntryId]); const selectedEntry = useMemo( - () => (entriesQuery.data ?? []).find((entry) => entry.id === activeSelectedEntryId) ?? null, - [activeSelectedEntryId, entriesQuery.data] + () => backendEntries.find((entry) => entry.id === activeSelectedEntryId) ?? null, + [activeSelectedEntryId, backendEntries] + ); + + // selectionOutOfScope: a selection survived but is no longer in filtered entries. + const isSelectionOutOfScope = Boolean( + selectedEntryId && !backendEntries.some((entry) => entry.id === selectedEntryId) ); const latestTimestamp = useMemo(() => { @@ -76,6 +257,16 @@ export function useLogsWorkspace() { return timestamps.sort((left, right) => right.localeCompare(left))[0] ?? null; }, [sourcesQuery.data]); + const clearAdvancedFilters = useCallback(() => { + setSearch(''); + setSelectedLevel('all'); + setSelectedSource('all'); + setModuleFilter(''); + setStageFilter(''); + setRequestIdFilter(''); + setTimeWindow('all'); + }, []); + return { configQuery, sourcesQuery, @@ -86,16 +277,33 @@ export function useLogsWorkspace() { setSelectedLevel, search, setSearch, + moduleFilter, + setModuleFilter, + stageFilter, + setStageFilter, + requestIdFilter, + setRequestIdFilter, + timeWindow, + setTimeWindow, limit, setLimit, selectedEntryId: activeSelectedEntryId, setSelectedEntryId, selectedEntry, + isSelectionOutOfScope, latestTimestamp, isInitialLoading: (!configQuery.data && configQuery.isLoading) || (!sourcesQuery.data && sourcesQuery.isLoading) || (!entriesQuery.data && entriesQuery.isLoading), + liveTail: { + isPaused, + pendingCount, + pause, + resume, + togglePause, + }, + clearAdvancedFilters, }; } @@ -129,6 +337,16 @@ export function getLogLevelOptions(): Array<{ value: LogsLevelFilter; label: str ]; } +export function getLogsTimeWindowOptions(): Array<{ value: LogsTimeWindow; label: string }> { + return [ + { value: 'all', label: 'All time' }, + { value: '5m', label: 'Last 5m' }, + { value: '15m', label: 'Last 15m' }, + { value: '1h', label: 'Last 1h' }, + { value: '24h', label: 'Last 24h' }, + ]; +} + export function getSelectedSourceLabel( source: LogsSourceFilter, sources: Array<{ source: string; label: string }> diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 8164facf..c35034cb 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -981,6 +981,14 @@ export interface LogsEntry { processId: number | null; runId: string | null; context?: unknown; + // Structured-log fields from #1141 contract. All optional; UI ships first, + // backend fills these incrementally. Order is stable and additive. + requestId?: string; + module?: string; + stage?: string; + latencyMs?: number; + metadata?: Record; + error?: { message: string; stack?: string; code?: string }; } export interface LogsEntriesParams { diff --git a/ui/src/pages/logs.tsx b/ui/src/pages/logs.tsx index ffc755fe..124439fd 100644 --- a/ui/src/pages/logs.tsx +++ b/ui/src/pages/logs.tsx @@ -1,487 +1,25 @@ -import { useEffect, useState } from 'react'; -import { - ArrowRight, - ChevronLeft, - ChevronRight, - RefreshCw, - ScrollText, - TimerReset, -} from 'lucide-react'; -import { Link } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { cn } from '@/lib/utils'; -import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; -import { LogsConfigCard } from '@/components/logs/logs-config-card'; -import { LogsDetailPanel } from '@/components/logs/logs-detail-panel'; -import { LogsEntryList } from '@/components/logs/logs-entry-list'; -import { LogsFilters } from '@/components/logs/logs-filters'; +import { LogsShell } from '@/components/logs/logs-shell'; import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton'; -import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs'; -// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready - -const DESKTOP_LOGS_BREAKPOINT = 1200; -const LEFT_PANEL_WIDTH = 336; -const RIGHT_PANEL_WIDTH = 368; -const COLLAPSED_PANEL_WIDTH = 52; - -function CollapsedPaneToggle({ - side, - label, - onExpand, -}: { - side: 'left' | 'right'; - label: string; - onExpand: () => void; -}) { - return ( -
- - - {label} - -
- ); -} +import { useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs'; +/** + * Logs route — calm, virtualized 3-pane shell. + * + * Implementation lives in `LogsShell` (composition + layout) and the + * `logs/*` components (filters/list/detail/settings). This file is just the + * route + initial-load gate. + */ export function LogsPage() { - // TODO i18n: uncomment when keys for Syncing/Refresh and other strings are added - // const { t } = useTranslation(); const workspace = useLogsWorkspace(); const updateConfig = useUpdateLogsConfig(); - const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []); - const [isDesktopLayout, setIsDesktopLayout] = useState(() => - typeof window !== 'undefined' ? window.innerWidth >= DESKTOP_LOGS_BREAKPOINT : false - ); - const [isFiltersCollapsed, setIsFiltersCollapsed] = useState(false); - const [isDetailsCollapsed, setIsDetailsCollapsed] = useState(false); - - useEffect(() => { - const mediaQuery = window.matchMedia(`(min-width: ${DESKTOP_LOGS_BREAKPOINT}px)`); - const syncLayout = () => { - setIsDesktopLayout(window.innerWidth >= DESKTOP_LOGS_BREAKPOINT); - }; - - syncLayout(); - mediaQuery.addEventListener('change', syncLayout); - return () => mediaQuery.removeEventListener('change', syncLayout); - }, []); if (workspace.isInitialLoading) { return ; } - const config = workspace.configQuery.data; - if (!config) { + if (!workspace.configQuery.data) { return null; } - return ( -
-
- -
-
-
-
- -
-
-
- - Operational - -

- Log Operations Center -

-
-

- CCS.TOC.LOGS.STREAM.v3 -

-
-
- -
- -
-
-
-
- - Redaction - - - {config.redact ? 'Enforced' : 'Standby'} - -
-
-
-
- -
-
- - Retention - - - {config.retain_days}D / {config.rotate_mb}MB - -
-
-
-
- -
- -
- -
-
- -
- -
- - - Telemetry Stream - - - Legacy Errors - - - -
-
- - - - - - Connected - -
- - {workspace.entriesQuery.data?.length ?? 0} captured - -
-
- - - {isDesktopLayout ? ( -
-
- {isFiltersCollapsed ? ( - setIsFiltersCollapsed(false)} - /> - ) : ( -
-
-
-

- Filters -

-

- Search, source, and retention controls -

-
- -
- - -
- - void Promise.all([ - workspace.sourcesQuery.refetch(), - workspace.entriesQuery.refetch(), - ]) - } - isRefreshing={ - workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching - } - /> -
- updateConfig.mutate(payload)} - isPending={updateConfig.isPending} - /> -
-
-
-
- )} -
- -
- -
- -
- {isDetailsCollapsed ? ( - setIsDetailsCollapsed(false)} - /> - ) : ( -
-
-
-

- Details -

-

- Selected entry context and raw payload -

-
- -
-
- -
-
- )} -
-
- ) : ( -
-
-
- - void Promise.all([ - workspace.sourcesQuery.refetch(), - workspace.entriesQuery.refetch(), - ]) - } - isRefreshing={ - workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching - } - /> - updateConfig.mutate(payload)} - isPending={updateConfig.isPending} - /> -
-
- -
- -
- -
- -
-
- )} -
- - -
-
-
- - - -
-
-
- -
-
-

- Legacy Diagnostic Node -

-

- CCS-MATRIX-FAILURE-MONITOR -

-
-
-
- Mode: Historical -
-
- -
-

- CLIProxy Failure Analysis -

-

- Maintain oversight of legacy request failures while the unified stream - consolidates system-wide telemetry. This view provides direct access to the - historical failure matrix for deep-field debugging. -

-
- -
- - -
- -
-
-
- - Realtime Monitoring Deck - -
- -
-
- - -
-
- ); + return ; }