mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
feat: adding background jobs ui page and apis - requires user is_admin to be set to true
This commit is contained in:
540
ui/src/pages/jobs/jobs-page.tsx
Normal file
540
ui/src/pages/jobs/jobs-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -500,6 +500,7 @@ export const MePage = () => {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<pre>{JSON.stringify(meQ.data, null, 2)}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user