Files
deerflow-code/web-code/pages/ObservabilityPage.tsx
T
2026-05-27 19:43:15 +08:00

811 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}