feat(traces): improve trace/span UI with timestamps, copy, syntax highlight

- Add created_at display to trace summary and span detail
- Add PreviewBlock component with copy button (top-right) and JSON
  syntax highlighting via highlight.js
- Show span start/end time inline with timezone support
- Use Badge for span duration and created_at on summary row
- StatusBadge: icon-only on mobile, text on desktop
- Double verbose trace preview limit from 100K to 200K chars
- Double preview content height on desktop (20vh → 40vh)
- formatDate() now accepts timezone param from useUiStore
- Show seconds in all timestamp displays
- Add i18n keys for all 3 locales (en, vi, zh)
This commit is contained in:
viettranx committed 2026-03-16 14:01:24 +07:00
1 parent 0dc3124607
commit 0d9906d40e
8 files changed
+129 -45

No files matched your search

+2 -2
View File
@@ -83,10 +83,10 @@ func NewCollector(ts store.TracingStore) *Collector {
// Verbose returns true if verbose tracing is enabled (full LLM input logging).
func (c *Collector) Verbose() bool { return c.verbose }
// PreviewMaxLen returns the max preview length: 100K when verbose, 500 otherwise.
// PreviewMaxLen returns the max preview length: 200K when verbose, 500 otherwise.
func (c *Collector) PreviewMaxLen() int {
if c.verbose {
return 100_000
return 200_000
}
return previewMaxLen
}
+6
View File
@@ -29,8 +29,10 @@
"spans": "Spans:",
"started": "Started:",
"delegatedFrom": "Delegated from:",
"createdAt": "Created:",
"input": "Input",
"output": "Output",
"copy": "Copy",
"spansCount": "Spans ({{count}})",
"llmCalls": "LLM",
"toolCalls": "tool",
@@ -39,8 +41,12 @@
"span": {
"model": "Model:",
"tokens": "Tokens:",
"createdAt": "Created:",
"input": "Input:",
"output": "Output:",
"copy": "Copy",
"startTime": "Start:",
"endTime": "End:",
"cacheRead": "read",
"cacheWrite": "write",
"thinking": "thinking",
+6
View File
@@ -29,8 +29,10 @@
"spans": "Khoảng:",
"started": "Bắt đầu:",
"delegatedFrom": "Ủy quyền từ:",
"createdAt": "Tạo lúc:",
"input": "Đầu vào",
"output": "Đầu ra",
"copy": "Sao chép",
"spansCount": "Khoảng ({{count}})",
"llmCalls": "LLM",
"toolCalls": "công cụ",
@@ -39,8 +41,12 @@
"span": {
"model": "Model:",
"tokens": "Token:",
"createdAt": "Tạo lúc:",
"input": "Đầu vào:",
"output": "Đầu ra:",
"copy": "Sao chép",
"startTime": "Bắt đầu:",
"endTime": "Kết thúc:",
"cacheRead": "đọc",
"cacheWrite": "ghi",
"thinking": "suy nghĩ",
+5
View File
@@ -11,7 +11,9 @@
"description": "LLM 调用追踪和性能数据",
"detail": {
"channel": "Channel:",
"copy": "复制",
"copyTraceId": "复制追踪 ID",
"createdAt": "创建时间:",
"delegatedFrom": "委托来自:",
"duration": "耗时:",
"input": "输入",
@@ -38,6 +40,9 @@
"cacheRead": "读取",
"cacheWrite": "写入",
"cached": "已缓存",
"copy": "复制",
"startTime": "开始:",
"endTime": "结束:",
"input": "输入:",
"model": "模型:",
"output": "输出:",
+6 -3
View File
@@ -1,11 +1,14 @@
export function formatDate(date: string | Date): string {
export function formatDate(date: string | Date, tz?: string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
const opts: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
second: "2-digit",
};
if (tz) opts.timeZone = resolveTimezone(tz);
return d.toLocaleDateString("en-US", opts);
}
export function formatRelativeTime(date: string | Date): string {
+100 -39
View File
@@ -7,15 +7,20 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ChevronRight, ChevronDown, Copy, Check, Download } from "lucide-react";
import { ChevronRight, ChevronDown, Copy, Check, Download, CircleCheck, CircleX, Loader, CircleMinus } from "lucide-react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useHttp } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Events } from "@/api/protocol";
import hljs from "highlight.js/lib/core";
import json from "highlight.js/lib/languages/json";
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
import { useUiStore } from "@/stores/use-ui-store";
import type { TraceData, SpanData } from "./hooks/use-traces";
import type { AgentEventPayload } from "@/types/chat";
hljs.registerLanguage("json", json);
interface SpanNode {
span: SpanData;
children: SpanNode[];
@@ -52,6 +57,7 @@ interface TraceDetailDialogProps {
export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace }: TraceDetailDialogProps) {
const { t } = useTranslation("traces");
const tz = useUiStore((s) => s.timezone);
const http = useHttp();
const [trace, setTrace] = useState<TraceData | null>(null);
const [spans, setSpans] = useState<SpanData[]>([]);
@@ -200,7 +206,11 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
</div>
<div>
<span className="text-muted-foreground">{t("detail.started")}</span>{" "}
{formatDate(trace.start_time)}
{formatDate(trace.start_time, tz)}
</div>
<div>
<span className="text-muted-foreground">{t("detail.createdAt")}</span>{" "}
{formatDate(trace.created_at, tz)}
</div>
{trace.parent_trace_id && (
<div>
@@ -217,17 +227,11 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
</div>
{trace.input_preview && (
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("detail.input")}</p>
<pre className="max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-sm">{trace.input_preview}</pre>
</div>
<PreviewBlock label={t("detail.input")} content={trace.input_preview} />
)}
{trace.output_preview && (
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("detail.output")}</p>
<pre className="max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-sm">{trace.output_preview}</pre>
</div>
<PreviewBlock label={t("detail.output")} content={trace.output_preview} />
)}
{trace.error && (
@@ -256,6 +260,7 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
const { t } = useTranslation("traces");
const tz = useUiStore((s) => s.timezone);
const [expanded, setExpanded] = useState(depth === 0);
const [detailOpen, setDetailOpen] = useState(false);
const { span, children } = node;
@@ -312,9 +317,14 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
)}
</span>
)}
<span className="shrink-0 text-xs text-muted-foreground">
{span.created_at && (
<Badge variant="outline" className="hidden shrink-0 text-xs text-muted-foreground lg:inline-flex">
{formatDate(span.created_at, tz)}
</Badge>
)}
<Badge variant="outline" className="shrink-0 text-xs text-muted-foreground">
{formatDuration(span.duration_ms || computeDurationMs(span.start_time, span.end_time))}
</span>
</Badge>
<StatusBadge status={span.status} />
</button>
</div>
@@ -322,11 +332,23 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
{/* Expanded detail panel */}
{detailOpen && (
<div className="max-h-[50vh] space-y-2 overflow-y-auto border-t px-3 py-2">
{span.model && (
<div className="text-xs">
<span className="text-muted-foreground">{t("span.model")}</span> {span.provider}/{span.model}
</div>
)}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
{span.start_time && (
<div>
<span className="text-muted-foreground">{t("span.startTime")}</span> {formatDate(span.start_time, tz)}
</div>
)}
{span.end_time && (
<div>
<span className="text-muted-foreground">{t("span.endTime")}</span> {formatDate(span.end_time, tz)}
</div>
)}
{span.model && (
<div>
<span className="text-muted-foreground">{t("span.model")}</span> {span.provider}/{span.model}
</div>
)}
</div>
{(span.input_tokens > 0 || span.output_tokens > 0) && (
<div className="text-xs">
<span className="text-muted-foreground">{t("span.tokens")}</span>{" "}
@@ -351,20 +373,10 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
</div>
)}
{span.input_preview && (
<div>
<p className="text-xs text-muted-foreground">{t("span.input")}</p>
<pre className="mt-1 max-h-[20vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
{span.input_preview}
</pre>
</div>
<PreviewBlock label={t("span.input")} content={span.input_preview} />
)}
{span.output_preview && (
<div>
<p className="text-xs text-muted-foreground">{t("span.output")}</p>
<pre className="mt-1 max-h-[20vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
{span.output_preview}
</pre>
</div>
<PreviewBlock label={t("span.output")} content={span.output_preview} />
)}
{span.error && (
<p className="break-all text-xs text-red-300">{span.error}</p>
@@ -381,15 +393,64 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
);
}
/** Try to pretty-print JSON; returns { text, isJson }. */
function formatPreview(text: string): { text: string; isJson: boolean } {
const trimmed = text.trim();
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
try {
const pretty = JSON.stringify(JSON.parse(trimmed), null, 2);
return { text: pretty, isJson: true };
} catch {
// not valid JSON
}
}
return { text, isJson: false };
}
function PreviewBlock({ label, content }: { label: string; content: string }) {
const { t } = useTranslation("traces");
const { copied, copy } = useClipboard();
const { text: formatted, isJson } = useMemo(() => formatPreview(content), [content]);
const highlightedHtml = useMemo(() => {
if (!isJson) return null;
return hljs.highlight(formatted, { language: "json" }).value;
}, [formatted, isJson]);
return (
<div className="relative rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{label}</p>
<button
type="button"
onClick={() => copy(content)}
className="absolute right-2 top-2 flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
{t("detail.copy")}
</button>
{highlightedHtml ? (
<pre
className="hljs mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]"
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
) : (
<pre className="mt-1 max-h-[20vh] overflow-y-auto whitespace-pre-wrap break-all text-xs sm:max-h-[40vh]">{formatted}</pre>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const variant =
status === "ok" || status === "success" || status === "completed"
? "success"
: status === "error" || status === "failed"
? "destructive"
: status === "running" || status === "pending"
? "info"
: "secondary";
return <Badge variant={variant} className="text-xs">{status || "unknown"}</Badge>;
const isOk = status === "ok" || status === "success" || status === "completed";
const isError = status === "error" || status === "failed";
const isRunning = status === "running" || status === "pending";
const variant = isOk ? "success" : isError ? "destructive" : isRunning ? "info" : "secondary";
const Icon = isOk ? CircleCheck : isError ? CircleX : isRunning ? Loader : CircleMinus;
return (
<Badge variant={variant} className="text-xs">
<Icon className={"h-3 w-3 sm:hidden" + (isRunning ? " animate-spin" : "")} />
<span className="hidden sm:inline">{status || "unknown"}</span>
</Badge>
);
}
+3 -1
View File
@@ -19,12 +19,14 @@ import { useTraces, type TraceData } from "./hooks/use-traces";
import { TraceDetailDialog } from "./trace-detail-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useUiStore } from "@/stores/use-ui-store";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
export function TracesPage() {
const { t } = useTranslation("traces");
const { t: tc } = useTranslation("common");
const tz = useUiStore((s) => s.timezone);
const [agentFilter, setAgentFilter] = useState<string>();
const [channelFilter, setChannelFilter] = useState<string>();
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
@@ -149,7 +151,7 @@ export function TracesPage() {
{trace.span_count}
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDate(trace.start_time)}
{formatDate(trace.start_time, tz)}
</td>
</tr>
))}
+1
View File
@@ -48,4 +48,5 @@ export interface SpanData {
input_preview: string;
output_preview: string;
metadata?: { cache_creation_tokens?: number; cache_read_tokens?: number; thinking_tokens?: number };
created_at: string;
}