From d7319928d18354eaf70ab7ea451e79496f9afd5a Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:53:18 +0100 Subject: [PATCH] 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. --- frontend/src/settings/Logs.tsx | 13 +- frontend/src/settings/traces/TraceChips.tsx | 4 +- frontend/src/settings/traces/TraceSheet.tsx | 33 +-- .../src/settings/traces/TraceSpanDetails.tsx | 9 +- .../src/settings/traces/TraceWaterfall.tsx | 195 ++++++++++-------- 5 files changed, 141 insertions(+), 113 deletions(-) diff --git a/frontend/src/settings/Logs.tsx b/frontend/src/settings/Logs.tsx index aec4c42f..52d7e08e 100644 --- a/frontend/src/settings/Logs.tsx +++ b/frontend/src/settings/Logs.tsx @@ -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} /> - + )} {detailRows.length > 0 && ( diff --git a/frontend/src/settings/traces/TraceChips.tsx b/frontend/src/settings/traces/TraceChips.tsx index e5aa32c7..d1240a57 100644 --- a/frontend/src/settings/traces/TraceChips.tsx +++ b/frontend/src/settings/traces/TraceChips.tsx @@ -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} diff --git a/frontend/src/settings/traces/TraceSheet.tsx b/frontend/src/settings/traces/TraceSheet.tsx index 027c4172..5a847c96 100644 --- a/frontend/src/settings/traces/TraceSheet.tsx +++ b/frontend/src/settings/traces/TraceSheet.tsx @@ -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 = - { - ok: 'success', - error: 'danger', - paused: 'warning', - cancelled: 'muted', - }; +/** Status pill tones. The theme has no success or warning token yet. */ +const STATUS_TONE: Record = { + 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({ !open && onClose()}> - + {t('settings.logs.trace.title')} {t('settings.logs.trace.subtitle')} -
+
{loading && (
)} {!loading && failed && ( -

+

{t('settings.logs.trace.failed')}

)} @@ -120,19 +119,23 @@ export default function TraceSheet({ {formatDateTime(trace.started_at)} - + {t( `settings.logs.trace.status.${trace.status}`, trace.status, )} - +
{trace.dropped_spans > 0 && ( -

+

{t('settings.logs.trace.droppedSpans', { count: trace.dropped_spans, })} diff --git a/frontend/src/settings/traces/TraceSpanDetails.tsx b/frontend/src/settings/traces/TraceSpanDetails.tsx index 576c42a7..09554057 100644 --- a/frontend/src/settings/traces/TraceSpanDetails.tsx +++ b/frontend/src/settings/traces/TraceSpanDetails.tsx @@ -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 }) { ))}

{span.error && ( -
- {span.error} -
+ + + {span.error} + + )} {preview.query !== undefined && ( diff --git a/frontend/src/settings/traces/TraceWaterfall.tsx b/frontend/src/settings/traces/TraceWaterfall.tsx index fba28239..30957719 100644 --- a/frontend/src/settings/traces/TraceWaterfall.tsx +++ b/frontend/src/settings/traces/TraceWaterfall.tsx @@ -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 = { - 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 (

@@ -81,101 +104,99 @@ export default function TraceWaterfall({ trace }: { trace: Trace }) { } return ( -

-
- - {t('settings.logs.trace.step')} - -
- {SCALE_STEPS.map((step) => ( +
+
+ {t('settings.logs.trace.step')} +
+ {SCALE_TICKS.map((tick) => ( - {step === 0 ? '0' : formatDurationMs(totalMs * step)} + {tick.at === 0 ? '0' : formatDurationMs(totalMs * tick.at)} ))}
{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 ( -
+
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' : ''}`} > - +
{hasChildren ? ( - + ) : ( - + )} - - - {span.name} - - {headline && ( - - {headline} + +
+ {/* Mouse shortcut to the row's button; keyboard users use the button. */} +
{selected && ( -
+
)}