Show AI reindex progress in admin
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (admin) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
ui-regression / playwright-regression (push) Failing after 8m14s
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (admin) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
ui-regression / playwright-regression (push) Failing after 8m14s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -7,16 +7,28 @@ import {
|
|||||||
StopCircle,
|
StopCircle,
|
||||||
TimerReset,
|
TimerReset,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
import {
|
||||||
import { toast } from 'sonner'
|
startTransition,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {
|
||||||
import { Input } from '@/components/ui/input'
|
Card,
|
||||||
import { Select } from '@/components/ui/select'
|
CardContent,
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -24,38 +36,39 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from "@/components/ui/table";
|
||||||
import { adminApi, ApiError } from '@/lib/api'
|
import { adminApi, ApiError } from "@/lib/api";
|
||||||
import type { WorkerJobRecord, WorkerOverview } from '@/lib/types'
|
import { formatWorkerProgress } from "@/lib/worker-progress";
|
||||||
import { cn } from '@/lib/utils'
|
import type { WorkerJobRecord, WorkerOverview } from "@/lib/types";
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
function prettyJson(value: unknown) {
|
function prettyJson(value: unknown) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return '—'
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2)
|
return JSON.stringify(value, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value)
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusVariant(status: string) {
|
function statusVariant(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'succeeded':
|
case "succeeded":
|
||||||
return 'success' as const
|
return "success" as const;
|
||||||
case 'running':
|
case "running":
|
||||||
return 'default' as const
|
return "default" as const;
|
||||||
case 'queued':
|
case "queued":
|
||||||
return 'secondary' as const
|
return "secondary" as const;
|
||||||
case 'failed':
|
case "failed":
|
||||||
return 'danger' as const
|
return "danger" as const;
|
||||||
case 'cancelled':
|
case "cancelled":
|
||||||
return 'warning' as const
|
return "warning" as const;
|
||||||
default:
|
default:
|
||||||
return 'outline' as const
|
return "outline" as const;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,124 +82,145 @@ const EMPTY_OVERVIEW: WorkerOverview = {
|
|||||||
active_jobs: 0,
|
active_jobs: 0,
|
||||||
worker_stats: [],
|
worker_stats: [],
|
||||||
catalog: [],
|
catalog: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
export function WorkersPage() {
|
export function WorkersPage() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams();
|
||||||
const [overview, setOverview] = useState<WorkerOverview>(EMPTY_OVERVIEW)
|
const [overview, setOverview] = useState<WorkerOverview>(EMPTY_OVERVIEW);
|
||||||
const [jobs, setJobs] = useState<WorkerJobRecord[]>([])
|
const [jobs, setJobs] = useState<WorkerJobRecord[]>([]);
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [actioning, setActioning] = useState<string | null>(null)
|
const [actioning, setActioning] = useState<string | null>(null);
|
||||||
const [selectedJobId, setSelectedJobId] = useState<number | null>(null)
|
const [selectedJobId, setSelectedJobId] = useState<number | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all')
|
const [statusFilter, setStatusFilter] = useState(
|
||||||
const [kindFilter, setKindFilter] = useState(searchParams.get('kind') || 'all')
|
searchParams.get("status") || "all",
|
||||||
const [workerFilter, setWorkerFilter] = useState(searchParams.get('worker') || 'all')
|
);
|
||||||
const [search, setSearch] = useState(searchParams.get('search') || '')
|
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 requestedJobId = useMemo(() => {
|
||||||
const raw = Number.parseInt(searchParams.get('job') || '', 10)
|
const raw = Number.parseInt(searchParams.get("job") || "", 10);
|
||||||
return Number.isFinite(raw) ? raw : null
|
return Number.isFinite(raw) ? raw : null;
|
||||||
}, [searchParams])
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStatusFilter(searchParams.get('status') || 'all')
|
setStatusFilter(searchParams.get("status") || "all");
|
||||||
setKindFilter(searchParams.get('kind') || 'all')
|
setKindFilter(searchParams.get("kind") || "all");
|
||||||
setWorkerFilter(searchParams.get('worker') || 'all')
|
setWorkerFilter(searchParams.get("worker") || "all");
|
||||||
setSearch(searchParams.get('search') || '')
|
setSearch(searchParams.get("search") || "");
|
||||||
}, [searchParams])
|
}, [searchParams]);
|
||||||
|
|
||||||
const loadData = useCallback(async (showToast = false) => {
|
const loadData = useCallback(
|
||||||
try {
|
async (showToast = false) => {
|
||||||
if (showToast) {
|
try {
|
||||||
setRefreshing(true)
|
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
|
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
},
|
||||||
setOverview(nextOverview)
|
[kindFilter, requestedJobId, search, statusFilter, workerFilter],
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
void loadData(false)
|
void loadData(false);
|
||||||
}, [loadData])
|
}, [loadData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void loadData(false)
|
void loadData(false);
|
||||||
}, 5000)
|
}, 5000);
|
||||||
|
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer);
|
||||||
}, [loadData])
|
}, [loadData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedJobId((current) => {
|
setSelectedJobId((current) => {
|
||||||
if (requestedJobId) {
|
if (requestedJobId) {
|
||||||
return requestedJobId
|
return requestedJobId;
|
||||||
}
|
}
|
||||||
if (current && jobs.some((item) => item.id === current)) {
|
if (current && jobs.some((item) => item.id === current)) {
|
||||||
return current
|
return current;
|
||||||
}
|
}
|
||||||
return jobs[0]?.id ?? null
|
return jobs[0]?.id ?? null;
|
||||||
})
|
});
|
||||||
}, [jobs, requestedJobId])
|
}, [jobs, requestedJobId]);
|
||||||
|
|
||||||
const selectedJob = useMemo(
|
const selectedJob = useMemo(
|
||||||
() => jobs.find((item) => item.id === selectedJobId) ?? null,
|
() => jobs.find((item) => item.id === selectedJobId) ?? null,
|
||||||
[jobs, selectedJobId],
|
[jobs, selectedJobId],
|
||||||
)
|
);
|
||||||
|
|
||||||
const runTask = useCallback(async (task: 'weekly' | 'monthly' | 'retry') => {
|
const runTask = useCallback(
|
||||||
try {
|
async (task: "weekly" | "monthly" | "retry") => {
|
||||||
setActioning(task)
|
try {
|
||||||
const result =
|
setActioning(task);
|
||||||
task === 'retry'
|
const result =
|
||||||
? await adminApi.runRetryDeliveriesWorker(80)
|
task === "retry"
|
||||||
: await adminApi.runDigestWorker(task)
|
? await adminApi.runRetryDeliveriesWorker(80)
|
||||||
toast.success(`已入队:#${result.job.id} ${result.job.display_name ?? result.job.worker_name}`)
|
: await adminApi.runDigestWorker(task);
|
||||||
await loadData(false)
|
toast.success(
|
||||||
setSelectedJobId(result.job.id)
|
`已入队:#${result.job.id} ${result.job.display_name ?? result.job.worker_name}`,
|
||||||
} catch (error) {
|
);
|
||||||
toast.error(error instanceof ApiError ? error.message : '任务入队失败。')
|
await loadData(false);
|
||||||
} finally {
|
setSelectedJobId(result.job.id);
|
||||||
setActioning(null)
|
} catch (error) {
|
||||||
}
|
toast.error(
|
||||||
}, [loadData])
|
error instanceof ApiError ? error.message : "任务入队失败。",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setActioning(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadData],
|
||||||
|
);
|
||||||
|
|
||||||
const workerOptions = overview.catalog.map((item) => ({
|
const workerOptions = overview.catalog.map((item) => ({
|
||||||
value: item.worker_name,
|
value: item.worker_name,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -194,7 +228,7 @@ export function WorkersPage() {
|
|||||||
<Skeleton className="h-40 rounded-3xl" />
|
<Skeleton className="h-40 rounded-3xl" />
|
||||||
<Skeleton className="h-[760px] rounded-3xl" />
|
<Skeleton className="h-[760px] rounded-3xl" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,78 +237,123 @@ export function WorkersPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Badge variant="secondary">Workers / Queue</Badge>
|
<Badge variant="secondary">Workers / Queue</Badge>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight">异步 Worker 控制台</h2>
|
<h2 className="text-3xl font-semibold tracking-tight">
|
||||||
|
异步 Worker 控制台
|
||||||
|
</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
统一查看后台下载、通知投递与 digest / 重试任务;支持筛选、查看详情、取消、重跑与手动触发。
|
统一查看后台下载、通知投递与 digest /
|
||||||
|
重试任务;支持筛选、查看详情、取消、重跑与手动触发。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void loadData(true)}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
{refreshing ? '刷新中...' : '刷新'}
|
{refreshing ? "刷新中..." : "刷新"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
data-testid="workers-run-weekly"
|
data-testid="workers-run-weekly"
|
||||||
disabled={actioning !== null}
|
disabled={actioning !== null}
|
||||||
onClick={() => void runTask('weekly')}
|
onClick={() => void runTask("weekly")}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
{actioning === 'weekly' ? '入队中...' : '发送周报'}
|
{actioning === "weekly" ? "入队中..." : "发送周报"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-testid="workers-run-monthly"
|
data-testid="workers-run-monthly"
|
||||||
disabled={actioning !== null}
|
disabled={actioning !== null}
|
||||||
onClick={() => void runTask('monthly')}
|
onClick={() => void runTask("monthly")}
|
||||||
>
|
>
|
||||||
<Workflow className="h-4 w-4" />
|
<Workflow className="h-4 w-4" />
|
||||||
{actioning === 'monthly' ? '入队中...' : '发送月报'}
|
{actioning === "monthly" ? "入队中..." : "发送月报"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
data-testid="workers-run-retry"
|
data-testid="workers-run-retry"
|
||||||
disabled={actioning !== null}
|
disabled={actioning !== null}
|
||||||
onClick={() => void runTask('retry')}
|
onClick={() => void runTask("retry")}
|
||||||
>
|
>
|
||||||
<TimerReset className="h-4 w-4" />
|
<TimerReset className="h-4 w-4" />
|
||||||
{actioning === 'retry' ? '处理中...' : '重试待投递'}
|
{actioning === "retry" ? "处理中..." : "重试待投递"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||||
{[
|
{[
|
||||||
{ label: '总任务', value: overview.total_jobs, hint: `${overview.worker_stats.length} 种 worker`, icon: SquareTerminal },
|
{
|
||||||
{ label: '排队中', value: overview.queued, hint: 'queued', icon: LoaderCircle },
|
label: "总任务",
|
||||||
{ label: '运行中', value: overview.running, hint: 'running', icon: Workflow },
|
value: overview.total_jobs,
|
||||||
{ label: '成功', value: overview.succeeded, hint: 'succeeded', icon: Send },
|
hint: `${overview.worker_stats.length} 种 worker`,
|
||||||
{ label: '失败', value: overview.failed, hint: 'failed', icon: RotateCcw },
|
icon: SquareTerminal,
|
||||||
{ label: '已取消', value: overview.cancelled, hint: 'cancelled', icon: StopCircle },
|
},
|
||||||
|
{
|
||||||
|
label: "排队中",
|
||||||
|
value: overview.queued,
|
||||||
|
hint: "queued",
|
||||||
|
icon: LoaderCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "运行中",
|
||||||
|
value: overview.running,
|
||||||
|
hint: "running",
|
||||||
|
icon: Workflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "成功",
|
||||||
|
value: overview.succeeded,
|
||||||
|
hint: "succeeded",
|
||||||
|
icon: Send,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "失败",
|
||||||
|
value: overview.failed,
|
||||||
|
hint: "failed",
|
||||||
|
icon: RotateCcw,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "已取消",
|
||||||
|
value: overview.cancelled,
|
||||||
|
hint: "cancelled",
|
||||||
|
icon: StopCircle,
|
||||||
|
},
|
||||||
].map((item) => {
|
].map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Card key={item.label}>
|
<Card key={item.label}>
|
||||||
<CardContent className="flex items-center justify-between gap-4 p-5">
|
<CardContent className="flex items-center justify-between gap-4 p-5">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{item.label}</div>
|
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
<div className="mt-2 text-3xl font-semibold tracking-tight">{item.value}</div>
|
{item.label}
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{item.hint}</div>
|
</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold tracking-tight">
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.hint}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-primary/20 bg-primary/10 p-3 text-primary">
|
<div className="rounded-2xl border border-primary/20 bg-primary/10 p-3 text-primary">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Worker 分类视图</CardTitle>
|
<CardTitle>Worker 分类视图</CardTitle>
|
||||||
<CardDescription>快速看每类 worker / task 当前堆积、失败与最近执行情况。</CardDescription>
|
<CardDescription>
|
||||||
|
快速看每类 worker / task 当前堆积、失败与最近执行情况。
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
<CardContent className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
{overview.worker_stats.map((item) => (
|
{overview.worker_stats.map((item) => (
|
||||||
@@ -282,34 +361,50 @@ export function WorkersPage() {
|
|||||||
key={item.worker_name}
|
key={item.worker_name}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-3xl border border-border/70 bg-background/50 p-4 text-left transition hover:border-primary/30 hover:bg-primary/5',
|
"rounded-3xl border border-border/70 bg-background/50 p-4 text-left transition hover:border-primary/30 hover:bg-primary/5",
|
||||||
workerFilter === item.worker_name && 'border-primary/40 bg-primary/10',
|
workerFilter === item.worker_name &&
|
||||||
|
"border-primary/40 bg-primary/10",
|
||||||
)}
|
)}
|
||||||
onClick={() => setWorkerFilter((current) => (current === item.worker_name ? 'all' : item.worker_name))}
|
onClick={() =>
|
||||||
|
setWorkerFilter((current) =>
|
||||||
|
current === item.worker_name ? "all" : item.worker_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-foreground">{item.label}</div>
|
<div className="text-sm font-medium text-foreground">
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.worker_name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{item.job_kind}</Badge>
|
<Badge variant="outline">{item.job_kind}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-5 gap-2 text-center text-xs">
|
<div className="mt-4 grid grid-cols-5 gap-2 text-center text-xs">
|
||||||
{[
|
{[
|
||||||
['Q', item.queued],
|
["Q", item.queued],
|
||||||
['R', item.running],
|
["R", item.running],
|
||||||
['OK', item.succeeded],
|
["OK", item.succeeded],
|
||||||
['ERR', item.failed],
|
["ERR", item.failed],
|
||||||
['X', item.cancelled],
|
["X", item.cancelled],
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={String(label)} className="rounded-2xl border border-border/70 px-2 py-2">
|
<div
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
key={String(label)}
|
||||||
<div className="mt-1 text-base font-semibold text-foreground">{value}</div>
|
className="rounded-2xl border border-border/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>
|
</div>
|
||||||
<div className="mt-3 text-xs text-muted-foreground">
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
最近任务:{item.last_job_at ?? '—'}
|
最近任务:{item.last_job_at ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -320,11 +415,16 @@ export function WorkersPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>任务历史</CardTitle>
|
<CardTitle>任务历史</CardTitle>
|
||||||
<CardDescription>当前筛选后共 {total} 条,列表保留最近 120 条任务记录。</CardDescription>
|
<CardDescription>
|
||||||
|
当前筛选后共 {total} 条,列表保留最近 120 条任务记录。
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-3 lg:grid-cols-[180px_180px_220px_1fr]">
|
<div className="grid gap-3 lg:grid-cols-[180px_180px_220px_1fr]">
|
||||||
<Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
|
>
|
||||||
<option value="all">全部状态</option>
|
<option value="all">全部状态</option>
|
||||||
<option value="queued">queued</option>
|
<option value="queued">queued</option>
|
||||||
<option value="running">running</option>
|
<option value="running">running</option>
|
||||||
@@ -332,12 +432,18 @@ export function WorkersPage() {
|
|||||||
<option value="failed">failed</option>
|
<option value="failed">failed</option>
|
||||||
<option value="cancelled">cancelled</option>
|
<option value="cancelled">cancelled</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={kindFilter} onChange={(event) => setKindFilter(event.target.value)}>
|
<Select
|
||||||
|
value={kindFilter}
|
||||||
|
onChange={(event) => setKindFilter(event.target.value)}
|
||||||
|
>
|
||||||
<option value="all">全部类型</option>
|
<option value="all">全部类型</option>
|
||||||
<option value="worker">worker</option>
|
<option value="worker">worker</option>
|
||||||
<option value="task">task</option>
|
<option value="task">task</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={workerFilter} onChange={(event) => setWorkerFilter(event.target.value)}>
|
<Select
|
||||||
|
value={workerFilter}
|
||||||
|
onChange={(event) => setWorkerFilter(event.target.value)}
|
||||||
|
>
|
||||||
<option value="all">全部 worker</option>
|
<option value="all">全部 worker</option>
|
||||||
{workerOptions.map((item) => (
|
{workerOptions.map((item) => (
|
||||||
<option key={item.value} value={item.value}>
|
<option key={item.value} value={item.value}>
|
||||||
@@ -370,44 +476,64 @@ export function WorkersPage() {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
data-testid={`worker-job-row-${item.id}`}
|
data-testid={`worker-job-row-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer',
|
"cursor-pointer",
|
||||||
selectedJobId === item.id && 'bg-primary/5',
|
selectedJobId === item.id && "bg-primary/5",
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedJobId(item.id)}
|
onClick={() => setSelectedJobId(item.id)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
<TableCell className="font-mono text-xs">
|
||||||
|
#{item.id}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="font-medium text-foreground">{item.display_name ?? item.worker_name}</div>
|
<div className="font-medium text-foreground">
|
||||||
<div className="text-xs text-muted-foreground">{item.worker_name}</div>
|
{item.display_name ?? item.worker_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.worker_name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
|
<Badge variant={statusVariant(item.status)}>
|
||||||
{item.cancel_requested ? <div className="text-[11px] text-amber-600">cancel requested</div> : null}
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
{formatWorkerProgress(item) ? (
|
||||||
|
<div className="text-[11px] leading-5 text-muted-foreground">
|
||||||
|
{formatWorkerProgress(item)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.cancel_requested ? (
|
||||||
|
<div className="text-[11px] text-amber-600">
|
||||||
|
cancel requested
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
<div>{item.job_kind}</div>
|
<div>{item.job_kind}</div>
|
||||||
<div>{item.requested_by ?? 'system'}</div>
|
<div>{item.requested_by ?? "system"}</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{item.related_entity_type && item.related_entity_id
|
{item.related_entity_type && item.related_entity_id
|
||||||
? `${item.related_entity_type}:${item.related_entity_id}`
|
? `${item.related_entity_type}:${item.related_entity_id}`
|
||||||
: '—'}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
<div>{item.queued_at ?? item.created_at}</div>
|
<div>{item.queued_at ?? item.created_at}</div>
|
||||||
<div>done: {item.finished_at ?? '—'}</div>
|
<div>done: {item.finished_at ?? "—"}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!jobs.length ? (
|
{!jobs.length ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="py-10 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
当前筛选没有匹配任务。
|
当前筛选没有匹配任务。
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -420,14 +546,18 @@ export function WorkersPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>任务详情</CardTitle>
|
<CardTitle>任务详情</CardTitle>
|
||||||
<CardDescription>查看 payload / result / error,并对单个任务执行取消或重跑。</CardDescription>
|
<CardDescription>
|
||||||
|
查看 payload / result / error,并对单个任务执行取消或重跑。
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{selectedJob ? (
|
{selectedJob ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline">#{selectedJob.id}</Badge>
|
<Badge variant="outline">#{selectedJob.id}</Badge>
|
||||||
<Badge variant={statusVariant(selectedJob.status)}>{selectedJob.status}</Badge>
|
<Badge variant={statusVariant(selectedJob.status)}>
|
||||||
|
{selectedJob.status}
|
||||||
|
</Badge>
|
||||||
<Badge variant="secondary">{selectedJob.job_kind}</Badge>
|
<Badge variant="secondary">{selectedJob.job_kind}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -435,84 +565,155 @@ export function WorkersPage() {
|
|||||||
<h3 className="text-xl font-semibold tracking-tight text-foreground">
|
<h3 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
{selectedJob.display_name ?? selectedJob.worker_name}
|
{selectedJob.display_name ?? selectedJob.worker_name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{selectedJob.worker_name}</p>
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
{selectedJob.worker_name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{[
|
{[
|
||||||
['请求人', selectedJob.requested_by ?? 'system'],
|
["请求人", selectedJob.requested_by ?? "system"],
|
||||||
['来源', selectedJob.requested_source ?? 'system'],
|
["来源", selectedJob.requested_source ?? "system"],
|
||||||
['关联实体', selectedJob.related_entity_type && selectedJob.related_entity_id ? `${selectedJob.related_entity_type}:${selectedJob.related_entity_id}` : '—'],
|
[
|
||||||
['尝试次数', `${selectedJob.attempts_count} / ${selectedJob.max_attempts}`],
|
"关联实体",
|
||||||
['排队时间', selectedJob.queued_at ?? selectedJob.created_at],
|
selectedJob.related_entity_type &&
|
||||||
['开始时间', selectedJob.started_at ?? '—'],
|
selectedJob.related_entity_id
|
||||||
['完成时间', selectedJob.finished_at ?? '—'],
|
? `${selectedJob.related_entity_type}:${selectedJob.related_entity_id}`
|
||||||
['上游任务', selectedJob.parent_job_id ? `#${selectedJob.parent_job_id}` : '—'],
|
: "—",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"尝试次数",
|
||||||
|
`${selectedJob.attempts_count} / ${selectedJob.max_attempts}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"排队时间",
|
||||||
|
selectedJob.queued_at ?? selectedJob.created_at,
|
||||||
|
],
|
||||||
|
["开始时间", selectedJob.started_at ?? "—"],
|
||||||
|
["完成时间", selectedJob.finished_at ?? "—"],
|
||||||
|
[
|
||||||
|
"上游任务",
|
||||||
|
selectedJob.parent_job_id
|
||||||
|
? `#${selectedJob.parent_job_id}`
|
||||||
|
: "—",
|
||||||
|
],
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={String(label)} className="rounded-2xl border border-border/70 bg-background/50 px-4 py-3">
|
<div
|
||||||
<div className="text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{label}</div>
|
key={String(label)}
|
||||||
<div className="mt-2 text-sm text-foreground break-all">{value}</div>
|
className="rounded-2xl border border-border/70 bg-background/50 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-foreground break-all">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{formatWorkerProgress(selectedJob) ? (
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background/50 px-4 py-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
进度
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 text-foreground">
|
||||||
|
{formatWorkerProgress(selectedJob)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!selectedJob.can_cancel || actioning === `cancel-${selectedJob.id}`}
|
disabled={
|
||||||
|
!selectedJob.can_cancel ||
|
||||||
|
actioning === `cancel-${selectedJob.id}`
|
||||||
|
}
|
||||||
data-testid="workers-cancel-job"
|
data-testid="workers-cancel-job"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setActioning(`cancel-${selectedJob.id}`)
|
setActioning(`cancel-${selectedJob.id}`);
|
||||||
const result = await adminApi.cancelWorkerJob(selectedJob.id)
|
const result = await adminApi.cancelWorkerJob(
|
||||||
toast.success(`任务 #${result.id} 已标记取消。`)
|
selectedJob.id,
|
||||||
await loadData(false)
|
);
|
||||||
|
toast.success(`任务 #${result.id} 已标记取消。`);
|
||||||
|
await loadData(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof ApiError ? error.message : '取消任务失败。')
|
toast.error(
|
||||||
|
error instanceof ApiError
|
||||||
|
? error.message
|
||||||
|
: "取消任务失败。",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setActioning(null)
|
setActioning(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StopCircle className="h-4 w-4" />
|
<StopCircle className="h-4 w-4" />
|
||||||
{actioning === `cancel-${selectedJob.id}` ? '取消中...' : '取消任务'}
|
{actioning === `cancel-${selectedJob.id}`
|
||||||
|
? "取消中..."
|
||||||
|
: "取消任务"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={!selectedJob.can_retry || actioning === `retry-${selectedJob.id}`}
|
disabled={
|
||||||
|
!selectedJob.can_retry ||
|
||||||
|
actioning === `retry-${selectedJob.id}`
|
||||||
|
}
|
||||||
data-testid="workers-retry-job"
|
data-testid="workers-retry-job"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setActioning(`retry-${selectedJob.id}`)
|
setActioning(`retry-${selectedJob.id}`);
|
||||||
const result = await adminApi.retryWorkerJob(selectedJob.id)
|
const result = await adminApi.retryWorkerJob(
|
||||||
toast.success(`已重跑,新的任务 #${result.job.id}`)
|
selectedJob.id,
|
||||||
await loadData(false)
|
);
|
||||||
setSelectedJobId(result.job.id)
|
toast.success(`已重跑,新的任务 #${result.job.id}`);
|
||||||
|
await loadData(false);
|
||||||
|
setSelectedJobId(result.job.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof ApiError ? error.message : '重跑任务失败。')
|
toast.error(
|
||||||
|
error instanceof ApiError
|
||||||
|
? error.message
|
||||||
|
: "重跑任务失败。",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setActioning(null)
|
setActioning(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
{actioning === `retry-${selectedJob.id}` ? '重跑中...' : '重跑任务'}
|
{actioning === `retry-${selectedJob.id}`
|
||||||
|
? "重跑中..."
|
||||||
|
: "重跑任务"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">Payload</div>
|
<div className="mb-2 text-sm font-medium text-foreground">
|
||||||
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{prettyJson(selectedJob.payload)}</pre>
|
Payload
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">
|
||||||
|
{prettyJson(selectedJob.payload)}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">Result</div>
|
<div className="mb-2 text-sm font-medium text-foreground">
|
||||||
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{prettyJson(selectedJob.result)}</pre>
|
Result
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">
|
||||||
|
{prettyJson(selectedJob.result)}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-sm font-medium text-foreground">Error</div>
|
<div className="mb-2 text-sm font-medium text-foreground">
|
||||||
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{selectedJob.error_text ?? '—'}</pre>
|
Error
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">
|
||||||
|
{selectedJob.error_text ?? "—"}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -525,5 +726,5 @@ export function WorkersPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user