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);
+}