import { Check, ChevronLeft, ChevronRight, Eye, Plus, RotateCcw, X } from "lucide-react"; import { FormEvent, useCallback, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import { api } from "../api"; import { parseJson, STATUSES } from "../constants"; import { DateTime } from "../components/DateTime"; import { EmptyState } from "../components/EmptyState"; import { JsonField } from "../components/JsonField"; import { Modal } from "../components/Modal"; import { SelectField, SelectOption } from "../components/SelectField"; import { StatusBadge } from "../components/StatusBadge"; import type { Job, JobStatus } from "../types"; const jobTypeOptions: SelectOption[] = [ { value: "demo.success", label: "Successful" }, { value: "demo.fail", label: "Failing" }, { value: "demo.slow", label: "Slow with renewal" }, { value: "demo.timeout", label: "Timeout without renewal" }, { value: "demo.flaky", label: "Flaky retry" } ]; const pageSizeOptions: SelectOption[] = [ { value: "10", label: "10 / page" }, { value: "25", label: "25 / page" }, { value: "50", label: "50 / page" } ]; function demoPayload(type: string) { if (type === "demo.slow") return { sleep_seconds: 8 }; if (type === "demo.timeout") return { sleep_seconds: 45 }; if (type === "demo.flaky") return { fail_until_attempt: 2 }; if (type === "demo.fail") return { error: "Intentional demo failure" }; return { sleep_seconds: 1 }; } function attemptState(job: Job, attemptNumber: number) { if (attemptNumber > job.attempts) return "empty"; if (job.status === "succeeded") return attemptNumber === job.attempts ? "succeeded" : "failed"; if (job.status === "running" && attemptNumber === job.attempts) return "running"; return "failed"; } function AttemptMeter({ job }: { job: Job }) { return (
{Array.from({ length: job.max_attempts }, (_, index) => { const attemptNumber = index + 1; const state = attemptState(job, attemptNumber); return ( {state === "succeeded" && } {state === "failed" && } ); })} {/* {job.attempts}/{job.max_attempts} */}
); } export function JobsPage() { const [jobs, setJobs] = useState([]); const [totalJobs, setTotalJobs] = useState(0); const [createOpen, setCreateOpen] = useState(false); const [statusFilter, setStatusFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState(""); const [pageSize, setPageSize] = useState(25); const [currentPage, setCurrentPage] = useState(1); const [jobType, setJobType] = useState("demo.success"); const [payload, setPayload] = useState(JSON.stringify(demoPayload("demo.success"), null, 2)); const [priority, setPriority] = useState(50); const [maxAttempts, setMaxAttempts] = useState(3); const [idempotencyKey, setIdempotencyKey] = useState(""); const [availableAt, setAvailableAt] = useState(""); const refresh = useCallback(async () => { const page = await api.listJobsPage({ limit: pageSize, offset: (currentPage - 1) * pageSize, status: statusFilter === "all" ? undefined : statusFilter, type: typeFilter }); setJobs(page.results); setTotalJobs(page.count); }, [currentPage, pageSize, statusFilter, typeFilter]); useEffect(() => { void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught))); const id = window.setInterval(() => void refresh().catch(() => undefined), 2000); return () => window.clearInterval(id); }, [refresh]); useEffect(() => { setCurrentPage(1); }, [pageSize, statusFilter, typeFilter]); const totalPages = Math.max(1, Math.ceil(totalJobs / pageSize)); const firstItem = totalJobs ? (currentPage - 1) * pageSize + 1 : 0; const lastItem = Math.min(totalJobs, currentPage * pageSize); useEffect(() => { if (currentPage > totalPages) setCurrentPage(totalPages); }, [currentPage, totalPages]); function setType(nextType: string) { setJobType(nextType); setPayload(JSON.stringify(demoPayload(nextType), null, 2)); } async function createJob(event: FormEvent) { event.preventDefault(); try { await api.createJob({ type: jobType, payload: parseJson(payload), priority, max_attempts: maxAttempts, available_at: availableAt ? new Date(availableAt).toISOString() : null, idempotency_key: idempotencyKey || null }); setCreateOpen(false); setIdempotencyKey(""); await refresh(); toast.success("Job created."); } catch (caught) { toast.error(caught instanceof Error ? caught.message : String(caught)); } } async function createDemo(type: string, nextPriority = 50, nextMaxAttempts = 3) { try { await api.createJob({ type, payload: demoPayload(type), priority: nextPriority, max_attempts: nextMaxAttempts }); await refresh(); setCreateOpen(false); toast.success("Demo job created."); } catch (caught) { toast.error(caught instanceof Error ? caught.message : String(caught)); } } async function createBatch() { const batch = [ ["demo.success", 90], ["demo.slow", 80], ["demo.flaky", 75], ["demo.fail", 60], ["demo.success", 55], ["demo.timeout", 50], ["demo.success", 45], ["demo.flaky", 40], ["demo.fail", 35], ["demo.success", 30] ] as const; try { await Promise.all(batch.map(([type, nextPriority]) => api.createJob({ type, payload: demoPayload(type), priority: nextPriority, max_attempts: 3 }))); await refresh(); setCreateOpen(false); toast.success("Batch created."); } catch (caught) { toast.error(caught instanceof Error ? caught.message : String(caught)); } } const statusOptions: SelectOption[] = [{ value: "all", label: "All statuses" }, ...STATUSES.map((status) => ({ value: status, label: status }))]; return (
Lifecycle

Jobs

setStatusFilter(value as JobStatus | "all")} /> setTypeFilter(event.target.value)} />
{jobs.map((job) => ( ))}
Status Type Priority Attempts Available Locked By Action
{job.type} {job.priority} {job.locked_by ?? "-"}
{!jobs.length && }
Showing {firstItem}-{lastItem} of {totalJobs}
setPageSize(Number(value))} />
{createOpen && ( setCreateOpen(false)}>
)}
); }