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