fix(ui): align logs row and detail panel via shared accessors

Both surfaces now consume `getDisplayModule / getDisplayStage /
getDisplayRequestId / getDisplayLatency` from `utils.ts`, replacing the
previous divergence where the list row fell back to `source` while the
detail panel showed em-dash placeholders for the same entry.

Also widens the request-id column in the row table from 88px to 112px
and adds a hover-revealed clipboard icon for one-click copy of the full
requestId. List header bumped to 12px sans-serif uppercase + 9h height
for legibility.

Refs #1138, #1141, #1142
This commit is contained in:
Tam Nhu Tran
2026-04-30 13:37:02 -04:00
parent b892739756
commit d5b14bec1c
4 changed files with 115 additions and 26 deletions
+13 -11
View File
@@ -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 },
];
+3 -2
View File
@@ -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'
)}
>
<span role="columnheader">Time</span>
+64 -12
View File
@@ -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<void> {
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 (
<button
type="button"
@@ -45,7 +71,7 @@ function LogsRowImpl({
onClick={() => onSelect(entry.id)}
style={{ paddingLeft: 12 + indent }}
className={cn(
'flex w-full items-center gap-3 border-b border-border/50 pr-3 text-left',
'group flex w-full items-center gap-3 border-b border-border/50 pr-3 text-left',
ROW_DENSITY[density],
ROW_INTERACTIVE,
FOCUS_RING,
@@ -54,7 +80,7 @@ function LogsRowImpl({
>
<span
role="cell"
className={cn('w-[88px] shrink-0 truncate text-[11px] text-muted-foreground', MONO_NUMERIC)}
className={cn('w-[88px] shrink-0 truncate text-[12px] text-muted-foreground', MONO_NUMERIC)}
>
{formatHms(entry.timestamp)}
</span>
@@ -64,37 +90,62 @@ function LogsRowImpl({
{stageHint ? (
<span
role="cell"
className="w-[72px] shrink-0 truncate rounded border border-border bg-muted/30 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"
className="w-[72px] shrink-0 truncate rounded border border-border bg-muted/30 px-1.5 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground"
>
{stageHint}
</span>
) : null}
<span
role="cell"
className="w-[140px] shrink-0 truncate text-[11px] font-medium text-foreground/80"
className="w-[140px] shrink-0 truncate text-[12px] font-medium text-foreground/80"
>
{entry.module ?? sourceLabel}
{moduleLabel}
</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">
{entry.message}
{repeatCount && repeatCount > 1 ? (
<span
className="ml-2 inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground"
title={`${repeatCount} consecutive identical entries`}
>
×{repeatCount}
</span>
) : null}
</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
)}
>
{entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : ''}
{latencyLabel === '—' ? '' : latencyLabel}
</span>
<span
role="cell"
className={cn(
'hidden w-[88px] shrink-0 truncate text-right text-[11px] text-muted-foreground/80 lg:inline-block',
'hidden w-[112px] shrink-0 items-center justify-end gap-1 text-right text-[12px] text-muted-foreground/80 lg:inline-flex',
MONO_NUMERIC
)}
>
{entry.requestId ? entry.requestId.slice(-8) : ''}
{entry.requestId ? (
<>
<span className="truncate">{shortRequestId}</span>
<span
role="button"
tabIndex={-1}
aria-label={justCopied ? 'Copied requestId' : 'Copy requestId'}
title={justCopied ? 'Copied' : 'Copy requestId'}
onClick={handleCopyRequestId}
className={cn(
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground/50 opacity-0 transition-opacity hover:bg-muted hover:text-foreground group-hover:opacity-100',
justCopied && 'text-emerald-600 opacity-100'
)}
>
<Copy className="h-3 w-3" aria-hidden="true" />
</span>
</>
) : null}
</span>
</button>
);
@@ -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
);
});
+35 -1
View File
@@ -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);
}