feat: adding background jobs ui page and apis - requires user is_admin to be set to true

This commit is contained in:
allanice001
2025-11-04 23:52:37 +00:00
parent 91686c1ea4
commit c41af60b26
97 changed files with 11135 additions and 138 deletions

View File

@@ -0,0 +1,540 @@
import { useEffect, useState, type FC } from "react"
import { archerAdminApi } from "@/api/archer_admin"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Loader2, Plus, RefreshCw, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
// Types (align with generated client camelCase)
type JobStatus =
| "queued"
| "running"
| "succeeded"
| "failed"
| "canceled"
| "retrying"
| "scheduled"
type DtoJob = {
id: string
type: string
queue: string
status: JobStatus
attempts: number
maxAttempts?: number
createdAt: string
updatedAt?: string
lastError?: string | null
runAt?: string | null
payload?: unknown
}
type DtoPageJob = {
items: DtoJob[]
total: number
page: number
pageSize: number
}
type QueueInfo = {
name: string
pending: number
running: number
failed: number
scheduled: number
}
const STATUS: JobStatus[] = [
"queued",
"running",
"succeeded",
"failed",
"canceled",
"retrying",
"scheduled",
]
const statusClass: Record<JobStatus, string> = {
queued: "bg-amber-100 text-amber-800",
running: "bg-sky-100 text-sky-800",
succeeded: "bg-emerald-100 text-emerald-800",
failed: "bg-red-100 text-red-800",
canceled: "bg-zinc-200 text-zinc-700",
retrying: "bg-orange-100 text-orange-800",
scheduled: "bg-violet-100 text-violet-800",
}
function fmt(dt?: string | null) {
if (!dt) return "—"
const d = new Date(dt)
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(d)
}
// Small debounce hook for search input
function useDebounced<T>(value: T, ms = 300) {
const [v, setV] = useState(value)
useEffect(() => {
const t = setTimeout(() => setV(value), ms)
return () => clearTimeout(t)
}, [value, ms])
return v
}
export const JobsPage: FC = () => {
const qc = useQueryClient()
// Filters
const [status, setStatus] = useState<string>("")
const [queue, setQueue] = useState<string>("")
const [q, setQ] = useState<string>("")
const debouncedQ = useDebounced(q, 300)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const key = ["archer", "jobs", { status, queue, q: debouncedQ, page, pageSize }]
// Jobs query
const jobsQ = useQuery({
queryKey: key,
queryFn: () =>
archerAdminApi.listJobs({
status: status || undefined,
queue: queue || undefined,
q: debouncedQ || undefined,
page,
pageSize,
}) as Promise<DtoPageJob>,
keepPreviousData: true,
staleTime: 10_000,
})
// Queues summary (optional header)
const queuesQ = useQuery({
queryKey: ["archer", "queues"],
queryFn: () => archerAdminApi.listQueues() as Promise<QueueInfo[]>,
staleTime: 30_000,
})
// Mutations
const enqueueM = useMutation({
mutationFn: (body: { queue: string; type: string; payload?: unknown; run_at?: string }) =>
archerAdminApi.enqueue(body),
onSuccess: () => qc.invalidateQueries({ queryKey: ["archer", "jobs"] }),
})
const retryM = useMutation({
mutationFn: (id: string) => archerAdminApi.retryJob(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["archer", "jobs"] }),
})
const cancelM = useMutation({
mutationFn: (id: string) => archerAdminApi.cancelJob(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["archer", "jobs"] }),
})
const busy = jobsQ.isFetching
const data = jobsQ.data
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
return (
<div className="container mx-auto space-y-6 p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Archer Jobs</h1>
<p className="text-muted-foreground text-sm">
Inspect, enqueue, retry and cancel background jobs.
</p>
</div>
<div className="flex gap-2">
<EnqueueDialog
onSubmit={(payload) => enqueueM.mutateAsync(payload)}
submitting={enqueueM.isPending}
/>
<Button
variant="secondary"
onClick={() => qc.invalidateQueries({ queryKey: ["archer", "jobs"] })}
disabled={busy}
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Refresh
</Button>
</div>
</div>
{/* Queue metrics (optional) */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{queuesQ.data?.map((q) => (
<Card key={q.name}>
<CardHeader>
<CardTitle className="text-base">{q.name}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 text-sm">
<Metric label="Pending" value={q.pending} />
<Metric label="Running" value={q.running} />
<Metric label="Failed" value={q.failed} />
<Metric label="Scheduled" value={q.scheduled} />
</CardContent>
</Card>
))}
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-4">
<div className="col-span-2 flex items-center gap-2">
<Input
placeholder="Search id, queue, error, payload…"
value={q}
onChange={(e) => {
setQ(e.target.value)
setPage(1)
}}
onKeyDown={(e) =>
e.key === "Enter" && qc.invalidateQueries({ queryKey: ["archer", "jobs"] })
}
/>
{q && (
<Button variant="ghost" size="icon" onClick={() => setQ("")}>
<X className="h-4 w-4" />
</Button>
)}
<Button onClick={() => qc.invalidateQueries({ queryKey: ["archer", "jobs"] })}>
<Search className="mr-2 h-4 w-4" /> Search
</Button>
</div>
<Select
value={status || "all"}
onValueChange={(v) => {
setStatus(v === "all" ? "" : v)
setPage(1)
}}
>
<SelectTrigger>
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Queue (optional)"
value={queue}
onChange={(e) => {
setQueue(e.target.value)
setPage(1)
}}
/>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Page size</Label>
<Select
value={String(pageSize)}
onValueChange={(v) => {
setPageSize(Number(v))
setPage(1)
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Queue</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attempts</TableHead>
<TableHead>Run At</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="pr-4 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobsQ.isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
Loading
</TableCell>
</TableRow>
)}
{jobsQ.isError && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-red-600">
Failed to load jobs
</TableCell>
</TableRow>
)}
{!jobsQ.isLoading && data && data.items.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
No jobs match your filters.
</TableCell>
</TableRow>
)}
{data?.items.map((j) => (
<TableRow key={j.id}>
<TableCell>
<code className="text-xs">{j.id}</code>
</TableCell>
<TableCell>
<Badge variant="secondary">{j.queue}</Badge>
</TableCell>
<TableCell>
<span className={cn("rounded-md px-2 py-0.5 text-xs", statusClass[j.status])}>
{j.status}
</span>
</TableCell>
<TableCell>
{j.maxAttempts ? `${j.attempts}/${j.maxAttempts}` : j.attempts}
</TableCell>
<TableCell>{fmt(j.runAt)}</TableCell>
<TableCell>{fmt(j.updatedAt ?? j.createdAt)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{(j.status === "failed" || j.status === "canceled") && (
<Button
size="sm"
variant="outline"
disabled={retryM.isPending}
onClick={() => retryM.mutate(j.id)}
>
Retry
</Button>
)}
{(j.status === "queued" ||
j.status === "running" ||
j.status === "scheduled") && (
<Button
size="sm"
variant="outline"
disabled={cancelM.isPending}
onClick={() => cancelM.mutate(j.id)}
>
Cancel
</Button>
)}
<DetailsButton job={j} />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex items-center justify-between border-t p-3 text-sm">
<div>
Page {page} of {totalPages} {data?.total ?? 0} total
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={page <= 1 || jobsQ.isFetching}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</Button>
<Button
variant="outline"
disabled={page >= totalPages || jobsQ.isFetching}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="bg-muted/30 rounded-lg border p-3">
<div className="text-muted-foreground text-xs">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
)
}
function DetailsButton({ job }: { job: DtoJob }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="ghost">
Details
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Job {job.id}</DialogTitle>
</DialogHeader>
<div className="grid gap-3">
{job.lastError && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Last error</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-auto text-xs whitespace-pre-wrap">{job.lastError}</pre>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-sm">Payload</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-auto text-xs whitespace-pre-wrap">
{JSON.stringify(job.payload, null, 2)}
</pre>
</CardContent>
</Card>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function EnqueueDialog({
onSubmit,
submitting,
}: {
onSubmit: (body: {
queue: string
type: string
payload?: unknown
run_at?: string
}) => Promise<unknown>
submitting?: boolean
}) {
const [open, setOpen] = useState(false)
const [queue, setQueue] = useState("")
const [type, setType] = useState("")
const [payload, setPayload] = useState("{}")
const [runAt, setRunAt] = useState("")
const canSubmit = queue && type && !submitting
async function handleSubmit() {
const parsed = payload ? JSON.parse(payload) : undefined
await onSubmit({ queue, type, payload: parsed, run_at: runAt || undefined })
setOpen(false)
setQueue("")
setType("")
setPayload("{}")
setRunAt("")
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> Enqueue
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Enqueue Job</DialogTitle>
</DialogHeader>
<div className="grid gap-3">
<div className="grid gap-2">
<Label>Queue</Label>
<Input
value={queue}
onChange={(e) => setQueue(e.target.value)}
placeholder="e.g. bootstrap_bastion"
/>
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Input
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. bootstrap_bastion"
/>
</div>
<div className="grid gap-2">
<Label>Payload (JSON)</Label>
<Textarea
value={payload}
onChange={(e) => setPayload(e.target.value)}
className="min-h-[120px] font-mono text-xs"
/>
</div>
<div className="grid gap-2">
<Label>Run at (optional)</Label>
<Input type="datetime-local" value={runAt} onChange={(e) => setRunAt(e.target.value)} />
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Enqueue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from "react"
import { labelsApi } from "@/api/labels.ts"
import { taintsApi } from "@/api/taints.ts"
import type { DtoLabelResponse, DtoTaintResponse } from "@/sdk"
import type { DtoLabelResponse } from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CircleSlash2, Pencil, Plus, Search, Tags } from "lucide-react"
@@ -29,13 +28,6 @@ import {
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import {
Table,
TableBody,

View File

@@ -500,6 +500,7 @@ export const MePage = () => {
</div>
</DialogContent>
</Dialog>
<pre>{JSON.stringify(meQ.data, null, 2)}</pre>
</div>
)
}