From 0d9906d40e3614458fd9176f88fc337347a9114c Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 16 Mar 2026 14:01:24 +0700 Subject: [PATCH] feat(traces): improve trace/span UI with timestamps, copy, syntax highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/tracing/collector.go | 4 +- ui/web/src/i18n/locales/en/traces.json | 6 + ui/web/src/i18n/locales/vi/traces.json | 6 + ui/web/src/i18n/locales/zh/traces.json | 5 + ui/web/src/lib/format.ts | 9 +- .../src/pages/traces/trace-detail-dialog.tsx | 139 +++++++++++++----- ui/web/src/pages/traces/traces-page.tsx | 4 +- ui/web/src/types/trace.ts | 1 + 8 files changed, 129 insertions(+), 45 deletions(-) diff --git a/internal/tracing/collector.go b/internal/tracing/collector.go index 9d66624e..184843e1 100644 --- a/internal/tracing/collector.go +++ b/internal/tracing/collector.go @@ -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 } diff --git a/ui/web/src/i18n/locales/en/traces.json b/ui/web/src/i18n/locales/en/traces.json index 6db86b81..56014d0f 100644 --- a/ui/web/src/i18n/locales/en/traces.json +++ b/ui/web/src/i18n/locales/en/traces.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/traces.json b/ui/web/src/i18n/locales/vi/traces.json index b843fc56..3ed43aea 100644 --- a/ui/web/src/i18n/locales/vi/traces.json +++ b/ui/web/src/i18n/locales/vi/traces.json @@ -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ĩ", diff --git a/ui/web/src/i18n/locales/zh/traces.json b/ui/web/src/i18n/locales/zh/traces.json index a9635f26..e13751b5 100644 --- a/ui/web/src/i18n/locales/zh/traces.json +++ b/ui/web/src/i18n/locales/zh/traces.json @@ -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": "输出:", diff --git a/ui/web/src/lib/format.ts b/ui/web/src/lib/format.ts index 22f29813..b046e0da 100644 --- a/ui/web/src/lib/format.ts +++ b/ui/web/src/lib/format.ts @@ -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 { diff --git a/ui/web/src/pages/traces/trace-detail-dialog.tsx b/ui/web/src/pages/traces/trace-detail-dialog.tsx index ec3e860b..63633d3c 100644 --- a/ui/web/src/pages/traces/trace-detail-dialog.tsx +++ b/ui/web/src/pages/traces/trace-detail-dialog.tsx @@ -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(null); const [spans, setSpans] = useState([]); @@ -200,7 +206,11 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
{t("detail.started")}{" "} - {formatDate(trace.start_time)} + {formatDate(trace.start_time, tz)} +
+
+ {t("detail.createdAt")}{" "} + {formatDate(trace.created_at, tz)}
{trace.parent_trace_id && (
@@ -217,17 +227,11 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
{trace.input_preview && ( -
-

{t("detail.input")}

-
{trace.input_preview}
-
+ )} {trace.output_preview && ( -
-

{t("detail.output")}

-
{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.created_at && ( + + {formatDate(span.created_at, tz)} + + )} + {formatDuration(span.duration_ms || computeDurationMs(span.start_time, span.end_time))} - + @@ -322,11 +332,23 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) { {/* Expanded detail panel */} {detailOpen && (
- {span.model && ( -
- {t("span.model")} {span.provider}/{span.model} -
- )} +
+ {span.start_time && ( +
+ {t("span.startTime")} {formatDate(span.start_time, tz)} +
+ )} + {span.end_time && ( +
+ {t("span.endTime")} {formatDate(span.end_time, tz)} +
+ )} + {span.model && ( +
+ {t("span.model")} {span.provider}/{span.model} +
+ )} +
{(span.input_tokens > 0 || span.output_tokens > 0) && (
{t("span.tokens")}{" "} @@ -351,20 +373,10 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
)} {span.input_preview && ( -
-

{t("span.input")}

-
-                  {span.input_preview}
-                
-
+ )} {span.output_preview && ( -
-

{t("span.output")}

-
-                  {span.output_preview}
-                
-
+ )} {span.error && (

{span.error}

@@ -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 ( +
+

{label}

+ + {highlightedHtml ? ( +
+      ) : (
+        
{formatted}
+ )} +
+ ); +} + 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 {status || "unknown"}; + 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 ( + + + {status || "unknown"} + + ); } diff --git a/ui/web/src/pages/traces/traces-page.tsx b/ui/web/src/pages/traces/traces-page.tsx index 591b4189..5da4b130 100644 --- a/ui/web/src/pages/traces/traces-page.tsx +++ b/ui/web/src/pages/traces/traces-page.tsx @@ -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(); const [channelFilter, setChannelFilter] = useState(); const [selectedTraceId, setSelectedTraceId] = useState(null); @@ -149,7 +151,7 @@ export function TracesPage() { {trace.span_count} - {formatDate(trace.start_time)} + {formatDate(trace.start_time, tz)} ))} diff --git a/ui/web/src/types/trace.ts b/ui/web/src/types/trace.ts index c4018650..81650cb1 100644 --- a/ui/web/src/types/trace.ts +++ b/ui/web/src/types/trace.ts @@ -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; }