import React, { useState, useEffect, useCallback } from 'react'; import { useTheme } from '../contexts/ThemeContext'; import { Activity, AlertTriangle, Clock, Filter, RefreshCw, ChevronLeft, ChevronRight, X, Link2, ArrowRight, Trash2, } from 'lucide-react'; // ------------------------------------------------------------------ // // Types // // ------------------------------------------------------------------ // interface CallTrace { id: string; trace_id?: string; task_id?: string; parent_call_id?: string; call_type: 'model' | 'mcp' | 'skill'; status: 'success' | 'failed' | 'cancelled' | 'timeout'; started_at: number; ended_at?: number; duration_ms: number; name: string; action: string; mcp_key?: string; mcp_name?: string; skill_key?: string; skill_name?: string; model?: string; input_summary?: string; output_summary?: string; error_id?: string; error_message?: string; meta?: Record; } interface ErrorLog { id: string; trace_id?: string; task_id?: string; call_id?: string; source: string; level: 'warning' | 'error'; happened_at: number; message: string; recoverable: boolean; mcp_key?: string; mcp_name?: string; skill_key?: string; skill_name?: string; model?: string; action?: string; input_summary?: string; params_summary?: Record; } interface PagedResponse { items: T[]; total: number; page: number; page_size: number; } interface Options { models: string[]; mcps: { key: string; name: string }[]; skills: { key: string; name: string }[]; statuses: string[]; sources: string[]; } // ------------------------------------------------------------------ // // Helpers // // ------------------------------------------------------------------ // function formatTs(ms: number): string { return new Date(ms).toLocaleString('zh-CN', { hour12: false }); } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(2)}s`; } const STATUS_COLORS: Record = { success: 'bg-green-500/20 text-green-400', failed: 'bg-red-500/20 text-red-400', timeout: 'bg-yellow-500/20 text-yellow-400', cancelled: 'bg-slate-500/20 text-slate-400', warning: 'bg-yellow-500/20 text-yellow-400', error: 'bg-red-500/20 text-red-400', }; const TYPE_COLORS: Record = { model: 'bg-blue-500/20 text-blue-400', mcp: 'bg-purple-500/20 text-purple-400', skill: 'bg-cyan-500/20 text-cyan-400', }; const SOURCE_COLORS: Record = { model: 'bg-blue-500/20 text-blue-400', llm: 'bg-blue-500/20 text-blue-400', mcp: 'bg-purple-500/20 text-purple-400', skill: 'bg-cyan-500/20 text-cyan-400', orchestrator: 'bg-orange-500/20 text-orange-400', network: 'bg-pink-500/20 text-pink-400', }; const backUrl = 'https://ch1.b.uat.4.cn/zncmback' function Badge({ label, colorClass }: { label: string; colorClass: string }) { return ( {label} ); } // ------------------------------------------------------------------ // // API // // ------------------------------------------------------------------ // async function fetchOptions(): Promise { const res = await fetch(`${backUrl}/api/observability/options`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } async function fetchCalls(params: Record): Promise> { const qs = new URLSearchParams(); Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') qs.append(k, String(v)); }); const res = await fetch(`${backUrl}/api/observability/calls?${qs}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } async function fetchErrors(params: Record): Promise> { const qs = new URLSearchParams(); Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') qs.append(k, String(v)); }); const res = await fetch(`${backUrl}/api/observability/errors?${qs}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } // ------------------------------------------------------------------ // // Shared UI atoms // // ------------------------------------------------------------------ // function Select({ value, onChange, options, placeholder, cls, }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[]; placeholder?: string; cls?: string; }) { return ( ); } function Pagination({ page, pageSize, total, onPage, isDark }: { page: number; pageSize: number; total: number; onPage: (p: number) => void; isDark: boolean; }) { const totalPages = Math.max(1, Math.ceil(total / pageSize)); const textMuted = isDark ? 'text-slate-400' : 'text-gray-500'; const btn = `p-1.5 rounded-lg transition-all disabled:opacity-40 ${isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-100'}`; return (
共 {total} 条
{page} / {totalPages}
); } // ------------------------------------------------------------------ // // Linked items — shown inside detail modals // // ------------------------------------------------------------------ // function LinkedErrors({ callId, traceId, isDark }: { callId: string; traceId?: string; isDark: boolean }) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { fetchErrors({ call_id: callId, page: 1, page_size: 10 }) .then((r) => setItems(r.items)) .catch(() => setItems([])) .finally(() => setLoading(false)); }, [callId]); const label = isDark ? 'text-slate-400' : 'text-gray-500'; const rowBg = isDark ? 'bg-red-900/20 border-red-800/30' : 'bg-red-50 border-red-200'; if (loading) return

