mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-05 00: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 { cn } from '@/lib/utils';
|
||||||
import { LogLevelBadge } from './log-level-badge';
|
import { LogLevelBadge } from './log-level-badge';
|
||||||
import { LogsEmpty } from './logs-empty';
|
import { LogsEmpty } from './logs-empty';
|
||||||
import { formatJson } from './utils';
|
import {
|
||||||
|
formatJson,
|
||||||
|
getDisplayLatency,
|
||||||
|
getDisplayModule,
|
||||||
|
getDisplayRequestId,
|
||||||
|
getDisplayStage,
|
||||||
|
} from './utils';
|
||||||
import { FOCUS_RING, MONO_NUMERIC } from './tokens';
|
import { FOCUS_RING, MONO_NUMERIC } from './tokens';
|
||||||
|
|
||||||
export interface LogsDetailPanelProps {
|
export interface LogsDetailPanelProps {
|
||||||
@@ -26,19 +32,15 @@ interface OverviewRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] {
|
function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] {
|
||||||
|
// Use shared accessors so this panel and the list row never diverge.
|
||||||
return [
|
return [
|
||||||
{ label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true },
|
{ label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true },
|
||||||
{ label: 'Level', value: entry.level },
|
{ label: 'Level', value: entry.level },
|
||||||
{ label: 'Module', value: entry.module ?? '—' },
|
{ label: 'Module', value: getDisplayModule(entry, sourceLabel) },
|
||||||
{ label: 'Stage', value: entry.stage ?? '—' },
|
{ label: 'Stage', value: getDisplayStage(entry) },
|
||||||
{ label: 'Request ID', value: entry.requestId ?? '—', mono: true },
|
{ label: 'Request ID', value: getDisplayRequestId(entry), mono: true },
|
||||||
{
|
{ label: 'Latency', value: getDisplayLatency(entry), mono: true },
|
||||||
label: 'Latency',
|
{ label: 'Source', value: sourceLabel ?? entry.source ?? '—' },
|
||||||
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: 'Run ID', value: entry.runId ?? '—', mono: true },
|
||||||
{ label: 'Process ID', value: entry.processId ?? '—', mono: true },
|
{ label: 'Process ID', value: entry.processId ?? '—', mono: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface LogsEntryListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLS_TEMPLATE =
|
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({
|
export function LogsEntryList({
|
||||||
entries,
|
entries,
|
||||||
@@ -85,6 +85,7 @@ export function LogsEntryList({
|
|||||||
density={density}
|
density={density}
|
||||||
sourceLabel={sourceLabels[item.entry.source] ?? item.entry.source}
|
sourceLabel={sourceLabels[item.entry.source] ?? item.entry.source}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
repeatCount={item.repeatCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -98,7 +99,7 @@ export function LogsEntryList({
|
|||||||
role="row"
|
role="row"
|
||||||
className={cn(
|
className={cn(
|
||||||
COLS_TEMPLATE,
|
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>
|
<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 type { LogsEntry } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { LogLevelBadge } from './log-level-badge';
|
import { LogLevelBadge } from './log-level-badge';
|
||||||
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
||||||
|
import { getDisplayLatency, getDisplayModule, getDisplayRequestId } from './utils';
|
||||||
|
|
||||||
export interface LogsRowProps {
|
export interface LogsRowProps {
|
||||||
entry: LogsEntry;
|
entry: LogsEntry;
|
||||||
@@ -14,6 +16,8 @@ export interface LogsRowProps {
|
|||||||
indent?: number;
|
indent?: number;
|
||||||
/** Optional stage chip text (e.g. for child rows of a trace). */
|
/** Optional stage chip text (e.g. for child rows of a trace). */
|
||||||
stageHint?: string;
|
stageHint?: string;
|
||||||
|
/** Optional repeat counter — when the row coalesces N identical consecutive entries. */
|
||||||
|
repeatCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHms(timestamp: string): string {
|
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({
|
function LogsRowImpl({
|
||||||
entry,
|
entry,
|
||||||
isSelected,
|
isSelected,
|
||||||
@@ -35,7 +47,21 @@ function LogsRowImpl({
|
|||||||
onSelect,
|
onSelect,
|
||||||
indent = 0,
|
indent = 0,
|
||||||
stageHint,
|
stageHint,
|
||||||
|
repeatCount,
|
||||||
}: LogsRowProps) {
|
}: 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -45,7 +71,7 @@ function LogsRowImpl({
|
|||||||
onClick={() => onSelect(entry.id)}
|
onClick={() => onSelect(entry.id)}
|
||||||
style={{ paddingLeft: 12 + indent }}
|
style={{ paddingLeft: 12 + indent }}
|
||||||
className={cn(
|
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_DENSITY[density],
|
||||||
ROW_INTERACTIVE,
|
ROW_INTERACTIVE,
|
||||||
FOCUS_RING,
|
FOCUS_RING,
|
||||||
@@ -54,7 +80,7 @@ function LogsRowImpl({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
role="cell"
|
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)}
|
{formatHms(entry.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
@@ -64,37 +90,62 @@ function LogsRowImpl({
|
|||||||
{stageHint ? (
|
{stageHint ? (
|
||||||
<span
|
<span
|
||||||
role="cell"
|
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}
|
{stageHint}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
role="cell"
|
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>
|
||||||
<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}
|
{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>
|
||||||
<span
|
<span
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
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
|
MONO_NUMERIC
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : ''}
|
{latencyLabel === '—' ? '' : latencyLabel}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
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
|
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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -107,6 +158,7 @@ export const LogsRow = memo(LogsRowImpl, (prev, next) => {
|
|||||||
prev.density === next.density &&
|
prev.density === next.density &&
|
||||||
prev.indent === next.indent &&
|
prev.indent === next.indent &&
|
||||||
prev.stageHint === next.stageHint &&
|
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.
|
// NOTE: This module contains utility functions that are not directly i18n-aware.
|
||||||
// String literals here ("No activity yet", "Error", etc.) are used as fallbacks
|
// String literals here ("No activity yet", "Error", etc.) are used as fallbacks
|
||||||
// and defaults in non-component contexts. Components consuming these values
|
// and defaults in non-component contexts. Components consuming these values
|
||||||
@@ -80,3 +80,37 @@ export function getLevelLabel(level: LogsLevel) {
|
|||||||
return 'Debug';
|
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