mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
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:
1 parent
8157bcf3bf
commit
d7319928d1
5 files changed
+141
-113
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user