811 lines
38 KiB
TypeScript
811 lines
38 KiB
TypeScript
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>
|
||
);
|
||
}
|