feat(trace): waterfall visualization + persona YAML sync
- trace.Logger: Trace+Span model with 14+ hop tracking - trace.SpanTimer: convenience API for instrumentation - trace_adapter.go: serialize Trace+Span for frontend - Frontend TracePage: colored waterfall bars, expand/collapse, detail popup - persona.go: full v1 YAML parser (identity/traits/forbidden/sections) - cyrene_persona.yaml: v1 persona config copied to v2 - Orchestrator: TraceHook + MemoryExtractor injection points
This commit is contained in:
1 parent
47047c903c
commit
4e2e81f684
8 files changed
+1178
-198
No files matched your search
@@ -1,74 +1,185 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { getToken } from '../../api/client'
|
||||
|
||||
// Hop color map — waterfall bar colors
|
||||
const hopColors: Record<string, string> = {
|
||||
msg_received: '#06b6d4', dispatch: '#8b5cf6', intent_analysis: '#f59e0b',
|
||||
intent_fast: '#fbbf24', persona_load: '#ec4899', subsession: '#10b981',
|
||||
memory_search: '#3b82f6', iot_query: '#06b6d4', context_build: '#6b7280',
|
||||
llm_request: '#6366f1', llm_first_token: '#818cf8', llm_stream: '#a5b4fc',
|
||||
review_parse: '#8b5cf6', ws_push: '#f97316', db_persist: '#22c55e',
|
||||
memory_extract: '#ec4899', background_think: '#14b8a6',
|
||||
}
|
||||
|
||||
const hopIcons: Record<string, string> = {
|
||||
msg_received: '📥', msg_sent: '📤',
|
||||
intent: '🎯', llm_call: '🧠', tool_call: '🔧',
|
||||
response: '💬', session: '👤', subsession: '⚡',
|
||||
msg_received: '📥', dispatch: '🚀', intent_analysis: '🎯', intent_fast: '⚡',
|
||||
persona_load: '👤', subsession: '⚙', memory_search: '🧠', iot_query: '⌂',
|
||||
context_build: '📦', llm_request: '🤖', llm_first_token: '💬', llm_stream: '▸',
|
||||
review_parse: '📝', ws_push: '📤', db_persist: '💾', memory_extract: '♻',
|
||||
background_think: '🔄',
|
||||
}
|
||||
|
||||
export default function TracePage() {
|
||||
const [events, setEvents] = useState<any[]>([])
|
||||
const [traces, setTraces] = useState<any[]>([])
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<any>(null)
|
||||
const intervalRef = useRef<number>()
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrace()
|
||||
const interval = setInterval(fetchTrace, 5000)
|
||||
return () => clearInterval(interval)
|
||||
fetchTraces()
|
||||
intervalRef.current = window.setInterval(fetchTraces, 3000)
|
||||
return () => clearInterval(intervalRef.current)
|
||||
}, [])
|
||||
|
||||
const fetchTrace = async () => {
|
||||
const fetchTraces = async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch('/api/v1/admin/trace', { headers: { Authorization: `Bearer ${token}` } })
|
||||
const data = await res.json()
|
||||
setEvents(data.events || [])
|
||||
setTraces(data.traces || [])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const next = new Set(expanded)
|
||||
if (next.has(id)) next.delete(id) else next.add(id)
|
||||
setExpanded(next)
|
||||
}
|
||||
|
||||
// Calculate global time scale
|
||||
const maxDuration = Math.max(100, ...traces.map((t: any) => {
|
||||
if (t.end_time && t.start_time) {
|
||||
return new Date(t.end_time).getTime() - new Date(t.start_time).getTime()
|
||||
}
|
||||
const maxSpan = Math.max(0, ...(t.spans || []).map((s: any) => (s.start_ms + s.duration)))
|
||||
return maxSpan
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">全链路追踪</h2>
|
||||
<p className="text-xs text-[var(--mx-text-dim)]">最近 {events.length} 条追踪事件</p>
|
||||
<div className="p-6 space-y-4 h-full flex flex-col" style={{ color: 'var(--mx-text)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">全链路追踪</h2>
|
||||
<button onClick={fetchTraces}
|
||||
className="px-3 py-1.5 rounded-full text-xs border"
|
||||
style={{ borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}
|
||||
>刷新</button>
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<div className="p-8 text-center text-[var(--mx-text-dim)] text-sm">暂无追踪数据 — 发送消息后刷新</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{events.map((ev, i) => (
|
||||
<div key={ev.id || i}
|
||||
className={`bg-[var(--mx-surface)] border rounded-[14px] px-4 py-3 flex items-center gap-3 ${
|
||||
ev.status === 'error' ? 'border-[var(--mx-error)]/30' : 'border-[var(--mx-outline)]'
|
||||
}`}
|
||||
>
|
||||
{/* Hop icon */}
|
||||
<span className="text-lg shrink-0">{hopIcons[ev.hop] || '●'}</span>
|
||||
{/* Waterfall charts */}
|
||||
<div className="flex-1 overflow-auto space-y-3">
|
||||
{traces.length === 0 ? (
|
||||
<div className="text-center py-12 text-sm" style={{ color: 'var(--mx-text-dim)' }}>
|
||||
暂无追踪数据 — 发送消息后自动记录
|
||||
</div>
|
||||
) : (
|
||||
traces.map((trace: any) => {
|
||||
const totalMs = trace.end_time
|
||||
? new Date(trace.end_time).getTime() - new Date(trace.start_time).getTime()
|
||||
: Date.now() - new Date(trace.start_time).getTime()
|
||||
const isRunning = trace.status === 'running'
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--mx-text)]">{ev.label || ev.hop}</span>
|
||||
{ev.duration_ms > 0 && (
|
||||
<span className="text-xs text-[var(--mx-text-dim)]">{ev.duration_ms}ms</span>
|
||||
)}
|
||||
return (
|
||||
<div key={trace.id}
|
||||
className="rounded-[18px] border overflow-hidden"
|
||||
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 cursor-pointer"
|
||||
onClick={() => toggleExpand(trace.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{isRunning ? (
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse shrink-0" />
|
||||
) : trace.status === 'error' ? (
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{background:'var(--mx-error)'}} />
|
||||
) : (
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{background:'var(--mx-tertiary-text)'}} />
|
||||
)}
|
||||
<span className="text-sm truncate">{trace.user_msg || '(无消息)'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs shrink-0" style={{ color: 'var(--mx-text-dim)' }}>
|
||||
<span>{totalMs}ms</span>
|
||||
<span>{trace.spans?.length || 0} 环节</span>
|
||||
<span>{expanded.has(trace.id) ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{ev.detail && (
|
||||
<p className="text-xs text-[var(--mx-text-dim)] truncate mt-0.5">{ev.detail}</p>
|
||||
|
||||
{/* Expanded: waterfall view */}
|
||||
{expanded.has(trace.id) && (
|
||||
<div className="px-4 pb-4 space-y-1">
|
||||
{/* Time scale ruler */}
|
||||
<div className="flex justify-between text-xs px-1" style={{ color: 'var(--mx-text-dim)' }}>
|
||||
<span>0ms</span>
|
||||
<span>{Math.round(maxDuration/2)}ms</span>
|
||||
<span>{Math.round(maxDuration)}ms</span>
|
||||
</div>
|
||||
|
||||
{/* Span bars */}
|
||||
{(trace.spans || []).map((span: any, i: number) => {
|
||||
const pct = maxDuration > 0 ? (span.duration / maxDuration) * 100 : 0
|
||||
const leftPct = maxDuration > 0 ? (span.start_ms / maxDuration) * 100 : 0
|
||||
const color = hopColors[span.name] || '#6b7280'
|
||||
|
||||
return (
|
||||
<div key={i}
|
||||
className="flex items-center gap-2 group cursor-pointer hover:opacity-90"
|
||||
onClick={() => setSelected({...span, trace_id: trace.id, trace_msg: trace.user_msg})}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="w-24 shrink-0 text-right">
|
||||
<span className="text-xs">{hopIcons[span.name] || '●'} {span.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex-1 relative h-5">
|
||||
<div className="absolute top-0 h-4 rounded-full opacity-20"
|
||||
style={{ left: `${leftPct}%`, width: `${Math.max(pct, 1)}%`, background: color }} />
|
||||
<div className="absolute top-0 h-4 rounded-full"
|
||||
style={{ left: `${leftPct}%`, width: `${Math.max(pct, 1)}%`, background: color }} />
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="w-14 shrink-0 text-right text-xs" style={{ color: 'var(--mx-text-dim)' }}>
|
||||
{span.duration}ms
|
||||
</div>
|
||||
|
||||
{/* Status dot */}
|
||||
<div className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ background: span.status === 'error' ? 'var(--mx-error)' : span.status === 'running' ? '#fbbf24' : color }} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className={`text-xs font-mono text-[var(--mx-text-dim)]`}>
|
||||
{ev.timestamp ? ev.timestamp.slice(11, 19) : ''}
|
||||
</span>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
ev.status === 'success' ? 'bg-[#4788FF]' :
|
||||
ev.status === 'error' ? 'bg-[#F12522]' :
|
||||
ev.status === 'pending' ? 'bg-yellow-500' : 'bg-[#404040]'
|
||||
}`} />
|
||||
</div>
|
||||
{/* Detail popup */}
|
||||
{selected && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelected(null)}>
|
||||
<div className="rounded-[18px] border p-6 max-w-md w-full mx-4 space-y-3"
|
||||
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{hopIcons[selected.name]} {selected.label}</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span style={{color:'var(--mx-text-dim)'}}>耗时</span><p>{selected.duration}ms</p></div>
|
||||
<div><span style={{color:'var(--mx-text-dim)'}}>偏移</span><p>{selected.start_ms}ms</p></div>
|
||||
<div><span style={{color:'var(--mx-text-dim)'}}>状态</span><p>{selected.status}</p></div>
|
||||
<div><span style={{color:'var(--mx-text-dim)'}}>Hop</span><p>{selected.name}</p></div>
|
||||
</div>
|
||||
))}
|
||||
{selected.detail && (
|
||||
<div className="rounded-[14px] border p-3 text-xs font-mono whitespace-pre-wrap max-h-40 overflow-auto"
|
||||
style={{ background: 'var(--mx-bg)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}>
|
||||
{selected.detail}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setSelected(null)}
|
||||
className="w-full py-2 rounded-full text-sm text-white" style={{background:'var(--mx-primary)'}}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user