代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-27 19:43:15 +08:00
1 parent 8f5af46ec2
commit 8f75e12871
168 files changed
+76575

No files matched your search

+810
View File
@@ -0,0 +1,810 @@
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<string, unknown>;
}
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<string, unknown>;
}
interface PagedResponse<T> {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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 (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{label}
</span>
);
}
// ------------------------------------------------------------------ //
// API //
// ------------------------------------------------------------------ //
async function fetchOptions(): Promise<Options> {
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<string, string | number | undefined>): Promise<PagedResponse<CallTrace>> {
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<string, string | number | undefined>): Promise<PagedResponse<ErrorLog>> {
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 (
<select value={value} onChange={(e) => onChange(e.target.value)}
className={`text-sm rounded-lg border px-3 py-1.5 outline-none ${cls ?? ''}`}>
{placeholder && <option value="">{placeholder}</option>}
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
);
}
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 (
<div className={`flex items-center gap-3 text-sm ${textMuted}`}>
<span>共 {total} 条</span>
<div className="flex items-center gap-1">
<button className={btn} disabled={page <= 1} onClick={() => onPage(page - 1)}><ChevronLeft className="w-4 h-4" /></button>
<span className="px-2">{page} / {totalPages}</span>
<button className={btn} disabled={page >= totalPages} onClick={() => onPage(page + 1)}><ChevronRight className="w-4 h-4" /></button>
</div>
</div>
);
}
// ------------------------------------------------------------------ //
// Linked items — shown inside detail modals //
// ------------------------------------------------------------------ //
function LinkedErrors({ callId, traceId, isDark }: { callId: string; traceId?: string; isDark: boolean }) {
const [items, setItems] = useState<ErrorLog[]>([]);
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 <p className={`text-xs ${label}`}>加载相关错误…</p>;
if (items.length === 0) return <p className={`text-xs ${label}`}>无相关错误日志</p>;
return (
<div className="space-y-1.5">
{items.map((e) => (
<div key={e.id} className={`rounded-lg border p-2.5 ${rowBg}`}>
<div className="flex items-center gap-2 flex-wrap">
<Badge label={e.level} colorClass={STATUS_COLORS[e.level] ?? ''} />
<Badge label={e.source} colorClass={SOURCE_COLORS[e.source] ?? ''} />
<span className={`text-xs ${label}`}>{formatTs(e.happened_at)}</span>
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-slate-200' : 'text-gray-800'}`}>{e.message}</p>
</div>
))}
</div>
);
}
function LinkedCall({ callId, isDark }: { callId: string; isDark: boolean }) {
const [item, setItem] = useState<CallTrace | null>(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 <p className={`text-xs ${label}`}>加载关联调用…</p>;
if (!item) return <p className={`text-xs ${label}`}>未找到关联调用记录(ID: {callId})</p>;
return (
<div className={`rounded-lg border p-2.5 ${rowBg}`}>
<div className="flex items-center gap-2 flex-wrap">
<Badge label={item.call_type} colorClass={TYPE_COLORS[item.call_type] ?? ''} />
<Badge label={item.status} colorClass={STATUS_COLORS[item.status] ?? ''} />
<span className={`text-xs font-mono ${label}`}>{formatDuration(item.duration_ms)}</span>
<span className={`text-xs ${label}`}>{formatTs(item.started_at)}</span>
</div>
<p className={`text-sm mt-1 font-medium ${isDark ? 'text-slate-200' : 'text-gray-800'}`}>{item.name}</p>
<p className={`text-xs mt-0.5 ${label}`}>{item.action}</p>
</div>
);
}
// ------------------------------------------------------------------ //
// 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 (
<div className={`border-t pt-4 mt-4 ${divider}`}>
<div className={`flex items-center gap-1.5 mb-2 text-xs font-semibold uppercase tracking-wide ${label}`}>
<Link2 className="w-3.5 h-3.5" />
{title}
</div>
{children}
</div>
);
}
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 (
<div className={`rounded-lg p-2.5 ${rowBg}`}>
<span className={`text-xs font-medium ${textSecondary}`}>{label}</span>
<p className={`text-sm mt-0.5 break-all whitespace-pre-wrap ${textPrimary}`}>{value}</p>
</div>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className={`relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl border shadow-2xl p-6 ${bg}`}
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className={`font-semibold text-base ${textPrimary}`}>调用详情</h3>
<button onClick={onClose} className={`p-1.5 rounded-lg ${isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100'}`}>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-1.5">
<FieldRow label="ID" value={item.id} isDark={isDark} />
<FieldRow label="Trace ID" value={item.trace_id} isDark={isDark} />
<FieldRow label="Task ID" value={item.task_id} isDark={isDark} />
<FieldRow label="Parent Call ID" value={item.parent_call_id} isDark={isDark} />
<FieldRow label="类型" value={item.call_type} isDark={isDark} />
<FieldRow label="状态" value={item.status} isDark={isDark} />
<FieldRow label="名称" value={item.name} isDark={isDark} />
<FieldRow label="操作" value={item.action} isDark={isDark} />
<FieldRow label="开始时间" value={formatTs(item.started_at)} isDark={isDark} />
<FieldRow label="结束时间" value={item.ended_at ? formatTs(item.ended_at) : undefined} isDark={isDark} />
<FieldRow label="耗时" value={formatDuration(item.duration_ms)} isDark={isDark} />
<FieldRow label="模型" value={item.model} isDark={isDark} />
<FieldRow label="MCP Key" value={item.mcp_key} isDark={isDark} />
<FieldRow label="MCP 名称" value={item.mcp_name} isDark={isDark} />
<FieldRow label="Skill Key" value={item.skill_key} isDark={isDark} />
<FieldRow label="Skill 名称" value={item.skill_name} isDark={isDark} />
<FieldRow label="输入摘要" value={item.input_summary} isDark={isDark} />
<FieldRow label="输出摘要" value={item.output_summary} isDark={isDark} />
<FieldRow label="错误信息" value={item.error_message} isDark={isDark} />
<FieldRow label="Meta" value={item.meta && Object.keys(item.meta).length ? JSON.stringify(item.meta, null, 2) : undefined} isDark={isDark} />
</div>
<Section title="相关错误日志" isDark={isDark}>
<LinkedErrors callId={item.id} traceId={item.trace_id} isDark={isDark} />
</Section>
</div>
</div>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className={`relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl border shadow-2xl p-6 ${bg}`}
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className={`font-semibold text-base ${textPrimary}`}>错误详情</h3>
<button onClick={onClose} className={`p-1.5 rounded-lg ${isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100'}`}>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-1.5">
<FieldRow label="ID" value={item.id} isDark={isDark} />
<FieldRow label="Trace ID" value={item.trace_id} isDark={isDark} />
<FieldRow label="Task ID" value={item.task_id} isDark={isDark} />
<FieldRow label="Call ID" value={item.call_id} isDark={isDark} />
<FieldRow label="来源" value={item.source} isDark={isDark} />
<FieldRow label="级别" value={item.level} isDark={isDark} />
<FieldRow label="发生时间" value={formatTs(item.happened_at)} isDark={isDark} />
<FieldRow label="可恢复" value={item.recoverable ? '是' : '否'} isDark={isDark} />
<FieldRow label="消息" value={item.message} isDark={isDark} />
<FieldRow label="MCP Key" value={item.mcp_key} isDark={isDark} />
<FieldRow label="MCP 名称" value={item.mcp_name} isDark={isDark} />
<FieldRow label="Skill Key" value={item.skill_key} isDark={isDark} />
<FieldRow label="Skill 名称" value={item.skill_name} isDark={isDark} />
<FieldRow label="模型" value={item.model} isDark={isDark} />
<FieldRow label="操作" value={item.action} isDark={isDark} />
<FieldRow label="输入摘要" value={item.input_summary} isDark={isDark} />
<FieldRow label="参数摘要" value={item.params_summary && Object.keys(item.params_summary).length ? JSON.stringify(item.params_summary, null, 2) : undefined} isDark={isDark} />
</div>
{item.call_id && (
<Section title="关联调用记录" isDark={isDark}>
<LinkedCall callId={item.call_id} isDark={isDark} />
</Section>
)}
</div>
</div>
);
}
// ------------------------------------------------------------------ //
// 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 <span className={isDark ? 'text-slate-600' : 'text-gray-400'}>-</span>;
return (
<button
title="按此 Task ID 筛选"
onClick={(e) => { e.stopPropagation(); onFilter(taskId); }}
className={`font-mono text-xs px-1.5 py-0.5 rounded transition-all max-w-[100px] truncate block
${isDark ? 'text-blue-400 hover:bg-blue-500/20' : 'text-blue-600 hover:bg-blue-50'}`}
>
{taskId}
</button>
);
}
// ------------------------------------------------------------------ //
// 调用耗时统计 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<PagedResponse<CallTrace> | null>(null);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const [selected, setSelected] = useState<CallTrace | null>(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 (
<div className="space-y-4">
<div className={`${cardBg} border ${cardBorder} rounded-xl p-4`}>
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-blue-400" />
<span className={`text-sm font-medium ${textPrimary}`}>筛选条件</span>
{filters.task_id && (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">
Task: {filters.task_id}
<button onClick={() => setF('task_id', '')} className="ml-0.5 hover:text-white">
<X className="w-3 h-3" />
</button>
</span>
)}
</div>
<div className="flex flex-wrap gap-3">
<Select value={filters.call_type} onChange={(v) => setF('call_type', v)} placeholder="全部类型" cls={selectCls}
options={[{ value: 'model', label: '模型' }, { value: 'mcp', label: 'MCP' }, { value: 'skill', label: 'Skill' }]} />
<Select value={filters.model} onChange={(v) => setF('model', v)} placeholder="全部模型" cls={selectCls}
options={opts.models.map((m) => ({ value: m, label: m }))} />
<Select value={filters.mcp_key} onChange={(v) => setF('mcp_key', v)} placeholder="全部MCP" cls={selectCls}
options={opts.mcps.map((m) => ({ value: m.key, label: m.name || m.key }))} />
<Select value={filters.skill_key} onChange={(v) => setF('skill_key', v)} placeholder="全部Skill" cls={selectCls}
options={opts.skills.map((s) => ({ value: s.key, label: s.name || s.key }))} />
<Select value={filters.status} onChange={(v) => setF('status', v)} placeholder="全部状态" cls={selectCls}
options={opts.statuses.map((s) => ({ value: s, label: s }))} />
<input value={filters.task_id} onChange={(e) => 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}`} />
<Select value={filters.sort_by} onChange={(v) => setF('sort_by', v)} cls={selectCls}
options={[{ value: 'started_at', label: '按时间' }, { value: 'duration_ms', label: '按耗时' }]} />
<Select value={filters.sort_order} onChange={(v) => setF('sort_order', v)} cls={selectCls}
options={[{ value: 'desc', label: '降序' }, { value: 'asc', label: '升序' }]} />
<button onClick={() => load(1)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-red-600 hover:bg-red-500 text-white'}`}>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />刷新
</button>
</div>
</div>
<div className={`${cardBg} border ${cardBorder} rounded-xl overflow-hidden`}>
{fetchError && <div className="p-4 text-red-400 text-sm">{fetchError}</div>}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className={`border-b ${divider}`}>
<th className={thCls}>时间</th>
<th className={thCls}>类型</th>
<th className={thCls}>名称</th>
<th className={thCls}>操作</th>
<th className={thCls}>耗时</th>
<th className={thCls}>状态</th>
<th className={thCls}>Task ID</th>
<th className={thCls}>输入摘要</th>
<th className={thCls}>输出摘要</th>
</tr>
</thead>
<tbody>
{loading && !data && <tr><td colSpan={9} className={`py-8 text-center text-sm ${textSecondary}`}>加载中…</td></tr>}
{!loading && data?.items.length === 0 && <tr><td colSpan={9} className={`py-8 text-center text-sm ${textSecondary}`}>暂无数据</td></tr>}
{data?.items.map((row) => (
<tr key={row.id} className={`border-b ${divider} cursor-pointer transition-colors ${trHover}`}
onClick={() => setSelected(row)}>
<td className={`${tdCls} whitespace-nowrap text-xs ${textSecondary}`}>{formatTs(row.started_at)}</td>
<td className={tdCls}><Badge label={row.call_type} colorClass={TYPE_COLORS[row.call_type] ?? ''} /></td>
<td className={`${tdCls} max-w-[120px] truncate`}>{row.name}</td>
<td className={`${tdCls} max-w-[160px] truncate ${textSecondary} text-xs`}>{row.action}</td>
<td className={`${tdCls} whitespace-nowrap font-mono`}>{formatDuration(row.duration_ms)}</td>
<td className={tdCls}><Badge label={row.status} colorClass={STATUS_COLORS[row.status] ?? ''} /></td>
<td className={tdCls}>
<div className="flex items-center gap-1">
<TaskIdPill taskId={row.task_id} onFilter={(id) => setF('task_id', id)} isDark={isDark} />
{row.task_id && (
<button title="在报错日志中查看" onClick={(e) => { e.stopPropagation(); onSwitchToErrors(row.task_id!); }}
className={`p-0.5 rounded transition-all ${isDark ? 'text-slate-500 hover:text-red-400' : 'text-gray-400 hover:text-red-500'}`}>
<ArrowRight className="w-3 h-3" />
</button>
)}
</div>
</td>
<td className={`${tdCls} max-w-[160px] truncate text-xs ${textSecondary}`}>{row.input_summary ?? '-'}</td>
<td className={`${tdCls} max-w-[160px] truncate text-xs ${textSecondary}`}>{row.output_summary ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{data && (
<div className={`flex items-center justify-end px-4 py-3 border-t ${divider}`}>
<Pagination page={data.page} pageSize={data.page_size} total={data.total} onPage={(p) => load(p)} isDark={isDark} />
</div>
)}
</div>
{selected && <CallDetailModal item={selected} onClose={() => setSelected(null)} isDark={isDark} />}
</div>
);
}
// ------------------------------------------------------------------ //
// 报错日志 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<PagedResponse<ErrorLog> | null>(null);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState('');
const [selected, setSelected] = useState<ErrorLog | null>(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 (
<div className="space-y-4">
<div className={`${cardBg} border ${cardBorder} rounded-xl p-4`}>
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-red-400" />
<span className={`text-sm font-medium ${textPrimary}`}>筛选条件</span>
{filters.task_id && (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">
Task: {filters.task_id}
<button onClick={() => setF('task_id', '')} className="ml-0.5 hover:text-white">
<X className="w-3 h-3" />
</button>
</span>
)}
</div>
<div className="flex flex-wrap gap-3">
<Select value={filters.source} onChange={(v) => setF('source', v)} placeholder="全部来源" cls={selectCls}
options={opts.sources.map((s) => ({ value: s, label: s }))} />
<Select value={filters.level} onChange={(v) => setF('level', v)} placeholder="全部级别" cls={selectCls}
options={[{ value: 'warning', label: '警告' }, { value: 'error', label: '错误' }]} />
<Select value={filters.model} onChange={(v) => setF('model', v)} placeholder="全部模型" cls={selectCls}
options={opts.models.map((m) => ({ value: m, label: m }))} />
<Select value={filters.mcp_key} onChange={(v) => setF('mcp_key', v)} placeholder="全部MCP" cls={selectCls}
options={opts.mcps.map((m) => ({ value: m.key, label: m.name || m.key }))} />
<Select value={filters.skill_key} onChange={(v) => setF('skill_key', v)} placeholder="全部Skill" cls={selectCls}
options={opts.skills.map((s) => ({ value: s.key, label: s.name || s.key }))} />
<input value={filters.task_id} onChange={(e) => 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}`} />
<Select value={filters.sort_order} onChange={(v) => setF('sort_order', v)} cls={selectCls}
options={[{ value: 'desc', label: '降序' }, { value: 'asc', label: '升序' }]} />
<button onClick={() => load(1)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-red-600 hover:bg-red-500 text-white'}`}>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />刷新
</button>
</div>
</div>
<div className={`${cardBg} border ${cardBorder} rounded-xl overflow-hidden`}>
{fetchError && <div className="p-4 text-red-400 text-sm">{fetchError}</div>}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className={`border-b ${divider}`}>
<th className={thCls}>时间</th>
<th className={thCls}>级别</th>
<th className={thCls}>来源</th>
<th className={thCls}>消息</th>
<th className={thCls}>可恢复</th>
<th className={thCls}>Task ID</th>
<th className={thCls}>MCP</th>
<th className={thCls}>Skill</th>
<th className={thCls}>模型</th>
</tr>
</thead>
<tbody>
{loading && !data && <tr><td colSpan={9} className={`py-8 text-center text-sm ${textSecondary}`}>加载中…</td></tr>}
{!loading && data?.items.length === 0 && <tr><td colSpan={9} className={`py-8 text-center text-sm ${textSecondary}`}>暂无数据</td></tr>}
{data?.items.map((row) => (
<tr key={row.id} className={`border-b ${divider} cursor-pointer transition-colors ${trHover}`}
onClick={() => setSelected(row)}>
<td className={`${tdCls} whitespace-nowrap text-xs ${textSecondary}`}>{formatTs(row.happened_at)}</td>
<td className={tdCls}><Badge label={row.level} colorClass={STATUS_COLORS[row.level] ?? ''} /></td>
<td className={tdCls}><Badge label={row.source} colorClass={SOURCE_COLORS[row.source] ?? ''} /></td>
<td className={`${tdCls} max-w-[240px] truncate`}>{row.message}</td>
<td className={`${tdCls} text-xs`}>
<span className={row.recoverable ? 'text-green-400' : 'text-red-400'}>{row.recoverable ? '是' : '否'}</span>
</td>
<td className={tdCls}>
<div className="flex items-center gap-1">
<TaskIdPill taskId={row.task_id} onFilter={(id) => setF('task_id', id)} isDark={isDark} />
{row.task_id && (
<button title="在调用耗时中查看" onClick={(e) => { e.stopPropagation(); onSwitchToCalls(row.task_id!); }}
className={`p-0.5 rounded transition-all ${isDark ? 'text-slate-500 hover:text-blue-400' : 'text-gray-400 hover:text-blue-500'}`}>
<ArrowRight className="w-3 h-3" />
</button>
)}
</div>
</td>
<td className={`${tdCls} text-xs ${textSecondary}`}>{row.mcp_name ?? row.mcp_key ?? '-'}</td>
<td className={`${tdCls} text-xs ${textSecondary}`}>{row.skill_name ?? row.skill_key ?? '-'}</td>
<td className={`${tdCls} text-xs ${textSecondary}`}>{row.model ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{data && (
<div className={`flex items-center justify-end px-4 py-3 border-t ${divider}`}>
<Pagination page={data.page} pageSize={data.page_size} total={data.total} onPage={(p) => load(p)} isDark={isDark} />
</div>
)}
</div>
{selected && <ErrorDetailModal item={selected} onClose={() => setSelected(null)} isDark={isDark} />}
</div>
);
}
// ------------------------------------------------------------------ //
// Main Page //
// ------------------------------------------------------------------ //
const MANUAL_PURGE_DAYS = 14;
async function callPurge(retainDays: number): Promise<{ deleted_call_traces: number; deleted_error_logs: number }> {
const res = await fetch(`${backUrl}/api/observability/purge?retain_days=${retainDays}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export default function ObservabilityPage() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [activeTab, setActiveTab] = useState<'calls' | 'errors'>('calls');
const [opts, setOpts] = useState<Options>({ models: [], mcps: [], skills: [], statuses: [], sources: [] });
const [sharedTaskId, setSharedTaskId] = useState('');
const [purging, setPurging] = useState(false);
const [purgeMsg, setPurgeMsg] = useState('');
useEffect(() => {
fetchOptions().then(setOpts).catch(console.error);
}, []);
const handlePurge = async () => {
if (!window.confirm(`确认清理 ${MANUAL_PURGE_DAYS} 天前的旧记录?此操作不可撤销。`)) return;
setPurging(true);
setPurgeMsg('');
try {
const r = await callPurge(MANUAL_PURGE_DAYS);
setPurgeMsg(`已清理:调用记录 ${r.deleted_call_traces} 条,错误日志 ${r.deleted_error_logs} 条`);
} catch (e) {
setPurgeMsg(`清理失败:${e}`);
} finally {
setPurging(false);
}
};
const handleSwitchToErrors = (taskId: string) => {
setSharedTaskId(taskId);
setActiveTab('errors');
};
const handleSwitchToCalls = (taskId: string) => {
setSharedTaskId(taskId);
setActiveTab('calls');
};
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 tabActive = isDark ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'bg-red-50 text-red-600 border border-red-200';
const tabInactive = isDark ? 'text-slate-400 hover:text-white hover:bg-slate-800/50' : 'text-gray-600 hover:text-red-600 hover:bg-red-50';
return (
<div className="space-y-6">
<div className={`${cardBg} backdrop-blur-xl border ${cardBorder} rounded-xl p-4`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isDark ? 'bg-blue-500/20' : 'bg-red-50'}`}>
<Activity className={`w-5 h-5 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<div>
<h1 className={`font-bold text-lg ${textPrimary}`}>可观测性</h1>
<p className={`text-sm ${textSecondary}`}>调用耗时统计 · 报错日志</p>
</div>
</div>
<div className="flex items-center gap-3">
{purgeMsg && (
<span className={`text-xs px-2 py-1 rounded-lg ${isDark ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
{purgeMsg}
</span>
)}
<button onClick={handlePurge} disabled={purging}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-all
${isDark ? 'border-red-800/40 text-red-400 hover:bg-red-900/20 disabled:opacity-40' : 'border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-40'}`}>
<Trash2 className={`w-3.5 h-3.5 ${purging ? 'animate-spin' : ''}`} />
清理 {MANUAL_PURGE_DAYS} 天前数据
</button>
</div>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => setActiveTab('calls')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'calls' ? tabActive : tabInactive}`}>
<Clock className="w-4 h-4" />调用耗时统计
</button>
<button onClick={() => setActiveTab('errors')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'errors' ? tabActive : tabInactive}`}>
<AlertTriangle className="w-4 h-4" />报错日志
</button>
</div>
{activeTab === 'calls' ? (
<CallsTab opts={opts} isDark={isDark} externalTaskId={sharedTaskId} onSwitchToErrors={handleSwitchToErrors} />
) : (
<ErrorsTab opts={opts} isDark={isDark} externalTaskId={sharedTaskId} onSwitchToCalls={handleSwitchToCalls} />
)}
</div>
);
}