249 lines
12 KiB
TypeScript
249 lines
12 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { Activity, X } from "lucide-react";
|
|
import { adminApi } from "../../api";
|
|
import { Pager } from "../../components/pager";
|
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
|
import { SystemLoading } from "../../components/loading";
|
|
import type { AdminTask, AdminTaskDetail } from "../../types";
|
|
import { pts } from "../stage-config";
|
|
|
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
const TABS = [
|
|
{ key: "", label: "全部" },
|
|
{ key: "failed", label: "失败" },
|
|
{ key: "succeeded", label: "成功" }
|
|
];
|
|
|
|
function fmtDate(iso: string) {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
const p = (n: number) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
}
|
|
|
|
function statusPill(status: string) {
|
|
if (status === "succeeded") return <span className="pill ok"><span className="dot" />成功</span>;
|
|
if (status === "failed") return <span className="pill err"><span className="dot" />失败</span>;
|
|
if (["cancelled", "compensating"].includes(status)) return <span className="pill neutral"><span className="dot" />{status}</span>;
|
|
return <span className="pill info"><span className="dot" />进行中</span>;
|
|
}
|
|
|
|
function attemptKind(attempt: AdminTaskDetail["attempts"][number]) {
|
|
if (attempt.is_fallback) return "Fallback";
|
|
if (attempt.is_retry) return "原模型重试";
|
|
return "首次调用";
|
|
}
|
|
|
|
export function AdminTasksPage({ notify }: { notify: Notify }) {
|
|
const [tasks, setTasks] = useState<AdminTask[]>([]);
|
|
const [count, setCount] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [tab, setTab] = useState("");
|
|
const [anomalyOnly, setAnomalyOnly] = useState(false);
|
|
const [detail, setDetail] = useState<AdminTaskDetail | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await adminApi.tasks({ status: tab || undefined, anomaly: anomalyOnly ? "1" : undefined, page, page_size: PAGE_SIZE });
|
|
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
|
setTasks(res.results);
|
|
setCount(res.count);
|
|
} catch {
|
|
notify("error", "加载任务失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tab, anomalyOnly, page]);
|
|
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
async function openDetail(id: string) {
|
|
setDetailLoading(true);
|
|
setDetail(null);
|
|
try {
|
|
setDetail(await adminApi.taskDetail(id));
|
|
} catch {
|
|
notify("error", "加载详情失败");
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
}
|
|
|
|
async function retry(t: AdminTask) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
try {
|
|
await adminApi.retryTask(t.id);
|
|
notify("success", "已重投,稍后刷新查看");
|
|
await load();
|
|
} catch (e) {
|
|
notify("error", e instanceof Error ? e.message : "重投失败");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
// 手动回收僵尸任务:卡在「已预留」超 10 分钟(worker 从未认领)→ 标失败 + 退还冻结积分。
|
|
// 自动回收只在同团队下次提交时触发,团队弃用该功能时积分会永久冻结,这是超管兜底出口。
|
|
async function reap(t: AdminTask) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
try {
|
|
await adminApi.reapTask(t.id);
|
|
notify("success", "已回收:任务标记失败,预留积分已退还");
|
|
await load();
|
|
} catch (e) {
|
|
notify("error", e instanceof Error ? e.message : "回收失败");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="page-head">
|
|
<div>
|
|
<h1>任务监控</h1>
|
|
<div className="sub"><span className="mono">// {count} 个任务</span> · 全局 AI 任务 · 成本异常 · 失败重投</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-toolbar">
|
|
<div className="tabs-sub">
|
|
{TABS.map((t) => (
|
|
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => { setTab(t.key); setPage(1); }}>{t.label}</button>
|
|
))}
|
|
</div>
|
|
<button type="button" className={`chip admin-anomaly-chip${anomalyOnly ? " active" : ""}`} onClick={() => { setAnomalyOnly((v) => !v); setPage(1); }}>
|
|
仅成本异常
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<SystemLoading title="正在加载任务" description="正在同步最新数据,请稍候。" icon="activity" />
|
|
) : tasks.length === 0 ? (
|
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>暂无任务</h3><p>// no tasks</p></div>
|
|
) : (
|
|
<div className="admin-table-wrap">
|
|
<table className="t admin-table">
|
|
<thead>
|
|
<tr><th>类型</th><th>团队</th><th>模型</th><th>状态</th><th>成本(预估→实际)</th><th>时间</th><th className="col-actions">操作</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{tasks.map((t) => (
|
|
<tr key={t.id}>
|
|
<td className="mono admin-code">{t.task_type}</td>
|
|
<td>{t.team_name || <span className="muted">—</span>}</td>
|
|
<td>{t.model_name || <span className="muted">—</span>}</td>
|
|
<td>{statusPill(t.status)}</td>
|
|
<td className="mono">
|
|
{pts(t.estimated_cost)} → {pts(t.actual_cost)} 积分{t.margin_yuan != null ? ` · 毛利 ¥${t.margin_yuan}` : ""}
|
|
{t.cost_anomaly && <span className="pill err admin-inline-pill">异常</span>}
|
|
</td>
|
|
<td className="mono col-time">{fmtDate(t.created_at)}</td>
|
|
<td className="col-actions">
|
|
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}>详情</button>
|
|
{t.status === "failed" && (
|
|
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => retry(t)}>重投</button>
|
|
)}
|
|
{t.reapable && (
|
|
<button className="btn btn-sm btn-ghost danger" type="button" disabled={busy} title="卡死任务:标记失败并退还预留积分" onClick={() => reap(t)}>回收退款</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
|
</div>
|
|
)}
|
|
|
|
{(detail || detailLoading) && (
|
|
<div className="drawer-bg show" onClick={(e) => { if (e.target === e.currentTarget) setDetail(null); }}>
|
|
<div className="drawer admin-drawer show" role="dialog" aria-modal="true" aria-label="任务详情">
|
|
<div className="drawer-h">
|
|
<h3><Activity size={15} style={{ verticalAlign: "-2px", marginRight: 6 }} />任务详情</h3>
|
|
<button className="x" type="button" aria-label="关闭" onClick={() => setDetail(null)}><X size={14} /></button>
|
|
</div>
|
|
<div className="drawer-b">
|
|
{detailLoading || !detail ? (
|
|
<SystemLoading variant="inline" title="正在加载详情" />
|
|
) : (
|
|
<>
|
|
{(() => {
|
|
const attempts = detail.attempts || [];
|
|
const finalAttempt = [...attempts].reverse().find((attempt) => attempt.status === "succeeded");
|
|
const fallbackUsed = attempts.some((attempt) => attempt.is_fallback);
|
|
return (
|
|
<div className="admin-detail-meta">
|
|
<div><span className="k">状态</span><span className="v">{statusPill(detail.status)}</span></div>
|
|
<div><span className="k">团队</span><span className="v">{detail.team_name || "—"}</span></div>
|
|
<div><span className="k">模型</span><span className="v">{detail.model_name || "—"}</span></div>
|
|
<div><span className="k">计价</span><span className="v mono">{pts(detail.estimated_cost)} → {pts(detail.actual_cost)} 积分{detail.cost_anomaly ? " ⚠" : ""}</span></div>
|
|
<div><span className="k">平台成本</span><span className="v mono">{Number(detail.base_cost || 0) > 0 ? `¥${detail.base_cost} · 毛利 ${detail.margin_yuan != null ? `¥${detail.margin_yuan}` : "—"}` : "未知"}</span></div>
|
|
<div><span className="k">模型调用</span><span className="v mono">{attempts.length ? `${attempts.length} 次 · ${fallbackUsed ? "发生 Fallback" : "未切换"}` : "旧任务 · 无尝试链"}</span></div>
|
|
{finalAttempt && <div><span className="k">最终成功模型</span><span className="v">{finalAttempt.provider_display_name || finalAttempt.provider_name} / {finalAttempt.model_display_name || finalAttempt.model_name}</span></div>}
|
|
</div>
|
|
);
|
|
})()}
|
|
{detail.attempts?.length > 0 && (
|
|
<>
|
|
<div className="admin-detail-subhead">模型调用链 · {detail.attempts.length} 次</div>
|
|
<div className="admin-attempt-list">
|
|
{detail.attempts.map((attempt) => (
|
|
<div className="admin-attempt" key={attempt.id}>
|
|
<div className="admin-attempt-head">
|
|
<span className="admin-attempt-seq mono">// {String(attempt.sequence).padStart(2, "0")}</span>
|
|
{statusPill(attempt.status)}
|
|
<span className="admin-attempt-kind mono">{attemptKind(attempt)}</span>
|
|
</div>
|
|
<div className="admin-attempt-model">
|
|
<span>{attempt.provider_display_name || attempt.provider_name}</span>
|
|
<span className="admin-attempt-sep">/</span>
|
|
<strong>{attempt.model_display_name || attempt.model_name}</strong>
|
|
</div>
|
|
<div className="admin-attempt-meta mono">
|
|
<span>{attempt.operation}</span>
|
|
<span>{attempt.duration_ms == null ? "耗时 —" : `耗时 ${attempt.duration_ms} ms`}</span>
|
|
<span>{Number(attempt.platform_cost || 0) > 0 ? `平台成本 ¥${attempt.platform_cost}` : "平台成本未知"}</span>
|
|
{attempt.provider_task_id && <span>Provider ID {attempt.provider_task_id}</span>}
|
|
</div>
|
|
{(attempt.error_type || attempt.raw_error) && (
|
|
<div className="admin-attempt-error">
|
|
<span className="mono">[{attempt.error_type || "unknown"}{attempt.provider_error_code ? ` · ${attempt.provider_error_code}` : ""}]</span>
|
|
<span>{attempt.raw_error || attempt.safe_error_summary}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
{detail.error_message && (
|
|
<>
|
|
<div className="admin-detail-subhead">错误</div>
|
|
<pre className="admin-json admin-json-err">{detail.error_message}</pre>
|
|
</>
|
|
)}
|
|
<div className="admin-detail-subhead">请求 payload</div>
|
|
<pre className="admin-json">{JSON.stringify(detail.request_payload, null, 2)}</pre>
|
|
<div className="admin-detail-subhead">响应 payload</div>
|
|
<pre className="admin-json">{JSON.stringify(detail.response_payload, null, 2)}</pre>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|