feat: add worker operations and fix gitea actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s

This commit is contained in:
2026-04-02 03:43:37 +08:00
parent ee0bec4a78
commit a516be2e91
37 changed files with 3890 additions and 879 deletions

View File

@@ -0,0 +1,529 @@
import {
LoaderCircle,
RefreshCcw,
RotateCcw,
Send,
SquareTerminal,
StopCircle,
TimerReset,
Workflow,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { WorkerJobRecord, WorkerOverview } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useSearchParams } from 'react-router-dom'
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
}
}
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 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-40 rounded-3xl" />
<Skeleton className="h-[760px] 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">
<Badge variant="secondary">Workers / Queue</Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> Worker </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
digest /
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<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>
</div>
<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: '运行中', 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) => {
const Icon = item.icon
return (
<Card key={item.label}>
<CardContent className="flex items-center justify-between gap-4 p-5">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{item.label}</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 className="rounded-2xl border border-primary/20 bg-primary/10 p-3 text-primary">
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
)
})}
</div>
<Card>
<CardHeader>
<CardTitle>Worker </CardTitle>
<CardDescription> worker / task </CardDescription>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{overview.worker_stats.map((item) => (
<button
key={item.worker_name}
type="button"
className={cn(
'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',
)}
onClick={() => setWorkerFilter((current) => (current === item.worker_name ? 'all' : item.worker_name))}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">{item.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
</div>
<Badge variant="outline">{item.job_kind}</Badge>
</div>
<div className="mt-4 grid grid-cols-5 gap-2 text-center text-xs">
{[
['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/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-3 text-xs text-muted-foreground">
{item.last_job_at ?? '—'}
</div>
</button>
))}
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[1.4fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> {total} 120 </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((item) => (
<TableRow
key={item.id}
data-testid={`worker-job-row-${item.id}`}
className={cn(
'cursor-pointer',
selectedJobId === item.id && 'bg-primary/5',
)}
onClick={() => setSelectedJobId(item.id)}
>
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium text-foreground">{item.display_name ?? item.worker_name}</div>
<div className="text-xs text-muted-foreground">{item.worker_name}</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
{item.cancel_requested ? <div className="text-[11px] text-amber-600">cancel requested</div> : null}
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-xs text-muted-foreground">
<div>{item.job_kind}</div>
<div>{item.requested_by ?? 'system'}</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{item.related_entity_type && item.related_entity_id
? `${item.related_entity_type}:${item.related_entity_id}`
: '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div>{item.queued_at ?? item.created_at}</div>
<div>done: {item.finished_at ?? '—'}</div>
</TableCell>
</TableRow>
))}
{!jobs.length ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> payload / result / error</CardDescription>
</CardHeader>
<CardContent>
{selectedJob ? (
<div className="space-y-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>
<h3 className="text-xl font-semibold tracking-tight text-foreground">
{selectedJob.display_name ?? selectedJob.worker_name}
</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{selectedJob.worker_name}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
['请求人', selectedJob.requested_by ?? '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.started_at ?? '—'],
['完成时间', selectedJob.finished_at ?? '—'],
['上游任务', selectedJob.parent_job_id ? `#${selectedJob.parent_job_id}` : '—'],
].map(([label, value]) => (
<div key={String(label)} 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 className="flex flex-wrap gap-3">
<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>
<div className="mb-2 text-sm font-medium text-foreground">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 className="mb-2 text-sm font-medium text-foreground">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 className="mb-2 text-sm font-medium text-foreground">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 className="rounded-3xl border border-dashed border-border/70 bg-background/50 px-5 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}