mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 18:14:13 +00:00
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:
1 parent
0dc3124607
commit
0d9906d40e
8 files changed
+129
-45
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ĩ",
|
||||
|
||||
@@ -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": "输出:",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user