生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。 模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。 验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。 已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。
192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
"use client";
|
||
import { useState } from "react";
|
||
import useSWR from "swr";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { adminFetcher } from "./media-records-panel";
|
||
import { formatRecordTime } from "@/lib/admin/record-time";
|
||
type Event = {
|
||
id: string;
|
||
modelDisplayName: string;
|
||
modelRuntimeId: string;
|
||
providerName: string;
|
||
eventType: string;
|
||
createdAt: string;
|
||
errorMessage: string;
|
||
elapsedMs: number | null;
|
||
consecutiveFailures: number;
|
||
autoDisabled: boolean;
|
||
};
|
||
export function ModelHealthPanel({
|
||
providers,
|
||
models,
|
||
initialModelId = "",
|
||
}: {
|
||
initialModelId?: string;
|
||
providers: Array<{ id: string; name: string }>;
|
||
models: Array<{ id: string; displayName: string; providerId: string }>;
|
||
}) {
|
||
const [filters, setFilters] = useState({
|
||
providerId: "",
|
||
modelId: initialModelId,
|
||
eventType: "",
|
||
startDate: "",
|
||
endDate: "",
|
||
});
|
||
const [page, setPage] = useState(1);
|
||
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
|
||
const { data, error, isLoading, isValidating, mutate } = useSWR<{
|
||
events: Event[];
|
||
hasMore: boolean;
|
||
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
|
||
keepPreviousData: true,
|
||
revalidateOnFocus: false,
|
||
});
|
||
const update = (change: Partial<typeof filters>) => {
|
||
setFilters((f) => ({ ...f, ...change }));
|
||
setPage(1);
|
||
};
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold">运行健康</h3>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
按上游和模型追踪故障,查看完整原因与发生时间。
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-3">
|
||
{[
|
||
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
|
||
[
|
||
"modelId",
|
||
"全部模型",
|
||
models
|
||
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
|
||
.map((m) => [m.id, m.displayName]),
|
||
],
|
||
[
|
||
"eventType",
|
||
"全部错误",
|
||
[
|
||
["quota", "额度 / 余额"],
|
||
["timeout", "超时"],
|
||
["unavailable", "不可用"],
|
||
["invalid_model", "模型不可用"],
|
||
["download_error", "结果下载失败"],
|
||
["error", "其他错误"],
|
||
["success", "成功"],
|
||
],
|
||
],
|
||
].map(([key, label, options]) => (
|
||
<select
|
||
key={key as string}
|
||
aria-label={label as string}
|
||
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
|
||
value={filters[key as keyof typeof filters]}
|
||
onChange={(e) =>
|
||
update({
|
||
[key as string]: e.target.value,
|
||
...(key === "providerId" ? { modelId: "" } : {}),
|
||
})
|
||
}
|
||
>
|
||
<option value="">{label as string}</option>
|
||
{(options as string[][]).map(([value, text]) => (
|
||
<option key={value} value={value}>
|
||
{text}
|
||
</option>
|
||
))}
|
||
</select>
|
||
))}
|
||
<Input
|
||
aria-label="故障开始日期"
|
||
className="h-10 w-40"
|
||
type="date"
|
||
value={filters.startDate}
|
||
onChange={(e) => update({ startDate: e.target.value })}
|
||
/>
|
||
<Input
|
||
aria-label="故障结束日期"
|
||
className="h-10 w-40"
|
||
type="date"
|
||
value={filters.endDate}
|
||
onChange={(e) => update({ endDate: e.target.value })}
|
||
/>
|
||
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
|
||
刷新日志
|
||
</Button>
|
||
</div>
|
||
{error ? (
|
||
<p role="alert" className="p-6 text-sm text-destructive">
|
||
日志加载失败,请点击刷新重试。
|
||
</p>
|
||
) : isLoading ? (
|
||
<p role="status" className="p-6 text-sm">
|
||
正在加载故障日志…
|
||
</p>
|
||
) : (
|
||
<div className="divide-y rounded-xl border">
|
||
{data?.events.map((event) => (
|
||
<div key={event.id} className="p-4">
|
||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||
<strong className="font-semibold">
|
||
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
|
||
</strong>
|
||
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
|
||
<span className="rounded bg-muted px-2 py-1 text-xs">
|
||
{{
|
||
quota: "额度 / 余额",
|
||
timeout: "超时",
|
||
unavailable: "不可用",
|
||
error: "服务错误",
|
||
invalid_model: "模型不可用",
|
||
download_error: "结果下载失败",
|
||
success: "成功",
|
||
}[event.eventType] || event.eventType}
|
||
</span>
|
||
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||
{formatRecordTime(event.createdAt)}
|
||
</time>
|
||
</div>
|
||
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
|
||
{event.errorMessage || "未记录详细原因"}
|
||
</p>
|
||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
||
<span>连续失败 {event.consecutiveFailures} 次</span>
|
||
{event.elapsedMs !== null && (
|
||
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
|
||
)}
|
||
{event.autoDisabled && <span>已自动停用</span>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!data?.events.length && (
|
||
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">
|
||
第 {page} 页{isValidating && " · 正在更新…"}
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
disabled={page === 1 || isValidating}
|
||
onClick={() => setPage((p) => p - 1)}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
disabled={!data?.hasMore || isValidating}
|
||
onClick={() => setPage((p) => p + 1)}
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|