1008 lines
38 KiB
TypeScript
1008 lines
38 KiB
TypeScript
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
Clock3,
|
||
Filter,
|
||
PlayCircle,
|
||
RefreshCcw,
|
||
RotateCcw,
|
||
Send,
|
||
SquareTerminal,
|
||
StopCircle,
|
||
TimerReset,
|
||
Workflow,
|
||
} from "lucide-react";
|
||
import {
|
||
startTransition,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useState,
|
||
} from "react";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select } from "@/components/ui/select";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { adminApi, ApiError } from "@/lib/api";
|
||
import { formatDateTime } from "@/lib/admin-format";
|
||
import {
|
||
formatWorkerProgress,
|
||
getWorkerProgressPercent,
|
||
} from "@/lib/worker-progress";
|
||
import type { WorkerJobRecord, WorkerOverview, WorkerStats } from "@/lib/types";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
function prettyJson(value: unknown) {
|
||
if (value === null || value === undefined) {
|
||
return "—";
|
||
}
|
||
|
||
try {
|
||
return JSON.stringify(value, null, 2);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function statusVariant(status: string) {
|
||
switch (status) {
|
||
case "succeeded":
|
||
return "success" as const;
|
||
case "running":
|
||
return "default" as const;
|
||
case "queued":
|
||
return "secondary" as const;
|
||
case "failed":
|
||
return "danger" as const;
|
||
case "cancelled":
|
||
return "warning" as const;
|
||
default:
|
||
return "outline" as const;
|
||
}
|
||
}
|
||
|
||
function summaryIconTone(status: string) {
|
||
switch (status) {
|
||
case "running":
|
||
return "border-blue-500/20 bg-blue-500/10 text-blue-600";
|
||
case "succeeded":
|
||
return "border-emerald-500/20 bg-emerald-500/10 text-emerald-600";
|
||
case "failed":
|
||
return "border-rose-500/20 bg-rose-500/10 text-rose-600";
|
||
case "queued":
|
||
return "border-sky-500/20 bg-sky-500/10 text-sky-600";
|
||
default:
|
||
return "border-primary/20 bg-primary/10 text-primary";
|
||
}
|
||
}
|
||
|
||
function workerStatTotal(item: WorkerStats) {
|
||
return (
|
||
item.queued + item.running + item.succeeded + item.failed + item.cancelled
|
||
);
|
||
}
|
||
|
||
function workerHealthLabel(item: WorkerStats) {
|
||
if (item.failed > 0) {
|
||
return "有失败";
|
||
}
|
||
if (item.running > 0) {
|
||
return "执行中";
|
||
}
|
||
if (item.queued > 0) {
|
||
return "排队中";
|
||
}
|
||
return "稳定";
|
||
}
|
||
|
||
function workerHealthTone(item: WorkerStats) {
|
||
if (item.failed > 0) {
|
||
return "text-rose-600";
|
||
}
|
||
if (item.running > 0) {
|
||
return "text-blue-600";
|
||
}
|
||
if (item.queued > 0) {
|
||
return "text-sky-600";
|
||
}
|
||
return "text-emerald-600";
|
||
}
|
||
|
||
function relatedEntityText(job: WorkerJobRecord) {
|
||
if (!job.related_entity_type || !job.related_entity_id) {
|
||
return "—";
|
||
}
|
||
|
||
return `${job.related_entity_type}:${job.related_entity_id}`;
|
||
}
|
||
|
||
function jobTitle(job: WorkerJobRecord) {
|
||
return job.display_name ?? job.worker_name;
|
||
}
|
||
|
||
function SummaryCard({
|
||
label,
|
||
value,
|
||
hint,
|
||
icon: Icon,
|
||
tone,
|
||
}: {
|
||
label: string;
|
||
value: string | number;
|
||
hint: string;
|
||
icon: typeof SquareTerminal;
|
||
tone: string;
|
||
}) {
|
||
return (
|
||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||
<CardContent className="flex items-start justify-between gap-4 pt-6">
|
||
<div className="min-w-0">
|
||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||
{label}
|
||
</div>
|
||
<div className="mt-3 text-3xl font-semibold tracking-tight text-foreground">
|
||
{value}
|
||
</div>
|
||
<div className="mt-2 text-sm leading-6 text-muted-foreground">
|
||
{hint}
|
||
</div>
|
||
</div>
|
||
<div className={cn("rounded-2xl border p-3", tone)}>
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const EMPTY_OVERVIEW: WorkerOverview = {
|
||
total_jobs: 0,
|
||
queued: 0,
|
||
running: 0,
|
||
succeeded: 0,
|
||
failed: 0,
|
||
cancelled: 0,
|
||
active_jobs: 0,
|
||
worker_stats: [],
|
||
catalog: [],
|
||
};
|
||
|
||
export function WorkersPage() {
|
||
const [searchParams] = useSearchParams();
|
||
const [overview, setOverview] = useState<WorkerOverview>(EMPTY_OVERVIEW);
|
||
const [jobs, setJobs] = useState<WorkerJobRecord[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [actioning, setActioning] = useState<string | null>(null);
|
||
const [selectedJobId, setSelectedJobId] = useState<number | null>(null);
|
||
const [statusFilter, setStatusFilter] = useState(
|
||
searchParams.get("status") || "all",
|
||
);
|
||
const [kindFilter, setKindFilter] = useState(
|
||
searchParams.get("kind") || "all",
|
||
);
|
||
const [workerFilter, setWorkerFilter] = useState(
|
||
searchParams.get("worker") || "all",
|
||
);
|
||
const [search, setSearch] = useState(searchParams.get("search") || "");
|
||
|
||
const requestedJobId = useMemo(() => {
|
||
const raw = Number.parseInt(searchParams.get("job") || "", 10);
|
||
return Number.isFinite(raw) ? raw : null;
|
||
}, [searchParams]);
|
||
|
||
useEffect(() => {
|
||
setStatusFilter(searchParams.get("status") || "all");
|
||
setKindFilter(searchParams.get("kind") || "all");
|
||
setWorkerFilter(searchParams.get("worker") || "all");
|
||
setSearch(searchParams.get("search") || "");
|
||
}, [searchParams]);
|
||
|
||
const loadData = useCallback(
|
||
async (showToast = false) => {
|
||
try {
|
||
if (showToast) {
|
||
setRefreshing(true);
|
||
}
|
||
const [nextOverview, nextJobs] = await Promise.all([
|
||
adminApi.getWorkersOverview(),
|
||
adminApi.listWorkerJobs({
|
||
status: statusFilter === "all" ? undefined : statusFilter,
|
||
jobKind: kindFilter === "all" ? undefined : kindFilter,
|
||
workerName: workerFilter === "all" ? undefined : workerFilter,
|
||
search: search.trim() || undefined,
|
||
limit: 120,
|
||
}),
|
||
]);
|
||
let nextJobList = nextJobs.jobs;
|
||
if (
|
||
requestedJobId &&
|
||
!nextJobList.some((item) => item.id === requestedJobId)
|
||
) {
|
||
try {
|
||
const requestedJob = await adminApi.getWorkerJob(requestedJobId);
|
||
nextJobList = [requestedJob, ...nextJobList];
|
||
} catch {
|
||
// ignore deep-link miss and keep current list
|
||
}
|
||
}
|
||
startTransition(() => {
|
||
setOverview(nextOverview);
|
||
setJobs(nextJobList);
|
||
setTotal(nextJobs.total);
|
||
});
|
||
if (showToast) {
|
||
toast.success("Worker 面板已刷新。");
|
||
}
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError ? error.message : "无法加载 worker 面板。",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
},
|
||
[kindFilter, requestedJobId, search, statusFilter, workerFilter],
|
||
);
|
||
|
||
useEffect(() => {
|
||
void loadData(false);
|
||
}, [loadData]);
|
||
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => {
|
||
void loadData(false);
|
||
}, 5000);
|
||
|
||
return () => window.clearInterval(timer);
|
||
}, [loadData]);
|
||
|
||
useEffect(() => {
|
||
setSelectedJobId((current) => {
|
||
if (requestedJobId) {
|
||
return requestedJobId;
|
||
}
|
||
if (current && jobs.some((item) => item.id === current)) {
|
||
return current;
|
||
}
|
||
return jobs[0]?.id ?? null;
|
||
});
|
||
}, [jobs, requestedJobId]);
|
||
|
||
const selectedJob = useMemo(
|
||
() => jobs.find((item) => item.id === selectedJobId) ?? null,
|
||
[jobs, selectedJobId],
|
||
);
|
||
|
||
const selectedWorkerLabel = useMemo(() => {
|
||
if (workerFilter === "all") {
|
||
return null;
|
||
}
|
||
return (
|
||
overview.catalog.find((item) => item.worker_name === workerFilter)
|
||
?.label ?? workerFilter
|
||
);
|
||
}, [overview.catalog, workerFilter]);
|
||
|
||
const selectedProgressText = selectedJob
|
||
? formatWorkerProgress(selectedJob)
|
||
: null;
|
||
const selectedProgressPercent = selectedJob
|
||
? getWorkerProgressPercent(selectedJob)
|
||
: null;
|
||
|
||
const runTask = useCallback(
|
||
async (task: "weekly" | "monthly" | "retry") => {
|
||
try {
|
||
setActioning(task);
|
||
const result =
|
||
task === "retry"
|
||
? await adminApi.runRetryDeliveriesWorker(80)
|
||
: await adminApi.runDigestWorker(task);
|
||
toast.success(
|
||
`已入队:#${result.job.id} ${result.job.display_name ?? result.job.worker_name}`,
|
||
);
|
||
await loadData(false);
|
||
setSelectedJobId(result.job.id);
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError ? error.message : "任务入队失败。",
|
||
);
|
||
} finally {
|
||
setActioning(null);
|
||
}
|
||
},
|
||
[loadData],
|
||
);
|
||
|
||
const workerOptions = overview.catalog.map((item) => ({
|
||
value: item.worker_name,
|
||
label: item.label,
|
||
}));
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<Skeleton className="h-56 rounded-3xl" />
|
||
<Skeleton className="h-[820px] rounded-3xl" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="secondary">Workers / Queue</Badge>
|
||
<Badge variant="outline">每 5 秒自动刷新</Badge>
|
||
{selectedWorkerLabel ? (
|
||
<Badge variant="outline">当前聚焦 {selectedWorkerLabel}</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
<div>
|
||
<h2 className="text-3xl font-semibold tracking-tight">
|
||
异步任务控制台
|
||
</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||
统一查看队列堆积、执行进度和失败任务。把手动调度、失败排查和运行状态都收在同一页里。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => void loadData(true)}
|
||
disabled={refreshing}
|
||
>
|
||
<RefreshCcw
|
||
className={cn("h-4 w-4", refreshing && "animate-spin")}
|
||
/>
|
||
{refreshing ? "刷新中..." : "刷新"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||
<SummaryCard
|
||
label="总任务"
|
||
value={overview.total_jobs}
|
||
hint={`${overview.worker_stats.length} 种 worker`}
|
||
icon={SquareTerminal}
|
||
tone={summaryIconTone("total")}
|
||
/>
|
||
<SummaryCard
|
||
label="活跃任务"
|
||
value={overview.active_jobs}
|
||
hint={`${overview.queued} queued · ${overview.running} running`}
|
||
icon={Activity}
|
||
tone={summaryIconTone("running")}
|
||
/>
|
||
<SummaryCard
|
||
label="成功 / 失败"
|
||
value={`${overview.succeeded} / ${overview.failed}`}
|
||
hint="完成情况"
|
||
icon={CheckCircle2}
|
||
tone={summaryIconTone(overview.failed > 0 ? "failed" : "succeeded")}
|
||
/>
|
||
<SummaryCard
|
||
label="人工操作"
|
||
value={`${jobs.filter((item) => item.can_retry || item.can_cancel).length}`}
|
||
hint="可取消或重跑"
|
||
icon={PlayCircle}
|
||
tone={summaryIconTone("queued")}
|
||
/>
|
||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||
<CardContent className="space-y-3 pt-6">
|
||
<div>
|
||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||
快捷操作
|
||
</div>
|
||
<div className="mt-2 text-sm leading-6 text-muted-foreground">
|
||
保留手动调度入口,但不再做成一整块独立视觉。
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||
<Button
|
||
variant="secondary"
|
||
data-testid="workers-run-weekly"
|
||
disabled={actioning !== null}
|
||
onClick={() => void runTask("weekly")}
|
||
>
|
||
<Send className="h-4 w-4" />
|
||
{actioning === "weekly" ? "入队中..." : "发送周报"}
|
||
</Button>
|
||
<Button
|
||
data-testid="workers-run-monthly"
|
||
disabled={actioning !== null}
|
||
onClick={() => void runTask("monthly")}
|
||
>
|
||
<Workflow className="h-4 w-4" />
|
||
{actioning === "monthly" ? "入队中..." : "发送月报"}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
data-testid="workers-run-retry"
|
||
disabled={actioning !== null}
|
||
onClick={() => void runTask("retry")}
|
||
>
|
||
<TimerReset className="h-4 w-4" />
|
||
{actioning === "retry" ? "处理中..." : "重试待投递"}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="border-border/70">
|
||
<CardHeader className="pb-4">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<CardTitle>Worker 视图</CardTitle>
|
||
<CardDescription>
|
||
每张卡代表一个 worker,优先显示失败、排队和最近一次活动。
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<Filter className="h-4 w-4" />
|
||
点击任意卡片即可快速锁定该 worker。
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{overview.worker_stats.length ? (
|
||
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||
{overview.worker_stats.map((item) => {
|
||
const totalJobs = Math.max(workerStatTotal(item), 1);
|
||
const isSelected = workerFilter === item.worker_name;
|
||
return (
|
||
<button
|
||
key={item.worker_name}
|
||
type="button"
|
||
className={cn(
|
||
"rounded-3xl border p-4 text-left transition",
|
||
"hover:border-primary/25 hover:bg-accent/40",
|
||
isSelected
|
||
? "border-primary/30 bg-primary/[0.05] shadow-[0_18px_45px_rgba(37,99,235,0.08)]"
|
||
: "border-border/70 bg-gradient-to-br from-card via-card to-background/70",
|
||
)}
|
||
onClick={() =>
|
||
setWorkerFilter((current) =>
|
||
current === item.worker_name ? "all" : item.worker_name,
|
||
)
|
||
}
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<div className="font-medium text-foreground">
|
||
{item.label}
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{item.worker_name}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 text-right">
|
||
<Badge variant="outline">{item.job_kind}</Badge>
|
||
<div
|
||
className={cn(
|
||
"text-xs font-medium",
|
||
workerHealthTone(item),
|
||
)}
|
||
>
|
||
{workerHealthLabel(item)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-border/70">
|
||
<div className="flex h-full">
|
||
<div
|
||
className="bg-sky-500/80"
|
||
style={{
|
||
width: `${(item.queued / totalJobs) * 100}%`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="bg-blue-500/85"
|
||
style={{
|
||
width: `${(item.running / totalJobs) * 100}%`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="bg-emerald-500/85"
|
||
style={{
|
||
width: `${(item.succeeded / totalJobs) * 100}%`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="bg-rose-500/85"
|
||
style={{
|
||
width: `${(item.failed / totalJobs) * 100}%`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="bg-amber-500/85"
|
||
style={{
|
||
width: `${(item.cancelled / totalJobs) * 100}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-5 gap-2 text-center">
|
||
{[
|
||
["Q", item.queued],
|
||
["R", item.running],
|
||
["OK", item.succeeded],
|
||
["ERR", item.failed],
|
||
["X", item.cancelled],
|
||
].map(([label, value]) => (
|
||
<div
|
||
key={String(label)}
|
||
className="rounded-2xl border border-border/60 bg-background/70 px-2 py-2"
|
||
>
|
||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||
{label}
|
||
</div>
|
||
<div className="mt-1 text-base font-semibold text-foreground">
|
||
{value}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center justify-between gap-4 text-xs text-muted-foreground">
|
||
<span>最近活动</span>
|
||
<span>
|
||
{item.last_job_at
|
||
? formatDateTime(item.last_job_at)
|
||
: "—"}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-3xl border border-dashed border-border/70 bg-background/60 px-5 py-10 text-center text-sm text-muted-foreground">
|
||
还没有可展示的 worker 统计。
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(0,24rem)]">
|
||
<Card className="border-border/70">
|
||
<CardHeader className="space-y-4">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<CardTitle>任务流</CardTitle>
|
||
<CardDescription>
|
||
当前筛选后共 {total} 条,列表保留最近 120 条记录。
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{statusFilter !== "all" ? (
|
||
<Badge variant={statusVariant(statusFilter)}>
|
||
{statusFilter}
|
||
</Badge>
|
||
) : null}
|
||
{kindFilter !== "all" ? (
|
||
<Badge variant="outline">{kindFilter}</Badge>
|
||
) : null}
|
||
{selectedWorkerLabel ? (
|
||
<Badge variant="outline">{selectedWorkerLabel}</Badge>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 lg:grid-cols-[180px_180px_220px_1fr]">
|
||
<Select
|
||
value={statusFilter}
|
||
onChange={(event) => setStatusFilter(event.target.value)}
|
||
>
|
||
<option value="all">全部状态</option>
|
||
<option value="queued">queued</option>
|
||
<option value="running">running</option>
|
||
<option value="succeeded">succeeded</option>
|
||
<option value="failed">failed</option>
|
||
<option value="cancelled">cancelled</option>
|
||
</Select>
|
||
<Select
|
||
value={kindFilter}
|
||
onChange={(event) => setKindFilter(event.target.value)}
|
||
>
|
||
<option value="all">全部类型</option>
|
||
<option value="worker">worker</option>
|
||
<option value="task">task</option>
|
||
</Select>
|
||
<Select
|
||
value={workerFilter}
|
||
onChange={(event) => setWorkerFilter(event.target.value)}
|
||
>
|
||
<option value="all">全部 worker</option>
|
||
{workerOptions.map((item) => (
|
||
<option key={item.value} value={item.value}>
|
||
{item.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Input
|
||
data-testid="workers-search"
|
||
placeholder="按 worker / display / entity 搜索"
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
/>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent>
|
||
{jobs.length ? (
|
||
<div className="space-y-3">
|
||
{jobs.map((item) => {
|
||
const progressText = formatWorkerProgress(item);
|
||
const progressPercent = getWorkerProgressPercent(item);
|
||
const isSelected = selectedJobId === item.id;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
data-testid={`worker-job-row-${item.id}`}
|
||
className={cn(
|
||
"w-full rounded-3xl border p-4 text-left transition",
|
||
"hover:border-primary/25 hover:bg-accent/40",
|
||
isSelected
|
||
? "border-primary/30 bg-primary/[0.05] shadow-[0_20px_50px_rgba(37,99,235,0.08)]"
|
||
: item.status === "failed"
|
||
? "border-rose-500/15 bg-rose-500/[0.03]"
|
||
: "border-border/70 bg-gradient-to-br from-card via-card to-background/70",
|
||
)}
|
||
onClick={() => setSelectedJobId(item.id)}
|
||
>
|
||
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div className="min-w-0 space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="outline">#{item.id}</Badge>
|
||
<Badge variant={statusVariant(item.status)}>
|
||
{item.status}
|
||
</Badge>
|
||
<Badge variant="outline">{item.job_kind}</Badge>
|
||
{item.cancel_requested ? (
|
||
<Badge variant="warning">cancel requested</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
<div>
|
||
<div className="break-all text-base font-semibold text-foreground [overflow-wrap:anywhere]">
|
||
{jobTitle(item)}
|
||
</div>
|
||
<div className="mt-1 break-all text-sm text-muted-foreground [overflow-wrap:anywhere]">
|
||
{item.worker_name}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid min-w-0 gap-2 text-sm text-muted-foreground lg:min-w-[18rem] lg:text-right">
|
||
<div>
|
||
入队:
|
||
{formatDateTime(item.queued_at ?? item.created_at)}
|
||
</div>
|
||
<div>
|
||
完成:
|
||
{item.finished_at
|
||
? formatDateTime(item.finished_at)
|
||
: "—"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-3 text-sm text-muted-foreground md:grid-cols-3">
|
||
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/80 px-3 py-3">
|
||
<div className="text-[11px] uppercase tracking-[0.18em]">
|
||
请求来源
|
||
</div>
|
||
<div className="mt-2 break-all text-foreground [overflow-wrap:anywhere]">
|
||
{item.requested_by ?? "system"}
|
||
<span className="text-muted-foreground">
|
||
{" · "}
|
||
{item.requested_source ?? "system"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/80 px-3 py-3">
|
||
<div className="text-[11px] uppercase tracking-[0.18em]">
|
||
关联实体
|
||
</div>
|
||
<div className="mt-2 text-foreground break-all">
|
||
{relatedEntityText(item)}
|
||
</div>
|
||
</div>
|
||
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/80 px-3 py-3">
|
||
<div className="text-[11px] uppercase tracking-[0.18em]">
|
||
尝试
|
||
</div>
|
||
<div className="mt-2 text-foreground">
|
||
{item.attempts_count} / {item.max_attempts}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{progressText ? (
|
||
<div className="mt-4 rounded-2xl border border-border/60 bg-background/80 px-3 py-3">
|
||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||
<span>进度</span>
|
||
{progressPercent !== null ? (
|
||
<span>{progressPercent}%</span>
|
||
) : null}
|
||
</div>
|
||
{progressPercent !== null ? (
|
||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-border/70">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||
style={{ width: `${progressPercent}%` }}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="mt-2 break-all text-sm leading-6 text-foreground [overflow-wrap:anywhere]">
|
||
{progressText}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-3xl border border-dashed border-border/70 bg-background/60 px-5 py-10 text-center text-sm text-muted-foreground">
|
||
当前筛选没有匹配任务。
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="min-w-0 xl:sticky xl:top-6 xl:self-start">
|
||
<Card className="overflow-hidden border-border/70">
|
||
<CardHeader className="space-y-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<CardTitle>任务详情</CardTitle>
|
||
<CardDescription>
|
||
选中左侧任务后,这里展示上下文、进度和原始数据。
|
||
</CardDescription>
|
||
</div>
|
||
<Clock3 className="h-5 w-5 text-muted-foreground" />
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="min-w-0 overflow-hidden">
|
||
{selectedJob ? (
|
||
<div className="min-w-0 space-y-4">
|
||
<div className="min-w-0 space-y-3 rounded-3xl border border-border/70 bg-background/70 p-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="outline">#{selectedJob.id}</Badge>
|
||
<Badge variant={statusVariant(selectedJob.status)}>
|
||
{selectedJob.status}
|
||
</Badge>
|
||
<Badge variant="secondary">{selectedJob.job_kind}</Badge>
|
||
</div>
|
||
|
||
<div className="min-w-0 overflow-hidden">
|
||
<div className="break-all text-lg font-semibold text-foreground [overflow-wrap:anywhere]">
|
||
{jobTitle(selectedJob)}
|
||
</div>
|
||
<div className="mt-1 break-all text-sm text-muted-foreground [overflow-wrap:anywhere]">
|
||
{selectedJob.worker_name}
|
||
</div>
|
||
</div>
|
||
|
||
{selectedProgressText ? (
|
||
<div className="rounded-2xl border border-border/60 bg-background/85 px-3 py-3">
|
||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||
<span>执行进度</span>
|
||
{selectedProgressPercent !== null ? (
|
||
<span>{selectedProgressPercent}%</span>
|
||
) : null}
|
||
</div>
|
||
{selectedProgressPercent !== null ? (
|
||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-border/70">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||
style={{ width: `${selectedProgressPercent}%` }}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="mt-2 break-all text-sm leading-6 text-foreground [overflow-wrap:anywhere]">
|
||
{selectedProgressText}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{[
|
||
["请求人", selectedJob.requested_by ?? "system"],
|
||
["来源", selectedJob.requested_source ?? "system"],
|
||
["关联实体", relatedEntityText(selectedJob)],
|
||
[
|
||
"尝试次数",
|
||
`${selectedJob.attempts_count} / ${selectedJob.max_attempts}`,
|
||
],
|
||
[
|
||
"排队时间",
|
||
formatDateTime(
|
||
selectedJob.queued_at ?? selectedJob.created_at,
|
||
),
|
||
],
|
||
[
|
||
"开始时间",
|
||
selectedJob.started_at
|
||
? formatDateTime(selectedJob.started_at)
|
||
: "—",
|
||
],
|
||
[
|
||
"完成时间",
|
||
selectedJob.finished_at
|
||
? formatDateTime(selectedJob.finished_at)
|
||
: "—",
|
||
],
|
||
[
|
||
"上游任务",
|
||
selectedJob.parent_job_id
|
||
? `#${selectedJob.parent_job_id}`
|
||
: "—",
|
||
],
|
||
].map(([label, value]) => (
|
||
<div
|
||
key={String(label)}
|
||
className="min-w-0 rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
|
||
>
|
||
<div className="text-[11px] uppercase tracking-[0.2em] text-muted-foreground">
|
||
{label}
|
||
</div>
|
||
<div className="mt-2 break-all text-sm text-foreground [overflow-wrap:anywhere]">
|
||
{value}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<Button
|
||
variant="outline"
|
||
disabled={
|
||
!selectedJob.can_cancel ||
|
||
actioning === `cancel-${selectedJob.id}`
|
||
}
|
||
data-testid="workers-cancel-job"
|
||
onClick={async () => {
|
||
try {
|
||
setActioning(`cancel-${selectedJob.id}`);
|
||
const result = await adminApi.cancelWorkerJob(
|
||
selectedJob.id,
|
||
);
|
||
toast.success(`任务 #${result.id} 已标记取消。`);
|
||
await loadData(false);
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError
|
||
? error.message
|
||
: "取消任务失败。",
|
||
);
|
||
} finally {
|
||
setActioning(null);
|
||
}
|
||
}}
|
||
>
|
||
<StopCircle className="h-4 w-4" />
|
||
{actioning === `cancel-${selectedJob.id}`
|
||
? "取消中..."
|
||
: "取消任务"}
|
||
</Button>
|
||
<Button
|
||
disabled={
|
||
!selectedJob.can_retry ||
|
||
actioning === `retry-${selectedJob.id}`
|
||
}
|
||
data-testid="workers-retry-job"
|
||
onClick={async () => {
|
||
try {
|
||
setActioning(`retry-${selectedJob.id}`);
|
||
const result = await adminApi.retryWorkerJob(
|
||
selectedJob.id,
|
||
);
|
||
toast.success(`已重跑,新的任务 #${result.job.id}`);
|
||
await loadData(false);
|
||
setSelectedJobId(result.job.id);
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof ApiError
|
||
? error.message
|
||
: "重跑任务失败。",
|
||
);
|
||
} finally {
|
||
setActioning(null);
|
||
}
|
||
}}
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
{actioning === `retry-${selectedJob.id}`
|
||
? "重跑中..."
|
||
: "重跑任务"}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="min-w-0 rounded-3xl border border-border/70 bg-background/70 p-4">
|
||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-foreground">
|
||
<SquareTerminal className="h-4 w-4 text-muted-foreground" />
|
||
Payload
|
||
</div>
|
||
<Textarea
|
||
readOnly
|
||
value={prettyJson(selectedJob.payload)}
|
||
className="min-h-[180px] break-all font-mono text-[12px] leading-6 [overflow-wrap:anywhere]"
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0 rounded-3xl border border-border/70 bg-background/70 p-4">
|
||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-foreground">
|
||
<Workflow className="h-4 w-4 text-muted-foreground" />
|
||
Result
|
||
</div>
|
||
<Textarea
|
||
readOnly
|
||
value={prettyJson(selectedJob.result)}
|
||
className="min-h-[180px] break-all font-mono text-[12px] leading-6 [overflow-wrap:anywhere]"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"min-w-0 rounded-3xl border p-4",
|
||
selectedJob.error_text
|
||
? "border-rose-500/20 bg-rose-500/[0.04]"
|
||
: "border-border/70 bg-background/70",
|
||
)}
|
||
>
|
||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-foreground">
|
||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||
Error
|
||
</div>
|
||
<Textarea
|
||
readOnly
|
||
value={selectedJob.error_text ?? "—"}
|
||
className="min-h-[132px] break-all font-mono text-[12px] leading-6 [overflow-wrap:anywhere]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-3xl border border-dashed border-border/70 bg-background/60 px-5 py-10 text-center text-sm text-muted-foreground">
|
||
暂无可查看的任务详情。
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|