加载相关错误…

; if (items.length === 0) return

无相关错误日志

; return (
{items.map((e) => (
{formatTs(e.happened_at)}

{e.message}

))}
); } function LinkedCall({ callId, isDark }: { callId: string; isDark: boolean }) { const [item, setItem] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { // call_id in error = the id of the CallTrace record fetchCalls({ trace_id: callId, page: 1, page_size: 1 }) .then((r) => setItem(r.items[0] ?? null)) .catch(() => setItem(null)) .finally(() => setLoading(false)); }, [callId]); const label = isDark ? 'text-slate-400' : 'text-gray-500'; const rowBg = isDark ? 'bg-blue-900/20 border-blue-800/30' : 'bg-blue-50 border-blue-200'; if (loading) return

加载关联调用…

; if (!item) return

未找到关联调用记录(ID: {callId})

; return (
{formatDuration(item.duration_ms)} {formatTs(item.started_at)}

{item.name}

{item.action}

); } // ------------------------------------------------------------------ // // Detail Modals // // ------------------------------------------------------------------ // function Section({ title, isDark, children }: { title: string; isDark: boolean; children: React.ReactNode }) { const divider = isDark ? 'border-blue-800/20' : 'border-gray-200'; const label = isDark ? 'text-slate-400' : 'text-gray-500'; return (
{title}
{children}
); } function FieldRow({ label, value, isDark }: { label: string; value?: string | null; isDark: boolean }) { if (!value) return null; const rowBg = isDark ? 'bg-slate-800/40' : 'bg-gray-50'; const textPrimary = isDark ? 'text-white' : 'text-gray-900'; const textSecondary = isDark ? 'text-slate-400' : 'text-gray-500'; return (
{label}

{value}

); } function CallDetailModal({ item, onClose, isDark }: { item: CallTrace; onClose: () => void; isDark: boolean }) { const bg = isDark ? 'bg-slate-900 border-blue-800/30' : 'bg-white border-gray-200'; const textPrimary = isDark ? 'text-white' : 'text-gray-900'; return (
e.stopPropagation()}>

调用详情

); } function ErrorDetailModal({ item, onClose, isDark }: { item: ErrorLog; onClose: () => void; isDark: boolean }) { const bg = isDark ? 'bg-slate-900 border-blue-800/30' : 'bg-white border-gray-200'; const textPrimary = isDark ? 'text-white' : 'text-gray-900'; return (
e.stopPropagation()}>

错误详情

{item.call_id && (
)}
); } // ------------------------------------------------------------------ // // Task ID pill — clickable, stops row-click propagation // // ------------------------------------------------------------------ // function TaskIdPill({ taskId, onFilter, isDark, }: { taskId?: string | null; onFilter: (id: string) => void; isDark: boolean }) { if (!taskId) return -; return ( ); } // ------------------------------------------------------------------ // // 调用耗时统计 Tab // // ------------------------------------------------------------------ // interface CallsTabProps { opts: Options; isDark: boolean; externalTaskId: string; onSwitchToErrors: (taskId: string) => void; } function CallsTab({ opts, isDark, externalTaskId, onSwitchToErrors }: CallsTabProps) { const [filters, setFilters] = useState({ call_type: '', model: '', mcp_key: '', skill_key: '', status: '', task_id: externalTaskId, sort_by: 'started_at', sort_order: 'desc', }); const [page, setPage] = useState(1); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [fetchError, setFetchError] = useState(''); const [selected, setSelected] = useState(null); const PAGE_SIZE = 20; // sync external task_id changes useEffect(() => { setFilters((prev) => ({ ...prev, task_id: externalTaskId })); }, [externalTaskId]); const load = useCallback(async (p: number, f = filters) => { setLoading(true); setFetchError(''); try { const result = await fetchCalls({ ...f, page: p, page_size: PAGE_SIZE }); setData(result); setPage(p); } catch (e) { setFetchError(String(e)); } finally { setLoading(false); } }, [filters]); useEffect(() => { load(1); }, [filters]); const setF = (key: string, val: string) => setFilters((prev) => ({ ...prev, [key]: val })); const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white'; const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-200'; const textPrimary = isDark ? 'text-white' : 'text-gray-900'; const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600'; const selectCls = isDark ? 'bg-slate-900/60 border-blue-800/40 text-slate-200' : 'bg-white border-gray-300 text-gray-700'; const inputCls = isDark ? 'bg-slate-900/60 border-blue-800/40 text-slate-200 placeholder-slate-500' : 'bg-white border-gray-300 text-gray-700 placeholder-gray-400'; const trHover = isDark ? 'hover:bg-slate-700/30' : 'hover:bg-gray-50'; const thCls = `text-left text-xs font-medium uppercase tracking-wide py-2.5 px-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`; const tdCls = `py-2.5 px-3 text-sm ${textPrimary}`; const divider = isDark ? 'border-blue-800/20' : 'border-gray-200'; return (
筛选条件 {filters.task_id && ( Task: {filters.task_id} )}
setF('model', v)} placeholder="全部模型" cls={selectCls} options={opts.models.map((m) => ({ value: m, label: m }))} /> setF('skill_key', v)} placeholder="全部Skill" cls={selectCls} options={opts.skills.map((s) => ({ value: s.key, label: s.name || s.key }))} /> setF('task_id', e.target.value)} placeholder="Task ID" className={`text-sm rounded-lg border px-3 py-1.5 outline-none w-36 ${inputCls}`} /> setF('sort_order', v)} cls={selectCls} options={[{ value: 'desc', label: '降序' }, { value: 'asc', label: '升序' }]} />
{fetchError &&
{fetchError}
}
{loading && !data && } {!loading && data?.items.length === 0 && } {data?.items.map((row) => ( setSelected(row)}> ))}
时间 类型 名称 操作 耗时 状态 Task ID 输入摘要 输出摘要
加载中…
暂无数据
{formatTs(row.started_at)} {row.name} {row.action} {formatDuration(row.duration_ms)}
setF('task_id', id)} isDark={isDark} /> {row.task_id && ( )}
{row.input_summary ?? '-'} {row.output_summary ?? '-'}
{data && (
load(p)} isDark={isDark} />
)}
{selected && setSelected(null)} isDark={isDark} />}
); } // ------------------------------------------------------------------ // // 报错日志 Tab // // ------------------------------------------------------------------ // interface ErrorsTabProps { opts: Options; isDark: boolean; externalTaskId: string; onSwitchToCalls: (taskId: string) => void; } function ErrorsTab({ opts, isDark, externalTaskId, onSwitchToCalls }: ErrorsTabProps) { const [filters, setFilters] = useState({ source: '', model: '', mcp_key: '', skill_key: '', level: '', task_id: externalTaskId, sort_order: 'desc', }); const [page, setPage] = useState(1); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [fetchError, setFetchError] = useState(''); const [selected, setSelected] = useState(null); const PAGE_SIZE = 20; useEffect(() => { setFilters((prev) => ({ ...prev, task_id: externalTaskId })); }, [externalTaskId]); const load = useCallback(async (p: number, f = filters) => { setLoading(true); setFetchError(''); try { const result = await fetchErrors({ ...f, page: p, page_size: PAGE_SIZE }); setData(result); setPage(p); } catch (e) { setFetchError(String(e)); } finally { setLoading(false); } }, [filters]); useEffect(() => { load(1); }, [filters]); const setF = (key: string, val: string) => setFilters((prev) => ({ ...prev, [key]: val })); const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white'; const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-200'; const textPrimary = isDark ? 'text-white' : 'text-gray-900'; const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600'; const selectCls = isDark ? 'bg-slate-900/60 border-blue-800/40 text-slate-200' : 'bg-white border-gray-300 text-gray-700'; const inputCls = isDark ? 'bg-slate-900/60 border-blue-800/40 text-slate-200 placeholder-slate-500' : 'bg-white border-gray-300 text-gray-700 placeholder-gray-400'; const trHover = isDark ? 'hover:bg-slate-700/30' : 'hover:bg-gray-50'; const thCls = `text-left text-xs font-medium uppercase tracking-wide py-2.5 px-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`; const tdCls = `py-2.5 px-3 text-sm ${textPrimary}`; const divider = isDark ? 'border-blue-800/20' : 'border-gray-200'; return (
筛选条件 {filters.task_id && ( Task: {filters.task_id} )}
setF('level', v)} placeholder="全部级别" cls={selectCls} options={[{ value: 'warning', label: '警告' }, { value: 'error', label: '错误' }]} /> setF('mcp_key', v)} placeholder="全部MCP" cls={selectCls} options={opts.mcps.map((m) => ({ value: m.key, label: m.name || m.key }))} /> setF('task_id', e.target.value)} placeholder="Task ID" className={`text-sm rounded-lg border px-3 py-1.5 outline-none w-36 ${inputCls}`} />