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)} />
| Status |
Type |
Priority |
Attempts |
Available |
Locked By |
Action |
{jobs.map((job) => (
|
|
{job.type}
|
{job.priority} |
|
|
{job.locked_by ?? "-"} |
|
))}
{!jobs.length &&
}
Showing {firstItem}-{lastItem} of {totalJobs}
setPageSize(Number(value))} />
{createOpen && (
setCreateOpen(false)}>
)}
);
}