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
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:
@@ -94,6 +94,10 @@ const SubscriptionsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/subscriptions-page')
|
||||
return { default: mod.SubscriptionsPage }
|
||||
})
|
||||
const WorkersPage = lazy(async () => {
|
||||
const mod = await import('@/pages/workers-page')
|
||||
return { default: mod.WorkersPage }
|
||||
})
|
||||
|
||||
type SessionContextValue = {
|
||||
session: AdminSessionResponse
|
||||
@@ -389,6 +393,14 @@ function AppRoutes() {
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workers"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<WorkersPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="audit"
|
||||
element={
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Settings,
|
||||
Sparkles,
|
||||
Tags,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
@@ -99,6 +100,12 @@ const primaryNav = [
|
||||
description: '邮件 / Webhook 推送',
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
to: '/workers',
|
||||
label: 'Workers',
|
||||
description: '异步任务 / 队列控制台',
|
||||
icon: Workflow,
|
||||
},
|
||||
{
|
||||
to: '/audit',
|
||||
label: '审计',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AdminAiProviderTestResponse,
|
||||
AdminImageUploadResponse,
|
||||
AdminMediaBatchDeleteResponse,
|
||||
AdminMediaDownloadResponse,
|
||||
AdminMediaDeleteResponse,
|
||||
AdminMediaListResponse,
|
||||
AdminMediaMetadataResponse,
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
MarkdownDocumentResponse,
|
||||
MarkdownImportResponse,
|
||||
MediaAssetMetadataPayload,
|
||||
MediaDownloadPayload,
|
||||
NotificationDeliveryRecord,
|
||||
PostPageResponse,
|
||||
PostListQuery,
|
||||
@@ -53,6 +55,10 @@ import type {
|
||||
SubscriptionPayload,
|
||||
SubscriptionRecord,
|
||||
SubscriptionUpdatePayload,
|
||||
WorkerJobListResponse,
|
||||
WorkerJobRecord,
|
||||
WorkerOverview,
|
||||
WorkerTaskActionResponse,
|
||||
TagRecord,
|
||||
TaxonomyPayload,
|
||||
UpdateCommentPayload,
|
||||
@@ -236,7 +242,7 @@ export const adminApi = {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
testSubscription: (id: number) =>
|
||||
request<{ queued: boolean; id: number; delivery_id: number }>(`/api/admin/subscriptions/${id}/test`, {
|
||||
request<{ queued: boolean; id: number; delivery_id: number; job_id?: number | null }>(`/api/admin/subscriptions/${id}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
listSubscriptionDeliveries: async (limit = 80) =>
|
||||
@@ -248,6 +254,42 @@ export const adminApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ period }),
|
||||
}),
|
||||
getWorkersOverview: () => request<WorkerOverview>('/api/admin/workers/overview'),
|
||||
listWorkerJobs: (query?: {
|
||||
status?: string
|
||||
jobKind?: string
|
||||
workerName?: string
|
||||
search?: string
|
||||
limit?: number
|
||||
}) =>
|
||||
request<WorkerJobListResponse>(
|
||||
appendQueryParams('/api/admin/workers/jobs', {
|
||||
status: query?.status,
|
||||
job_kind: query?.jobKind,
|
||||
worker_name: query?.workerName,
|
||||
search: query?.search,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
getWorkerJob: (id: number) => request<WorkerJobRecord>(`/api/admin/workers/jobs/${id}`),
|
||||
cancelWorkerJob: (id: number) =>
|
||||
request<WorkerJobRecord>(`/api/admin/workers/jobs/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
retryWorkerJob: (id: number) =>
|
||||
request<WorkerTaskActionResponse>(`/api/admin/workers/jobs/${id}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
runRetryDeliveriesWorker: (limit?: number) =>
|
||||
request<WorkerTaskActionResponse>('/api/admin/workers/tasks/retry-deliveries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ limit }),
|
||||
}),
|
||||
runDigestWorker: (period: 'weekly' | 'monthly') =>
|
||||
request<WorkerTaskActionResponse>('/api/admin/workers/tasks/digest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ period }),
|
||||
}),
|
||||
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
|
||||
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
|
||||
listCategories: () => request<CategoryRecord[]>('/api/admin/categories'),
|
||||
@@ -405,6 +447,19 @@ export const adminApi = {
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
downloadMediaObject: (payload: MediaDownloadPayload) =>
|
||||
request<AdminMediaDownloadResponse>('/api/admin/storage/media/download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
source_url: payload.sourceUrl,
|
||||
prefix: payload.prefix,
|
||||
title: payload.title,
|
||||
alt_text: payload.altText,
|
||||
caption: payload.caption,
|
||||
tags: payload.tags,
|
||||
notes: payload.notes,
|
||||
}),
|
||||
}),
|
||||
updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) =>
|
||||
request<AdminMediaMetadataResponse>('/api/admin/storage/media/metadata', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -125,6 +125,79 @@ export interface SubscriptionDigestResponse {
|
||||
skipped: number
|
||||
}
|
||||
|
||||
export interface WorkerCatalogEntry {
|
||||
worker_name: string
|
||||
job_kind: string
|
||||
label: string
|
||||
description: string
|
||||
queue_name: string | null
|
||||
supports_cancel: boolean
|
||||
supports_retry: boolean
|
||||
}
|
||||
|
||||
export interface WorkerStats {
|
||||
worker_name: string
|
||||
job_kind: string
|
||||
label: string
|
||||
queued: number
|
||||
running: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
cancelled: number
|
||||
last_job_at: string | null
|
||||
}
|
||||
|
||||
export interface WorkerOverview {
|
||||
total_jobs: number
|
||||
queued: number
|
||||
running: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
cancelled: number
|
||||
active_jobs: number
|
||||
worker_stats: WorkerStats[]
|
||||
catalog: WorkerCatalogEntry[]
|
||||
}
|
||||
|
||||
export interface WorkerJobRecord {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
id: number
|
||||
parent_job_id: number | null
|
||||
job_kind: string
|
||||
worker_name: string
|
||||
display_name: string | null
|
||||
status: string
|
||||
queue_name: string | null
|
||||
requested_by: string | null
|
||||
requested_source: string | null
|
||||
trigger_mode: string | null
|
||||
payload: Record<string, unknown> | null
|
||||
result: Record<string, unknown> | null
|
||||
error_text: string | null
|
||||
tags: unknown[] | Record<string, unknown> | null
|
||||
related_entity_type: string | null
|
||||
related_entity_id: string | null
|
||||
attempts_count: number
|
||||
max_attempts: number
|
||||
cancel_requested: boolean
|
||||
queued_at: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
can_cancel: boolean
|
||||
can_retry: boolean
|
||||
}
|
||||
|
||||
export interface WorkerJobListResponse {
|
||||
total: number
|
||||
jobs: WorkerJobRecord[]
|
||||
}
|
||||
|
||||
export interface WorkerTaskActionResponse {
|
||||
queued: boolean
|
||||
job: WorkerJobRecord
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_posts: number
|
||||
total_comments: number
|
||||
@@ -533,6 +606,22 @@ export interface AdminMediaReplaceResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface MediaDownloadPayload {
|
||||
sourceUrl: string
|
||||
prefix?: string | null
|
||||
title?: string | null
|
||||
altText?: string | null
|
||||
caption?: string | null
|
||||
tags?: string[]
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export interface AdminMediaDownloadResponse {
|
||||
queued: boolean
|
||||
job_id: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MediaAssetMetadataPayload {
|
||||
key: string
|
||||
title?: string | null
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
Rss,
|
||||
Star,
|
||||
Tags,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -35,7 +37,7 @@ import {
|
||||
formatReviewStatus,
|
||||
formatReviewType,
|
||||
} from '@/lib/admin-format'
|
||||
import type { AdminDashboardResponse } from '@/lib/types'
|
||||
import type { AdminDashboardResponse, WorkerOverview } from '@/lib/types'
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
@@ -66,6 +68,7 @@ function StatCard({
|
||||
|
||||
export function DashboardPage() {
|
||||
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
||||
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
@@ -75,9 +78,13 @@ export function DashboardPage() {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const next = await adminApi.dashboard()
|
||||
const [next, nextWorkerOverview] = await Promise.all([
|
||||
adminApi.dashboard(),
|
||||
adminApi.getWorkersOverview(),
|
||||
])
|
||||
startTransition(() => {
|
||||
setData(next)
|
||||
setWorkerOverview(nextWorkerOverview)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
@@ -98,7 +105,7 @@ export function DashboardPage() {
|
||||
void loadDashboard(false)
|
||||
}, [loadDashboard])
|
||||
|
||||
if (loading || !data) {
|
||||
if (loading || !data || !workerOverview) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -146,6 +153,12 @@ export function DashboardPage() {
|
||||
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
{
|
||||
label: 'Worker 活动',
|
||||
value: workerOverview.active_jobs,
|
||||
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
|
||||
icon: Workflow,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -314,6 +327,75 @@ export function DashboardPage() {
|
||||
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Worker 健康
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
当前排队 {workerOverview.queued}、运行 {workerOverview.running}、失败 {workerOverview.failed}。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'}
|
||||
data-testid="dashboard-worker-open"
|
||||
>
|
||||
查看队列
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<Link
|
||||
to="/workers?status=queued"
|
||||
data-testid="dashboard-worker-card-queued"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Queued</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/workers?status=running"
|
||||
data-testid="dashboard-worker-card-running"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Running</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/workers?status=failed"
|
||||
data-testid="dashboard-worker-card-failed"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Failed</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{workerOverview.worker_stats.length ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{workerOverview.worker_stats.slice(0, 3).map((item) => (
|
||||
<Link
|
||||
key={item.worker_name}
|
||||
to={`/workers?worker=${encodeURIComponent(item.worker_name)}`}
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div>
|
||||
<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="text-right text-xs text-muted-foreground">
|
||||
<div>Q {item.queued} · R {item.running}</div>
|
||||
<div>ERR {item.failed}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
CheckSquare,
|
||||
Copy,
|
||||
Download,
|
||||
Image as ImageIcon,
|
||||
RefreshCcw,
|
||||
Replace,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -58,6 +60,24 @@ const defaultMetadataForm: MediaMetadataFormState = {
|
||||
notes: '',
|
||||
}
|
||||
|
||||
type RemoteDownloadFormState = {
|
||||
sourceUrl: string
|
||||
title: string
|
||||
altText: string
|
||||
caption: string
|
||||
tags: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const defaultRemoteDownloadForm: RemoteDownloadFormState = {
|
||||
sourceUrl: '',
|
||||
title: '',
|
||||
altText: '',
|
||||
caption: '',
|
||||
tags: '',
|
||||
notes: '',
|
||||
}
|
||||
|
||||
function normalizeMediaTags(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
@@ -121,6 +141,11 @@ export function MediaPage() {
|
||||
const [metadataSaving, setMetadataSaving] = useState(false)
|
||||
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
||||
const [compressQuality, setCompressQuality] = useState('0.82')
|
||||
const [remoteDownloadForm, setRemoteDownloadForm] = useState<RemoteDownloadFormState>(
|
||||
defaultRemoteDownloadForm,
|
||||
)
|
||||
const [downloadingRemote, setDownloadingRemote] = useState(false)
|
||||
const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState<number | null>(null)
|
||||
|
||||
const loadItems = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
@@ -352,6 +377,147 @@ export function MediaPage() {
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-3xl border border-border/70 bg-background/50 p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">远程抓取到媒体库</p>
|
||||
<p className="text-xs leading-6 text-muted-foreground">
|
||||
输入可访问的图片 / PDF 直链后,会创建异步 worker 任务;下载完成后写入当前目录前缀,并同步媒体元数据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<FormField label="远程 URL">
|
||||
<Input
|
||||
data-testid="media-remote-url"
|
||||
value={remoteDownloadForm.sourceUrl}
|
||||
onChange={(event) =>
|
||||
setRemoteDownloadForm((current) => ({
|
||||
...current,
|
||||
sourceUrl: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://example.com/cover.webp"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
data-testid="media-remote-title"
|
||||
value={remoteDownloadForm.title}
|
||||
onChange={(event) =>
|
||||
setRemoteDownloadForm((current) => ({
|
||||
...current,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="远程抓取封面"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Alt 文本">
|
||||
<Input
|
||||
value={remoteDownloadForm.altText}
|
||||
onChange={(event) =>
|
||||
setRemoteDownloadForm((current) => ({
|
||||
...current,
|
||||
altText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="终端风格封面图"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="标签">
|
||||
<Input
|
||||
value={remoteDownloadForm.tags}
|
||||
onChange={(event) =>
|
||||
setRemoteDownloadForm((current) => ({
|
||||
...current,
|
||||
tags: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="remote, cover"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="Caption">
|
||||
<Textarea
|
||||
value={remoteDownloadForm.caption}
|
||||
onChange={(event) =>
|
||||
setRemoteDownloadForm((current) => ({
|
||||
...current,
|
||||
caption: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="适合记录图片用途或展示位置。"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="内部备注">
|
||||
<Textarea
|
||||
value={remoteDownloadForm.notes}
|
||||
onChange={(event) =>
|
||||
setRemoteDownloadForm((current) => ({
|
||||
...current,
|
||||
notes: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="可选:记录素材来源、版权说明等。"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Button
|
||||
data-testid="media-remote-download"
|
||||
disabled={!remoteDownloadForm.sourceUrl.trim() || downloadingRemote}
|
||||
onClick={async () => {
|
||||
if (!remoteDownloadForm.sourceUrl.trim()) {
|
||||
toast.error('请先填写远程 URL。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDownloadingRemote(true)
|
||||
const result = await adminApi.downloadMediaObject({
|
||||
sourceUrl: remoteDownloadForm.sourceUrl.trim(),
|
||||
prefix: uploadPrefix,
|
||||
title: remoteDownloadForm.title.trim() || null,
|
||||
altText: remoteDownloadForm.altText.trim() || null,
|
||||
caption: remoteDownloadForm.caption.trim() || null,
|
||||
tags: parseTagList(remoteDownloadForm.tags),
|
||||
notes: remoteDownloadForm.notes.trim() || null,
|
||||
})
|
||||
setLastRemoteDownloadJobId(result.job_id)
|
||||
toast.success(`远程抓取任务已入队:#${result.job_id}`)
|
||||
setRemoteDownloadForm(defaultRemoteDownloadForm)
|
||||
window.setTimeout(() => {
|
||||
void loadItems(false)
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '远程抓取失败。')
|
||||
} finally {
|
||||
setDownloadingRemote(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{downloadingRemote ? '抓取中...' : '抓取远程素材'}
|
||||
</Button>
|
||||
{lastRemoteDownloadJobId ? (
|
||||
<Button variant="outline" asChild data-testid="media-last-remote-job">
|
||||
<Link to={`/workers?job=${lastRemoteDownloadJobId}`}>查看任务详情</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types'
|
||||
import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types'
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
@@ -80,6 +81,8 @@ export function SubscriptionsPage() {
|
||||
const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null)
|
||||
const [actioningId, setActioningId] = useState<number | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [workerJobs, setWorkerJobs] = useState<WorkerJobRecord[]>([])
|
||||
const [lastActionJobId, setLastActionJobId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState(emptyForm())
|
||||
|
||||
const loadData = useCallback(async (showToast = false) => {
|
||||
@@ -87,13 +90,18 @@ export function SubscriptionsPage() {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const [nextSubscriptions, nextDeliveries] = await Promise.all([
|
||||
const [nextSubscriptions, nextDeliveries, nextWorkerJobs] = await Promise.all([
|
||||
adminApi.listSubscriptions(),
|
||||
adminApi.listSubscriptionDeliveries(),
|
||||
adminApi.listWorkerJobs({
|
||||
workerName: 'worker.notification_delivery',
|
||||
limit: 200,
|
||||
}),
|
||||
])
|
||||
startTransition(() => {
|
||||
setSubscriptions(nextSubscriptions)
|
||||
setDeliveries(nextDeliveries)
|
||||
setWorkerJobs(nextWorkerJobs.jobs)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('订阅中心已刷新。')
|
||||
@@ -123,6 +131,17 @@ export function SubscriptionsPage() {
|
||||
[deliveries],
|
||||
)
|
||||
|
||||
const deliveryJobMap = useMemo(() => {
|
||||
const map = new Map<number, WorkerJobRecord>()
|
||||
for (const item of workerJobs) {
|
||||
const relatedId = Number.parseInt(String(item.related_entity_id || ''), 10)
|
||||
if (Number.isFinite(relatedId) && !map.has(relatedId)) {
|
||||
map.set(relatedId, item)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [workerJobs])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
@@ -192,8 +211,9 @@ export function SubscriptionsPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('weekly')
|
||||
const result = await adminApi.sendSubscriptionDigest('weekly')
|
||||
toast.success(`周报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||
const result = await adminApi.runDigestWorker('weekly')
|
||||
setLastActionJobId(result.job.id)
|
||||
toast.success(`周报任务已入队:#${result.job.id}`)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
|
||||
@@ -211,8 +231,9 @@ export function SubscriptionsPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('monthly')
|
||||
const result = await adminApi.sendSubscriptionDigest('monthly')
|
||||
toast.success(`月报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||
const result = await adminApi.runDigestWorker('monthly')
|
||||
setLastActionJobId(result.job.id)
|
||||
toast.success(`月报任务已入队:#${result.job.id}`)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
|
||||
@@ -224,6 +245,11 @@ export function SubscriptionsPage() {
|
||||
<BellRing className="h-4 w-4" />
|
||||
{digesting === 'monthly' ? '入队中...' : '发送月报'}
|
||||
</Button>
|
||||
{lastActionJobId ? (
|
||||
<Button variant="outline" asChild data-testid="subscriptions-last-job">
|
||||
<Link to={`/workers?job=${lastActionJobId}`}>查看最近任务</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -414,8 +440,15 @@ export function SubscriptionsPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
await adminApi.testSubscription(item.id)
|
||||
toast.success('测试通知已入队。')
|
||||
const result = await adminApi.testSubscription(item.id)
|
||||
if (result.job_id) {
|
||||
setLastActionJobId(result.job_id)
|
||||
}
|
||||
toast.success(
|
||||
result.job_id
|
||||
? `测试通知已入队:#${result.job_id}`
|
||||
: '测试通知已入队。',
|
||||
)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
|
||||
@@ -478,11 +511,14 @@ export function SubscriptionsPage() {
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>重试</TableHead>
|
||||
<TableHead>Worker</TableHead>
|
||||
<TableHead>响应</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deliveries.map((item) => (
|
||||
{deliveries.map((item) => {
|
||||
const workerJob = deliveryJobMap.get(item.id)
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
|
||||
<TableCell>
|
||||
@@ -504,11 +540,26 @@ export function SubscriptionsPage() {
|
||||
<div>attempts: {item.attempts_count}</div>
|
||||
<div>next: {item.next_retry_at ?? '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{workerJob ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
data-testid={`subscription-delivery-job-${item.id}`}
|
||||
>
|
||||
<Link to={`/workers?job=${workerJob.id}`}>#{workerJob.id}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||
{item.response_text ?? '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
529
admin/src/pages/workers-page.tsx
Normal file
529
admin/src/pages/workers-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user