feat(frontend): add polling job queue ui

This commit is contained in:
2026-06-21 01:39:49 +03:30
parent 24a025587e
commit ac91428d49
36 changed files with 5674 additions and 0 deletions

View File

@@ -0,0 +1,239 @@
import { Plus, RotateCcw } from "lucide-react";
import { FormEvent, useCallback, useEffect, useMemo, 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" }
];
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 };
}
export function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("all");
const [typeFilter, setTypeFilter] = useState("");
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 () => {
setJobs(await api.listJobs());
}, []);
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]);
const filteredJobs = useMemo(() => {
return jobs.filter((job) => {
if (statusFilter !== "all" && job.status !== statusFilter) return false;
if (typeFilter && !job.type.toLowerCase().includes(typeFilter.toLowerCase())) return false;
return true;
});
}, [jobs, statusFilter, typeFilter]);
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();
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();
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="row-actions">
<button type="button" onClick={() => void createDemo("demo.success", 60)}>
Success
</button>
<button type="button" onClick={() => void createDemo("demo.fail", 50, 2)}>
Failing
</button>
<button type="button" onClick={() => void createDemo("demo.slow", 80)}>
Slow
</button>
<button type="button" onClick={() => void createDemo("demo.timeout", 70, 2)}>
Timeout
</button>
<button type="button" onClick={() => void createDemo("demo.flaky", 75, 3)}>
Flaky Retry
</button>
<button type="button" onClick={() => void createBatch()}>
<RotateCcw size={16} /> 10 Mixed
</button>
</div>
</section>
<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>
{filteredJobs.map((job) => (
<tr key={job.id}>
<td>
<StatusBadge status={job.status} />
</td>
<td>{job.type}</td>
<td>{job.priority}</td>
<td>
{job.attempts}/{job.max_attempts}
</td>
<td>
<DateTime value={job.available_at} />
</td>
<td>{job.locked_by ?? "-"}</td>
<td>
<Link className="secondary-button" to={`/jobs/${job.id}`}>
Detail
</Link>
</td>
</tr>
))}
</tbody>
</table>
{!filteredJobs.length && <EmptyState title="No jobs match the current filter" />}
</div>
</section>
{createOpen && (
<Modal title="Create Job" onClose={() => setCreateOpen(false)}>
<form className="modal-form" onSubmit={createJob}>
<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>
);
}