文件
csp/frontend/src/app/backend-logs/page.tsx

537 行
20 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
import { Activity, AlertCircle, List, Play, RefreshCw, Server, Trash2, Zap } from "lucide-react";
type BackendLogItem = {
id: number;
problem_id: number;
problem_title: string;
status: string;
progress: number;
message: string;
created_by: number;
max_solutions: number;
created_at: number;
started_at: number | null;
finished_at: number | null;
updated_at: number;
runner_pending: boolean;
};
type QueueJobItem = {
id: number;
problem_id: number;
problem_title: string;
status: string;
progress: number;
message: string;
updated_at: number;
started_at?: number | null;
};
type BackendLogsResp = {
items: BackendLogItem[];
running_jobs: QueueJobItem[];
queued_jobs: QueueJobItem[];
running_problem_ids: number[];
queued_problem_ids: number[];
running_count: number;
queued_count_preview: number;
pending_jobs: number;
missing_problems: number;
limit: number;
running_limit: number;
queued_limit: number;
};
type TriggerMissingResp = {
started: boolean;
missing_total: number;
candidate_count: number;
queued_count: number;
pending_jobs: number;
limit: number;
max_solutions: number;
};
type AdminUser = {
id: number;
username: string;
rating: number;
created_at: number;
};
type AdminUsersResp = {
items: AdminUser[];
total_count: number;
page: number;
page_size: number;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function BackendLogsPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [checkingAdmin, setCheckingAdmin] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [limit, setLimit] = useState(100);
const [pendingJobs, setPendingJobs] = useState(0);
const [missingProblems, setMissingProblems] = useState(0);
const [items, setItems] = useState<BackendLogItem[]>([]);
const [runningJobs, setRunningJobs] = useState<QueueJobItem[]>([]);
const [queuedJobs, setQueuedJobs] = useState<QueueJobItem[]>([]);
const [runningIds, setRunningIds] = useState<number[]>([]);
const [queuedIds, setQueuedIds] = useState<number[]>([]);
const [triggerLoading, setTriggerLoading] = useState(false);
const [triggerMsg, setTriggerMsg] = useState("");
const [users, setUsers] = useState<AdminUser[]>([]);
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
const [userMsg, setUserMsg] = useState("");
const refresh = async () => {
if (!isAdmin || !token) return;
setLoading(true);
setError("");
try {
const [data, usersData] = await Promise.all([
apiFetch<BackendLogsResp>(
`/api/v1/backend/logs?limit=${limit}&running_limit=20&queued_limit=100`,
{},
token
),
apiFetch<AdminUsersResp>("/api/v1/admin/users?page=1&page_size=200", {}, token),
]);
setPendingJobs(data.pending_jobs ?? 0);
setMissingProblems(data.missing_problems ?? 0);
setItems(data.items ?? []);
setRunningJobs(data.running_jobs ?? []);
setQueuedJobs(data.queued_jobs ?? []);
setRunningIds(data.running_problem_ids ?? []);
setQueuedIds(data.queued_problem_ids ?? []);
setUsers(usersData.items ?? []);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
let canceled = false;
const checkAdmin = async () => {
setCheckingAdmin(true);
const tk = readToken();
if (!canceled) setToken(tk);
if (!tk) {
if (!canceled) {
setIsAdmin(false);
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
setCheckingAdmin(false);
}
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk);
const allowed = (me?.username ?? "") === "admin";
if (!canceled) {
setIsAdmin(allowed);
if (!allowed) {
setError(tx("仅管理员可查看后台日志", "Backend logs are visible to admin only"));
} else {
setError("");
}
}
} catch (e: unknown) {
if (!canceled) {
setIsAdmin(false);
setError(String(e));
}
} finally {
if (!canceled) setCheckingAdmin(false);
}
};
void checkAdmin();
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isAdmin || !token) return;
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, limit]);
const triggerMissingSolutions = async () => {
if (!isAdmin || !token) {
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
return;
}
setTriggerLoading(true);
setTriggerMsg("");
setError("");
try {
const data = await apiFetch<TriggerMissingResp>(
"/api/v1/backend/solutions/generate-missing",
{
method: "POST",
body: JSON.stringify({ limit: 50000, max_solutions: 3 }),
},
token
);
setPendingJobs(data.pending_jobs ?? 0);
setTriggerMsg(
tx(
`已触发异步任务:候选 ${data.candidate_count} 题,入队 ${data.queued_count} 题(当前待处理 ${data.pending_jobs})。`,
`Async trigger submitted: candidate ${data.candidate_count}, queued ${data.queued_count} (pending ${data.pending_jobs}).`
)
);
await refresh();
} catch (e: unknown) {
setError(String(e));
} finally {
setTriggerLoading(false);
}
};
const deleteUser = async (user: AdminUser) => {
if (!isAdmin || !token) return;
if (user.username === "admin") {
setError(tx("保留管理员账号不可删除", "Reserved admin account cannot be deleted"));
return;
}
const ok = window.confirm(
tx(
`确认删除用户 ${user.username}(#${user.id})?该用户提交记录、错题本、草稿、积分记录会被级联删除。`,
`Delete user ${user.username}(#${user.id})? Submissions, wrong-book, drafts, and points records will be removed by cascade.`
)
);
if (!ok) return;
setDeleteUserId(user.id);
setError("");
setUserMsg("");
try {
await apiFetch(
`/api/v1/admin/users/${user.id}`,
{
method: "DELETE",
},
token
);
setUserMsg(
tx(
`已删除用户 ${user.username}(#${user.id})`,
`Deleted user ${user.username}(#${user.id}).`
)
);
await refresh();
} catch (e: unknown) {
setError(String(e));
} finally {
setDeleteUserId(null);
}
};
useEffect(() => {
if (!isAdmin || !token) return;
const timer = setInterval(() => {
void refresh();
}, 5000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, limit]);
if (checkingAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
{tx("正在校验管理员权限...", "Checking admin access...")}
</main>
);
}
if (!isAdmin) {
return (
<main className="mx-auto max-w-5xl px-3 py-8">
<h1 className="text-xl font-semibold">{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}</h1>
<p className="mt-3 text-sm text-red-600">
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
</p>
</main>
);
}
return (
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl flex items-center gap-2">
<Server size={24} />
{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}
</h1>
<div className="flex w-full flex-wrap items-center gap-2 text-sm sm:w-auto sm:justify-end">
<span className={pendingJobs > 0 ? "text-emerald-700" : "text-zinc-600"}>
{tx("待处理任务", "Pending jobs")} {pendingJobs}
</span>
<span className={missingProblems > 0 ? "text-amber-700" : "text-zinc-600"}>
{tx("缺失答案题目", "Problems missing answers")} {missingProblems}
</span>
<button
className="rounded border px-3 py-1 disabled:opacity-50 flex items-center gap-1"
onClick={() => void triggerMissingSolutions()}
disabled={triggerLoading}
>
<Zap size={14} />
{triggerLoading ? tx("手动补全中...", "Triggering...") : tx("手动补全(可选)", "Manual fill (optional)")}
</button>
<select
className="rounded border px-2 py-1"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
>
<option value={50}>{tx("最近 50 条", "Latest 50")}</option>
<option value={100}>{tx("最近 100 条", "Latest 100")}</option>
<option value={200}>{tx("最近 200 条", "Latest 200")}</option>
</select>
<button className="rounded border px-3 py-1 sm:ml-auto flex items-center gap-1" onClick={() => void refresh()} disabled={loading}>
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
{tx("刷新", "Refresh")}
</button>
</div>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{triggerMsg && <p className="mt-3 text-sm text-emerald-700">{triggerMsg}</p>}
{userMsg && <p className="mt-3 text-sm text-emerald-700">{userMsg}</p>}
<p className="mt-3 text-xs text-zinc-500">
{tx(
"系统已自动单线程异步处理待队列任务,无需手工点击;上方按钮仅用于立即手动补全。",
"System auto-processes queued jobs in single-thread async mode; the button above is only for manual trigger."
)}
</p>
<section className="mt-4 rounded-xl border bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-medium">{tx("用户管理(可删除)", "User Management (Delete Supported)")}</h2>
<p className="text-xs text-zinc-600">
{tx("总用户", "Total users")} {users.length}
</p>
</div>
<div className="mt-3 divide-y md:hidden">
{users.map((user) => (
<article key={user.id} className="space-y-1 py-2 text-xs">
<p className="font-medium">
#{user.id} · {user.username}
</p>
<p className="text-zinc-600">
Rating {user.rating} · {tx("创建", "Created")} {fmtTs(user.created_at)}
</p>
<button
className="rounded border px-2 py-1 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60"
disabled={deleteUserId === user.id || user.username === "admin"}
onClick={() => void deleteUser(user)}
>
{user.username === "admin"
? tx("保留账号", "Reserved")
: deleteUserId === user.id
? tx("删除中...", "Deleting...")
: tx("删除用户", "Delete User")}
</button>
</article>
))}
{!loading && users.length === 0 && (
<p className="py-4 text-center text-sm text-zinc-500">{tx("暂无用户数据", "No users found")}</p>
)}
</div>
<div className="mt-2 hidden overflow-x-auto md:block">
<table className="min-w-full text-xs">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2">{tx("用户名", "Username")}</th>
<th className="px-2 py-2">Rating</th>
<th className="px-2 py-2">{tx("创建时间", "Created At")}</th>
<th className="px-2 py-2">{tx("操作", "Action")}</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t">
<td className="px-2 py-2">{user.id}</td>
<td className="px-2 py-2">{user.username}</td>
<td className="px-2 py-2">{user.rating}</td>
<td className="px-2 py-2 text-zinc-600">{fmtTs(user.created_at)}</td>
<td className="px-2 py-2">
<button
className="rounded border px-2 py-1 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60"
disabled={deleteUserId === user.id || user.username === "admin"}
onClick={() => void deleteUser(user)}
>
{user.username === "admin"
? tx("保留账号", "Reserved")
: deleteUserId === user.id
? tx("删除中...", "Deleting...")
: tx("删除用户", "Delete User")}
</button>
</td>
</tr>
))}
{!loading && users.length === 0 && (
<tr>
<td className="px-2 py-4 text-center text-zinc-500" colSpan={5}>
{tx("暂无用户数据", "No users found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<section className="mt-4 grid gap-3 md:grid-cols-2">
<article className="rounded-xl border bg-white p-3">
<h2 className="text-sm font-medium flex items-center gap-2">
<Activity size={16} className="text-emerald-600" />
{tx("正在处理Running", "Running Jobs")}
</h2>
<p className="mt-1 text-xs text-zinc-600">
{tx("当前题目 ID", "Current problem IDs:")}
{runningIds.length ? runningIds.join(", ") : tx("无", "None")}
</p>
<ul className="mt-2 space-y-2 text-xs">
{runningJobs.map((job) => (
<li key={job.id} className="rounded border border-zinc-200 p-2">
<p>
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
</p>
<p className="text-zinc-600">
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("开始", "Start")} {fmtTs(job.started_at ?? null)}
</p>
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
</li>
))}
{!runningJobs.length && <li className="text-zinc-500">{tx("当前无运行中的任务", "No running jobs")}</li>}
</ul>
</article>
<article className="rounded-xl border bg-white p-3">
<h2 className="text-sm font-medium flex items-center gap-2">
<List size={16} className="text-amber-600" />
{tx("待处理队列Queued", "Queued Jobs")}
</h2>
<p className="mt-1 text-xs text-zinc-600">
{tx("待处理题目 ID预览", "Queued problem IDs (preview):")}
{queuedIds.length ? queuedIds.join(", ") : tx("无", "None")}
</p>
<ul className="mt-2 max-h-56 space-y-2 overflow-auto text-xs">
{queuedJobs.map((job) => (
<li key={job.id} className="rounded border border-zinc-200 p-2">
<p>
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
</p>
<p className="text-zinc-600">
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("更新", "Updated")} {fmtTs(job.updated_at)}
</p>
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
</li>
))}
{!queuedJobs.length && <li className="text-zinc-500">{tx("当前无待处理任务", "No queued jobs")}</li>}
</ul>
</article>
</section>
<section className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{items.map((item) => (
<article key={item.id} className="space-y-2 p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{tx("任务", "Job")} #{item.id}
</p>
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
{item.status} · {item.progress}%
</span>
</div>
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
#{item.problem_id} {item.problem_title || tx("(未命名题目)", "(Untitled)")}
</Link>
<p className="whitespace-pre-wrap break-words text-zinc-600">{item.message || "-"}</p>
<p className="text-zinc-500">
{tx("创建", "Created")} {fmtTs(item.created_at)} · {tx("开始", "Start")} {fmtTs(item.started_at)} · {tx("结束", "End")} {fmtTs(item.finished_at)}
</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500">{tx("暂无后台任务日志", "No backend logs yet")}</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-xs">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-2">{tx("任务ID", "Job ID")}</th>
<th className="px-2 py-2">{tx("题目", "Problem")}</th>
<th className="px-2 py-2">{tx("状态", "Status")}</th>
<th className="px-2 py-2">{tx("进度", "Progress")}</th>
<th className="px-2 py-2">{tx("消息", "Message")}</th>
<th className="px-2 py-2">{tx("时间", "Time")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2 font-medium">{item.id}</td>
<td className="max-w-[260px] px-2 py-2">
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
#{item.problem_id} {item.problem_title || tx("(未命名题目)", "(Untitled)")}
</Link>
</td>
<td className="px-2 py-2">
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
{item.status}
</span>
</td>
<td className="px-2 py-2">{item.progress}%</td>
<td className="max-w-[420px] px-2 py-2">
<div className="whitespace-pre-wrap break-words">{item.message || "-"}</div>
</td>
<td className="px-2 py-2 text-zinc-600">
{tx("创建", "Created")} {fmtTs(item.created_at)}
<br />
{tx("开始", "Start")} {fmtTs(item.started_at)}
<br />
{tx("结束", "End")} {fmtTs(item.finished_at)}
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-2 py-6 text-center text-zinc-500" colSpan={6}>
{tx("暂无后台任务日志", "No backend logs yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</main>
);
}