Build the trace UI on theme tokens and existing components

Colours come from theme tokens only: status pills and chips use muted,
primary and destructive; waterfall bars use chart-1..5, one per group of
span kinds, with destructive for failures and muted-foreground for
cancelled or pending steps. Rows select through ghost Buttons, the error box
is a destructive Alert and View trace is an outline Button. Bar geometry and
indentation move to CSS custom properties, scale ticks to static
positions, and the sheet takes layout classes only.
This commit is contained in:
arc53-machine committed 2026-09-23 20:53:18 +01:00
1 parent 8157bcf3bf
commit d7319928d1
5 files changed
+141 -113

No files matched your search

+7 -6
View File
@@ -7,6 +7,7 @@ import userService from '../api/services/userService';
import ChevronRight from '../assets/chevron-right.svg';
import CopyButton from '../components/CopyButton';
import SkeletonLoader from '../components/SkeletonLoader';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import {
Select,
@@ -439,7 +440,7 @@ function Log({
onToggle(log.id);
}
}}
className={`text-foreground flex cursor-pointer flex-row items-start gap-2 p-2 px-4 py-3 ${
className={`text-foreground focus-visible:ring-ring/50 flex cursor-pointer flex-row items-start gap-2 p-2 px-4 py-3 outline-none focus-visible:ring-3 focus-visible:ring-inset ${
isOpen ? 'dark:bg-background rounded-t-xl bg-[#F1F1F1]' : ''
}`}
>
@@ -478,18 +479,18 @@ function Log({
durationMs={log.trace.duration_ms}
counts={log.trace.summary}
/>
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={() => log.trace && onViewTrace(log.trace.ref)}
className="border-border hover:bg-muted text-foreground inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs dark:hover:bg-white/10"
>
<Activity className="size-3.5" />
<Activity />
{log.trace.count > 1
? t('settings.logs.trace.viewRounds', {
count: log.trace.count,
})
: t('settings.logs.trace.view')}
</button>
</Button>
</div>
)}
{detailRows.length > 0 && (
+2 -2
View File
@@ -80,8 +80,8 @@ export default function TraceChips({ durationMs, counts }: TraceChipsProps) {
key={chip.key}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs tabular-nums ${
chip.tone === 'danger'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'bg-muted text-foreground dark:bg-white/10'
? 'bg-destructive/10 text-destructive'
: 'bg-muted text-foreground'
}`}
>
{chip.icon}
+18 -15
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Pill } from '../../admin/AdminUI';
import userService from '../../api/services/userService';
import Spinner from '../../components/Spinner';
import {
@@ -24,13 +23,13 @@ type TraceSheetProps = {
onClose: () => void;
};
const STATUS_TONE: Record<string, 'success' | 'danger' | 'warning' | 'muted'> =
{
ok: 'success',
error: 'danger',
paused: 'warning',
cancelled: 'muted',
};
/** Status pill tones. The theme has no success or warning token yet. */
const STATUS_TONE: Record<string, string> = {
ok: 'bg-muted text-foreground',
error: 'bg-destructive/10 text-destructive',
paused: 'bg-primary/10 text-primary',
cancelled: 'bg-muted text-muted-foreground',
};
/**
* Side panel showing the execution trace(s) behind one Logs row. A chat turn
@@ -79,22 +78,22 @@ export default function TraceSheet({
<Sheet open={traceRef !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
className="bg-card w-full overflow-y-auto sm:max-w-3xl"
className="w-full overflow-y-auto sm:max-w-3xl"
>
<SheetHeader className="pb-0">
<SheetHeader>
<SheetTitle>{t('settings.logs.trace.title')}</SheetTitle>
<SheetDescription>
{t('settings.logs.trace.subtitle')}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-6 px-4 pb-6">
<div className="flex flex-col gap-8 px-4 pb-6">
{loading && (
<div className="flex justify-center py-10">
<Spinner />
</div>
)}
{!loading && failed && (
<p className="text-sm text-red-500">
<p className="text-destructive text-sm">
{t('settings.logs.trace.failed')}
</p>
)}
@@ -120,19 +119,23 @@ export default function TraceSheet({
<span className="text-muted-foreground text-xs">
{formatDateTime(trace.started_at)}
</span>
<Pill tone={STATUS_TONE[trace.status] ?? 'muted'}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
STATUS_TONE[trace.status] ?? STATUS_TONE.cancelled
}`}
>
{t(
`settings.logs.trace.status.${trace.status}`,
trace.status,
)}
</Pill>
</span>
</div>
<TraceChips
durationMs={trace.duration_ms}
counts={trace.summary ?? {}}
/>
{trace.dropped_spans > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="text-muted-foreground text-xs">
{t('settings.logs.trace.droppedSpans', {
count: trace.dropped_spans,
})}
@@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '../../components/ui/alert';
import { ToolCallPanel } from '../../conversation/AnswerFlow';
import { TraceSpan } from '../types';
import { formatDurationMs, formatTokens } from './traceUtils';
@@ -121,9 +122,11 @@ export default function TraceSpanDetails({ span }: { span: TraceSpan }) {
))}
</div>
{span.error && (
<div className="rounded-lg bg-red-50 px-3 py-2 font-mono whitespace-pre-wrap text-red-700 dark:bg-red-900/30 dark:text-red-300">
{span.error}
</div>
<Alert variant="destructive">
<AlertDescription className="font-mono whitespace-pre-wrap">
{span.error}
</AlertDescription>
</Alert>
)}
{preview.query !== undefined && (
<ToolCallPanel title={f('query')} copyText={jsonText(preview.query)}>
+108 -87
View File
@@ -13,6 +13,7 @@ import {
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/button';
import { Trace, TraceSpan } from '../types';
import TraceSpanDetails from './TraceSpanDetails';
import {
@@ -23,29 +24,48 @@ import {
traceTotalMs,
} from './traceUtils';
/**
* Icon per span kind, and one of the five chart series per group of kinds:
* the agent and its steps, model calls, tool calls, retrieval (with its
* embedding, per-source search and rerank), and guardrails.
*/
const KIND_STYLE: Record<string, { icon: React.ElementType; bar: string }> = {
agent: { icon: Bot, bar: 'bg-violet-500' },
llm: { icon: Brain, bar: 'bg-sky-500' },
tool: { icon: Wrench, bar: 'bg-amber-500' },
retrieval: { icon: Search, bar: 'bg-emerald-500' },
search: { icon: Database, bar: 'bg-emerald-400' },
embedding: { icon: Binary, bar: 'bg-teal-500' },
rerank: { icon: ListFilter, bar: 'bg-lime-500' },
guardrail: { icon: ShieldCheck, bar: 'bg-rose-400' },
step: { icon: ListTree, bar: 'bg-indigo-400' },
agent: { icon: Bot, bar: 'bg-chart-1' },
step: { icon: ListTree, bar: 'bg-chart-1' },
llm: { icon: Brain, bar: 'bg-chart-2' },
tool: { icon: Wrench, bar: 'bg-chart-3' },
retrieval: { icon: Search, bar: 'bg-chart-4' },
search: { icon: Database, bar: 'bg-chart-4' },
embedding: { icon: Binary, bar: 'bg-chart-4' },
rerank: { icon: ListFilter, bar: 'bg-chart-4' },
guardrail: { icon: ShieldCheck, bar: 'bg-chart-5' },
};
function barClass(span: TraceSpan): string {
if (span.status === 'error') return 'bg-red-500';
if (['cancelled', 'pending', 'denied', 'skipped'].includes(span.status))
return 'bg-gray-400 dark:bg-gray-500';
return (KIND_STYLE[span.kind] ?? KIND_STYLE.step).bar;
const INACTIVE_STATUSES = ['cancelled', 'pending', 'denied', 'skipped'];
function kindStyle(span: TraceSpan) {
return KIND_STYLE[span.kind] ?? KIND_STYLE.step;
}
const SCALE_STEPS = [0, 0.25, 0.5, 0.75, 1];
const INDENT_PX = 14;
function barClass(span: TraceSpan): string {
if (span.status === 'error') return 'bg-destructive';
if (INACTIVE_STATUSES.includes(span.status)) return 'bg-muted-foreground/40';
return kindStyle(span).bar;
}
/** A waterfall of one trace's spans; click a row for its details. */
/** Scale ticks: 0, a quarter, half, three quarters and the full duration. */
const SCALE_TICKS = [
{ at: 0, className: 'left-0' },
{ at: 0.25, className: 'left-1/4 -translate-x-1/2' },
{ at: 0.5, className: 'left-1/2 -translate-x-1/2' },
{ at: 0.75, className: 'left-3/4 -translate-x-1/2' },
{ at: 1, className: 'right-0' },
];
/** Indent per nesting level, in rem. */
const INDENT_REM = 0.875;
/** A waterfall of one trace's spans; select a row for its details. */
export default function TraceWaterfall({ trace }: { trace: Trace }) {
const { t } = useTranslation();
const rows = useMemo(() => buildSpanRows(trace.spans), [trace.spans]);
@@ -72,6 +92,9 @@ export default function TraceWaterfall({ trace }: { trace: Trace }) {
return next;
});
const toggleSelected = (id: string) =>
setSelectedId((current) => (current === id ? null : id));
if (!rows.length) {
return (
<p className="text-muted-foreground py-4 text-center text-xs">
@@ -81,101 +104,99 @@ export default function TraceWaterfall({ trace }: { trace: Trace }) {
}
return (
<div className="flex flex-col text-xs" role="tree">
<div className="grid grid-cols-[minmax(0,5fr)_minmax(0,4fr)] gap-3 pb-1">
<span className="text-muted-foreground">
{t('settings.logs.trace.step')}
</span>
<div className="text-muted-foreground relative h-4">
{SCALE_STEPS.map((step) => (
<div className="flex flex-col gap-0.5" role="tree">
<div className="text-muted-foreground grid grid-cols-9 gap-3 pb-1 text-xs">
<span className="col-span-5">{t('settings.logs.trace.step')}</span>
<div className="relative col-span-4 h-4">
{SCALE_TICKS.map((tick) => (
<span
key={step}
className="absolute top-0 -translate-x-1/2 whitespace-nowrap tabular-nums first:translate-x-0 last:-translate-x-full"
style={{ left: `${step * 100}%` }}
key={tick.at}
className={`absolute top-0 whitespace-nowrap tabular-nums ${tick.className}`}
>
{step === 0 ? '0' : formatDurationMs(totalMs * step)}
{tick.at === 0 ? '0' : formatDurationMs(totalMs * tick.at)}
</span>
))}
</div>
</div>
{visibleRows.map(({ span, depth, hasChildren }) => {
const Icon = (KIND_STYLE[span.kind] ?? KIND_STYLE.step).icon;
const Icon = kindStyle(span).icon;
const { left, width } = barGeometry(span, totalMs);
const selected = selectedId === span.id;
const headline = spanHeadline(span, t);
const isCollapsed = collapsed.has(span.id);
const geometry = {
'--trace-indent': `${depth * INDENT_REM}rem`,
'--trace-bar-left': `${left}%`,
'--trace-bar-width': `${width}%`,
} as React.CSSProperties;
return (
<div key={span.id} role="treeitem" aria-selected={selected}>
<div
key={span.id}
role="treeitem"
aria-level={depth + 1}
aria-selected={selected}
aria-expanded={hasChildren ? !isCollapsed : undefined}
style={geometry}
>
<div
role="button"
tabIndex={0}
onClick={() => setSelectedId(selected ? null : span.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedId(selected ? null : span.id);
}
}}
className={`grid w-full cursor-pointer grid-cols-[minmax(0,5fr)_minmax(0,4fr)] items-center gap-3 rounded-md py-1 text-left ${
selected
? 'bg-muted dark:bg-white/10'
: 'hover:bg-muted/60 dark:hover:bg-white/5'
}`}
className={`grid grid-cols-9 items-center gap-3 rounded-md ${selected ? 'bg-accent' : ''}`}
>
<span
className="flex min-w-0 items-center gap-1.5"
style={{ paddingLeft: depth * INDENT_PX }}
>
<div className="col-span-5 flex min-w-0 items-center pl-(--trace-indent)">
{hasChildren ? (
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
aria-label={t('settings.logs.trace.toggleChildren')}
aria-expanded={!collapsed.has(span.id)}
onClick={(e) => {
e.stopPropagation();
toggleCollapsed(span.id);
}}
onKeyDown={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground shrink-0"
aria-expanded={!isCollapsed}
onClick={() => toggleCollapsed(span.id)}
>
<ChevronRight
className={`size-3 transition-transform ${collapsed.has(span.id) ? '' : 'rotate-90'}`}
className={`text-muted-foreground transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
/>
</button>
</Button>
) : (
<span className="w-3 shrink-0" />
<span className="size-8 shrink-0" />
)}
<Icon
className={`size-3.5 shrink-0 ${span.status === 'error' ? 'text-red-500' : 'text-muted-foreground'}`}
aria-label={t(
`settings.logs.trace.kinds.${span.kind}`,
span.kind,
)}
/>
<span className="text-foreground truncate" title={span.name}>
{span.name}
</span>
{headline && (
<span className="text-muted-foreground shrink-0 truncate">
{headline}
<Button
variant="ghost"
size="sm"
className="min-w-0 flex-1 justify-start"
aria-pressed={selected}
onClick={() => toggleSelected(span.id)}
>
<Icon
className={`size-3.5 ${span.status === 'error' ? 'text-destructive' : 'text-muted-foreground'}`}
aria-label={t(
`settings.logs.trace.kinds.${span.kind}`,
span.kind,
)}
/>
<span className="truncate font-normal" title={span.name}>
{span.name}
</span>
)}
<span className="text-muted-foreground ml-auto shrink-0 pl-2 tabular-nums">
{formatDurationMs(span.duration_ms)}
</span>
</span>
<span className="bg-muted/60 relative h-3 rounded-sm dark:bg-white/5">
{headline && (
<span className="text-muted-foreground truncate text-xs font-normal">
{headline}
</span>
)}
<span className="text-muted-foreground ml-auto shrink-0 pl-2 text-xs font-normal tabular-nums">
{formatDurationMs(span.duration_ms)}
</span>
</Button>
</div>
{/* Mouse shortcut to the row's button; keyboard users use the button. */}
<div
aria-hidden="true"
onClick={() => toggleSelected(span.id)}
className="bg-muted relative col-span-4 h-3 cursor-pointer rounded-sm"
>
<span
className={`absolute inset-y-0 rounded-sm ${barClass(span)}`}
style={{ left: `${left}%`, width: `${width}%` }}
title={`${span.name} · ${formatDurationMs(span.duration_ms)}`}
className={`absolute inset-y-0 left-(--trace-bar-left) w-(--trace-bar-width) rounded-sm ${barClass(span)}`}
/>
</span>
</div>
</div>
{selected && (
<div
className="border-border my-1 rounded-lg border px-3 py-2"
style={{ marginLeft: depth * INDENT_PX }}
>
<div className="border-border mt-1 mb-2 ml-(--trace-indent) rounded-lg border p-3">
<TraceSpanDetails span={span} />
</div>
)}