Files
termi-blog/admin/src/pages/workers-page.tsx

1008 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}