308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
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 (
|
|
<div className="attempt-meter" aria-label={`${job.attempts} of ${job.max_attempts} attempts used`}>
|
|
{Array.from({ length: job.max_attempts }, (_, index) => {
|
|
const attemptNumber = index + 1;
|
|
const state = attemptState(job, attemptNumber);
|
|
return (
|
|
<span className={`attempt-square ${state}`} key={attemptNumber} title={`Attempt ${attemptNumber}: ${state}`}>
|
|
{state === "succeeded" && <Check size={12} strokeWidth={3} />}
|
|
{state === "failed" && <X size={12} strokeWidth={3} />}
|
|
</span>
|
|
);
|
|
})}
|
|
{/* <span className="attempt-count">
|
|
{job.attempts}/{job.max_attempts}
|
|
</span> */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function JobsPage() {
|
|
const [jobs, setJobs] = useState<Job[]>([]);
|
|
const [totalJobs, setTotalJobs] = useState(0);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("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 (
|
|
<div className="page">
|
|
<header className="page-header">
|
|
<div>
|
|
<span className="eyebrow">Lifecycle</span>
|
|
<h2>Jobs</h2>
|
|
</div>
|
|
<button type="button" onClick={() => setCreateOpen(true)}>
|
|
<Plus size={16} /> Create Job
|
|
</button>
|
|
</header>
|
|
|
|
<section className="panel">
|
|
<div className="toolbar">
|
|
<SelectField value={statusFilter} options={statusOptions} onChange={(value) => setStatusFilter(value as JobStatus | "all")} />
|
|
<input placeholder="Filter by type" value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)} />
|
|
</div>
|
|
<div className="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Type</th>
|
|
<th>Priority</th>
|
|
<th>Attempts</th>
|
|
<th>Available</th>
|
|
<th>Locked By</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{jobs.map((job) => (
|
|
<tr key={job.id}>
|
|
<td>
|
|
<StatusBadge status={job.status} />
|
|
</td>
|
|
<td>
|
|
<code className="inline-code">{job.type}</code>
|
|
</td>
|
|
<td>{job.priority}</td>
|
|
<td>
|
|
<AttemptMeter job={job} />
|
|
</td>
|
|
<td>
|
|
<DateTime value={job.available_at} />
|
|
</td>
|
|
<td>{job.locked_by ?? "-"}</td>
|
|
<td>
|
|
<Link className="icon-button" aria-label={`view ${job.type}`} title="View job" to={`/jobs/${job.id}`}>
|
|
<Eye size={18} />
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{!jobs.length && <EmptyState title="No jobs match the current filter" />}
|
|
</div>
|
|
<div className="pagination-bar">
|
|
<span>
|
|
Showing {firstItem}-{lastItem} of {totalJobs}
|
|
</span>
|
|
<div className="pagination-controls">
|
|
<SelectField value={String(pageSize)} options={pageSizeOptions} onChange={(value) => setPageSize(Number(value))} />
|
|
<button className="secondary-button" disabled={currentPage <= 1} type="button" onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}>
|
|
<ChevronLeft size={16} /> Previous
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={currentPage >= totalPages}
|
|
type="button"
|
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
|
>
|
|
Next <ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{createOpen && (
|
|
<Modal title="Create Job" onClose={() => setCreateOpen(false)}>
|
|
<form className="modal-form" onSubmit={createJob}>
|
|
<label>Quick Create</label>
|
|
<div className="preset-grid">
|
|
<button className="secondary-button" type="button" onClick={() => setType("demo.success")}>
|
|
Success
|
|
</button>
|
|
<button className="secondary-button" type="button" onClick={() => setType("demo.fail")}>
|
|
Failing
|
|
</button>
|
|
<button className="secondary-button" type="button" onClick={() => setType("demo.slow")}>
|
|
Slow
|
|
</button>
|
|
<button className="secondary-button" type="button" onClick={() => setType("demo.timeout")}>
|
|
Timeout
|
|
</button>
|
|
<button className="secondary-button" type="button" onClick={() => setType("demo.flaky")}>
|
|
Flaky Retry
|
|
</button>
|
|
<button className="primary-button" type="button" onClick={() => void createBatch()}>
|
|
<RotateCcw size={16} /> 10 Mixed
|
|
</button>
|
|
</div>
|
|
<SelectField label="Job type" value={jobType} options={jobTypeOptions} onChange={setType} />
|
|
<JsonField label="Payload JSON" value={payload} onChange={setPayload} />
|
|
<label>
|
|
Priority
|
|
<input type="number" value={priority} onChange={(event) => setPriority(Number(event.target.value))} />
|
|
</label>
|
|
<label>
|
|
Max attempts
|
|
<input min={1} type="number" value={maxAttempts} onChange={(event) => setMaxAttempts(Number(event.target.value))} />
|
|
</label>
|
|
<label>
|
|
Available at
|
|
<input type="datetime-local" value={availableAt} onChange={(event) => setAvailableAt(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Idempotency key
|
|
<input value={idempotencyKey} onChange={(event) => setIdempotencyKey(event.target.value)} />
|
|
</label>
|
|
<div className="modal-actions">
|
|
<button className="secondary-button" type="button" onClick={() => setCreateOpen(false)}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit">Create</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|