diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx index 9f977127..09c68564 100644 --- a/ui/src/components/logs/logs-detail-panel.tsx +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -7,7 +7,13 @@ import type { LogsEntry } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { LogLevelBadge } from './log-level-badge'; import { LogsEmpty } from './logs-empty'; -import { formatJson } from './utils'; +import { + formatJson, + getDisplayLatency, + getDisplayModule, + getDisplayRequestId, + getDisplayStage, +} from './utils'; import { FOCUS_RING, MONO_NUMERIC } from './tokens'; export interface LogsDetailPanelProps { @@ -26,19 +32,15 @@ interface OverviewRow { } function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] { + // Use shared accessors so this panel and the list row never diverge. return [ { label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true }, { label: 'Level', value: entry.level }, - { label: 'Module', value: entry.module ?? '—' }, - { label: 'Stage', value: entry.stage ?? '—' }, - { label: 'Request ID', value: entry.requestId ?? '—', mono: true }, - { - label: 'Latency', - value: - entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : '—', - mono: true, - }, - { label: 'Source', value: sourceLabel ?? entry.source }, + { label: 'Module', value: getDisplayModule(entry, sourceLabel) }, + { label: 'Stage', value: getDisplayStage(entry) }, + { label: 'Request ID', value: getDisplayRequestId(entry), mono: true }, + { label: 'Latency', value: getDisplayLatency(entry), mono: true }, + { label: 'Source', value: sourceLabel ?? entry.source ?? '—' }, { label: 'Run ID', value: entry.runId ?? '—', mono: true }, { label: 'Process ID', value: entry.processId ?? '—', mono: true }, ]; diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx index b03687e8..b12c0b24 100644 --- a/ui/src/components/logs/logs-entry-list.tsx +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -25,7 +25,7 @@ export interface LogsEntryListProps { } const COLS_TEMPLATE = - 'grid grid-cols-[88px_64px_140px_minmax(0,1fr)_72px_88px] items-center gap-3 px-3'; + 'grid grid-cols-[88px_64px_140px_minmax(0,1fr)_72px_112px] items-center gap-3 px-3'; export function LogsEntryList({ entries, @@ -85,6 +85,7 @@ export function LogsEntryList({ density={density} sourceLabel={sourceLabels[item.entry.source] ?? item.entry.source} onSelect={onSelect} + repeatCount={item.repeatCount} /> ); }, @@ -98,7 +99,7 @@ export function LogsEntryList({ role="row" className={cn( COLS_TEMPLATE, - 'h-8 shrink-0 border-b border-border bg-muted/30 text-[10px] font-medium uppercase tracking-wide text-muted-foreground' + 'h-9 shrink-0 border-b border-border bg-muted/30 text-[12px] font-medium uppercase tracking-wide text-muted-foreground' )} > Time diff --git a/ui/src/components/logs/logs-row.tsx b/ui/src/components/logs/logs-row.tsx index 8889fe43..74c5d020 100644 --- a/ui/src/components/logs/logs-row.tsx +++ b/ui/src/components/logs/logs-row.tsx @@ -1,8 +1,10 @@ -import { memo } from 'react'; +import { memo, useState } from 'react'; +import { Copy } from 'lucide-react'; import type { LogsEntry } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { LogLevelBadge } from './log-level-badge'; import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens'; +import { getDisplayLatency, getDisplayModule, getDisplayRequestId } from './utils'; export interface LogsRowProps { entry: LogsEntry; @@ -14,6 +16,8 @@ export interface LogsRowProps { indent?: number; /** Optional stage chip text (e.g. for child rows of a trace). */ stageHint?: string; + /** Optional repeat counter — when the row coalesces N identical consecutive entries. */ + repeatCount?: number; } function formatHms(timestamp: string): string { @@ -27,6 +31,14 @@ function formatHms(timestamp: string): string { }); } +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + // best-effort; clipboard may be unavailable in non-secure contexts + } +} + function LogsRowImpl({ entry, isSelected, @@ -35,7 +47,21 @@ function LogsRowImpl({ onSelect, indent = 0, stageHint, + repeatCount, }: LogsRowProps) { + const [justCopied, setJustCopied] = useState(false); + const moduleLabel = getDisplayModule(entry, sourceLabel); + const latencyLabel = getDisplayLatency(entry); + const shortRequestId = getDisplayRequestId(entry, { short: true }); + + const handleCopyRequestId = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!entry.requestId) return; + await copyText(entry.requestId); + setJustCopied(true); + window.setTimeout(() => setJustCopied(false), 1200); + }; + return ( ); @@ -107,6 +158,7 @@ export const LogsRow = memo(LogsRowImpl, (prev, next) => { prev.density === next.density && prev.indent === next.indent && prev.stageHint === next.stageHint && - prev.sourceLabel === next.sourceLabel + prev.sourceLabel === next.sourceLabel && + prev.repeatCount === next.repeatCount ); }); diff --git a/ui/src/components/logs/utils.ts b/ui/src/components/logs/utils.ts index 90de8d1b..1c77dbf3 100644 --- a/ui/src/components/logs/utils.ts +++ b/ui/src/components/logs/utils.ts @@ -1,4 +1,4 @@ -import type { LogsLevel } from '@/lib/api-client'; +import type { LogsEntry, LogsLevel } from '@/lib/api-client'; // NOTE: This module contains utility functions that are not directly i18n-aware. // String literals here ("No activity yet", "Error", etc.) are used as fallbacks // and defaults in non-component contexts. Components consuming these values @@ -80,3 +80,37 @@ export function getLevelLabel(level: LogsLevel) { return 'Debug'; } } + +// Field accessors shared by list row + detail panel. +// Without these, list and detail diverged — list fell back to source/sourceLabel +// while detail showed `—` for the same entry. Single source of truth. + +export function getDisplayModule(entry: LogsEntry, sourceLabel?: string): string { + return entry.module ?? sourceLabel ?? entry.source ?? '—'; +} + +export function getDisplayStage(entry: LogsEntry): string { + return entry.stage ?? '—'; +} + +export function getDisplayRequestId(entry: LogsEntry, options: { short?: boolean } = {}): string { + if (!entry.requestId) return '—'; + return options.short ? entry.requestId.slice(-8) : entry.requestId; +} + +export function getDisplayLatency(entry: LogsEntry): string { + if (entry.latencyMs === undefined || entry.latencyMs === null) return '—'; + return `${entry.latencyMs}ms`; +} + +/** + * Default filter pattern: dashboard self-polling sources start with `web-server:`. + * UI applies as a default exclusion to keep the logs view focused on signal, + * not the dashboard observing itself. + */ +export const DASHBOARD_INTERNALS_PATTERN = /^web-server:/i; + +export function isDashboardInternal(entry: LogsEntry): boolean { + if (!entry.source) return false; + return DASHBOARD_INTERNALS_PATTERN.test(entry.source); +}