Compare commits

..

10 Commits

30 changed files with 1961 additions and 172 deletions

105
README.md
View File

@@ -1,4 +1,4 @@
# Minimal Senior-Level Job Queue
# Job Queue
This is a deliberately small PostgreSQL-backed job queue for the interview assignment.
@@ -14,6 +14,63 @@ The important parts are:
- idempotent job creation
- demo UI with job state and event polling
## Architecture
![prompt for generating an svg image for a minimal PostgreSQL-backed job queue architecture showing React UI talking to Django API, Django API using PostgreSQL, and a separate Django worker process with N configured threads claiming jobs from PostgreSQL with SKIP LOCKED; use clean interview-project style, simple labeled boxes, directional arrows, and callouts for two tables jobs and job_events](assets/images/project-architecture.png)
There is no queue table and no worker table. Workers are ephemeral process threads with generated ids. The queue is internal and ordered by:
```text
priority DESC, available_at ASC, created_at ASC, id ASC
```
## Statuses
![prompt for generating an svg image for the job status state machine with four states queued, running, succeeded, failed; arrows queued to running, running to succeeded, running to queued for retry after failure or timeout, running to failed when attempts are exhausted, and failed to queued for manual retry; use clear color coding and small labels on each transition](assets/images/job-status-state-machine.png)
The database also validates row shape:
- queued jobs cannot have locks or finish timestamps
- running jobs must have a lock owner and lease deadline
- terminal jobs must have a finish timestamp and no lock
## At-Least-Once Execution
![prompt for generating an svg image for at-least-once job execution failure recovery showing worker A claims attempt 1, worker A crashes or lease expires, cleanup requeues the job, worker B claims attempt 2, and stale worker A cannot complete attempt 1 because ownership no longer matches; use a horizontal timeline with worker lanes and database state callouts](assets/images/at-least-once-lease-recovery.png)
This queue provides at-least-once execution, not exactly-once execution.
A worker can perform an external side effect and crash before marking a job succeeded. The lease will expire and the job can run again. Real handlers should therefore be idempotent.
## Why PostgreSQL
The assignment requires PostgreSQL, and PostgreSQL gives a compact solution for safe concurrent claiming through `SELECT ... FOR UPDATE SKIP LOCKED`. This keeps the implementation transactional, inspectable, and easy to demo.
For a high-throughput distributed production queue, Redis-backed systems such as BullMQ or Sidekiq-style designs are common. That is documented as the next architecture, not implemented here.
## Assumptions And Interview Simplifications
This project is intentionally scoped as an internal single-queue system, not a multi-tenant queue platform. There is no queue CRUD, queue table, or dynamic routing model because the assignment focuses on safe claim semantics and deterministic job state.
PostgreSQL is used because the assignment requires it and because it makes transactional state easy to inspect during a demo. Redis, BullMQ, Sidekiq-style designs, or a dedicated broker would be better for very high throughput or broader distributed queue use cases.
The system provides at-least-once execution, not exactly-once execution. Handlers that perform external side effects must be idempotent because a worker can crash after the side effect and before marking the job succeeded.
The UI is demo and observability oriented. It shows queue pressure, job state, retries, leases, and event logs, but it is not a full production operator console.
Authentication and authorization are intentionally omitted from the public API for interview simplicity. A production deployment would protect all write endpoints and usually restrict operator actions by role.
## Production Follow-Ups
- Add authentication, authorization, and role-based permissions.
- Add metrics, alerting, and dashboards for queue depth, age, throughput, failures, and retry rate.
- Add event retention, archival, or partitioning so `JobEvent` does not grow forever.
- Add rate limits, producer quotas, and backpressure controls.
- Add cancellation or cooperative stop support for jobs.
- Add dead-letter metadata or a dead-letter inspection view.
- Consider Redis or a dedicated broker if throughput or cross-service distribution becomes the main requirement.
## Run
```powershell
@@ -60,52 +117,6 @@ docker compose up -d --build worker
docker compose logs -f worker
```
## Architecture
```text
React UI
|
Django API ---- PostgreSQL
|
Django worker process
|
N worker threads from env
```
There is no queue table and no worker table. Workers are ephemeral process threads with generated ids. The queue is internal and ordered by:
```text
priority DESC, available_at ASC, created_at ASC, id ASC
```
## Statuses
```text
queued -> running
running -> succeeded
running -> queued retry after failure or timeout
running -> failed attempts exhausted
failed -> queued manual retry
```
The database also validates row shape:
- queued jobs cannot have locks or finish timestamps
- running jobs must have a lock owner and lease deadline
- terminal jobs must have a finish timestamp and no lock
## At-Least-Once Execution
This queue provides at-least-once execution, not exactly-once execution.
A worker can perform an external side effect and crash before marking a job succeeded. The lease will expire and the job can run again. Real handlers should therefore be idempotent.
## Why PostgreSQL
The assignment requires PostgreSQL, and PostgreSQL gives a compact solution for safe concurrent claiming through `SELECT ... FOR UPDATE SKIP LOCKED`. This keeps the implementation transactional, inspectable, and easy to demo.
For a high-throughput distributed production queue, Redis-backed systems such as BullMQ or Sidekiq-style designs are common. That is documented as the next architecture, not implemented here.
## Useful Commands
Run backend tests locally with SQLite fallback:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,36 @@ from rest_framework.test import APIClient
from jobs.services import create_job
@pytest.mark.django_db
def test_jobs_use_limit_offset_pagination():
create_job(job_type="demo.success")
create_job(job_type="demo.fail")
create_job(job_type="demo.slow")
response = APIClient().get("/api/jobs/?limit=2")
assert response.status_code == 200
body = response.json()
assert body["count"] == 3
assert body["next"] is not None
assert body["previous"] is None
assert len(body["results"]) == 2
@pytest.mark.django_db
def test_jobs_pagination_supports_status_and_type_filters():
create_job(job_type="demo.success")
create_job(job_type="demo.fail")
create_job(job_type="demo.slow")
response = APIClient().get("/api/jobs/?status=queued&type=success&limit=10")
assert response.status_code == 200
body = response.json()
assert body["count"] == 1
assert body["results"][0]["type"] == "demo.success"
@pytest.mark.django_db
def test_global_events_use_cursor_pagination():
create_job(job_type="demo.success")

View File

@@ -1,7 +1,7 @@
from django.db import connection
from rest_framework import generics, status
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.pagination import CursorPagination
from rest_framework.pagination import CursorPagination, LimitOffsetPagination
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -17,6 +17,11 @@ class JobEventCursorPagination(CursorPagination):
ordering = "-id"
class JobLimitOffsetPagination(LimitOffsetPagination):
default_limit = 25
max_limit = 100
class HealthAPIView(APIView):
def get(self, request):
try:
@@ -35,9 +40,15 @@ class JobListCreateAPIView(APIView):
def get(self, request):
queryset = Job.objects.order_by("-created_at")
status_filter = request.query_params.get("status")
type_filter = request.query_params.get("type")
if status_filter:
queryset = queryset.filter(status=status_filter)
return Response(JobSerializer(queryset, many=True).data)
if type_filter:
queryset = queryset.filter(type__icontains=type_filter)
paginator = JobLimitOffsetPagination()
page = paginator.paginate_queryset(queryset, request, view=self)
return paginator.get_paginated_response(JobSerializer(page, many=True).data)
def post(self, request):
serializer = JobCreateSerializer(data=request.data)

View File

@@ -2,6 +2,8 @@
Vite React demo UI for the minimal job queue.
![prompt for generating an svg image for the React frontend information architecture of a job queue demo UI with four routes: dashboard, jobs page, job detail page, and global events page; show shared AppLayout with topbar/sidebar, API client calling Django REST endpoints, and visual panels for stats, job table, detail timeline, and terminal-style event log; use clean product UI diagram style](../assets/images/frontend/ui-architecture.png)
It reuses the visual style from the previous advanced frontend, but only keeps:
- dashboard
@@ -11,11 +13,15 @@ It reuses the visual style from the previous advanced frontend, but only keeps:
The UI polls:
![prompt for generating an svg image for frontend polling and pagination data flow: dashboard polls jobs stats and recent events every 2 seconds, job detail polls job and job events every 1 second, events page fetches first cursor page then loads older pages on terminal scroll, jobs page uses limit-offset pagination with page size and page number; show React pages on left and API endpoints on right with interval labels](../assets/images/frontend/data-flow.png)
- jobs and stats every 2 seconds
- events every 1 second
No WebSockets are used.
![prompt for generating an svg image for a terminal-style event log UI component with dark console background, colored rows by event type, cursor-paginated first page, older events loaded only when scrolling inside the terminal, and custom theme-aware scrollbar; include labels for live polling, de-duplication, and infinite scroll boundary](../assets/images/frontend/terminal-event-feed.png)
## Run
```powershell

View File

@@ -1,4 +1,4 @@
import type { CursorPage, Health, Job, JobEvent, JobStats } from "./types";
import type { CursorPage, Health, Job, JobEvent, JobStats, LimitOffsetPage } from "./types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api";
@@ -31,9 +31,24 @@ function normalizeCursorPage<T>(value: CursorPage<T> | T[]): CursorPage<T> {
return value;
}
function normalizeLimitOffsetPage<T>(value: LimitOffsetPage<T> | T[]): LimitOffsetPage<T> {
if (Array.isArray(value)) {
return { count: value.length, next: null, previous: null, results: value };
}
return value;
}
export const api = {
health: () => request<Health>("/health/"),
listJobs: () => request<Job[]>("/jobs/"),
listJobs: () => api.listJobsPage({ limit: 100 }).then((page) => page.results),
listJobsPage: (filters: { limit?: number; offset?: number; status?: string; type?: string } = {}) => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
});
const query = params.toString();
return request<LimitOffsetPage<Job> | Job[]>(`/jobs/${query ? `?${query}` : ""}`).then(normalizeLimitOffsetPage);
},
getJob: (jobId: string) => request<Job>(`/jobs/${jobId}/`),
createJob: (body: CreateJobBody) => request<Job>("/jobs/", { method: "POST", body: JSON.stringify(body) }),
retryJob: (jobId: string) => request<Job>(`/jobs/${jobId}/retry/`, { method: "POST" }),

View File

@@ -26,7 +26,6 @@ export function AppLayout() {
function handleThemeToggle() {
toggleTheme();
toast.success(theme === "dark" ? "Light mode enabled." : "Dark mode enabled.");
}
return (

View File

@@ -31,7 +31,9 @@ function BarMetric({ label, value, max, tone = "neutral" }: { label: string; val
);
}
function MiniHistogram({ data }: { data: Array<{ label: string; value: number; tone: string }> }) {
type ChartPoint = { label: string; value: number; tone: string };
function MiniHistogram({ data }: { data: ChartPoint[] }) {
const max = Math.max(...data.map((item) => item.value), 1);
return (
<div className="histogram" aria-label="histogram">
@@ -51,6 +53,18 @@ function MiniHistogram({ data }: { data: Array<{ label: string; value: number; t
);
}
function StatusStack({ data }: { data: ChartPoint[] }) {
const total = data.reduce((sum, item) => sum + item.value, 0);
return (
<div className="status-stack" aria-label="status distribution">
{data.map((item) => {
const width = total > 0 ? Math.max(6, Math.round((item.value / total) * 100)) : 25;
return <i className={item.tone} key={item.label} style={{ width: `${width}%` }} title={`${item.label}: ${item.value}`} />;
})}
</div>
);
}
export function DashboardPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [events, setEvents] = useState<JobEvent[]>([]);
@@ -110,6 +124,11 @@ export function DashboardPage() {
.map(([type, value]) => ({ label: eventTitle(type), value, tone: type.includes("fail") ? "failed" : type.includes("success") ? "succeeded" : "running" }));
}, [events]);
const maxPressure = Math.max(...pressureData.map((item) => item.value), 1);
const activePressure = stats.by_status.queued + stats.by_status.running + stats.retries_pending + stats.overdue_running;
const completionRate = recentJobs.length
? Math.round((recentJobs.filter((job) => job.status === "succeeded").length / recentJobs.length) * 100)
: 0;
const topEvent = eventData.at(0);
return (
<div className="page">
@@ -131,7 +150,7 @@ export function DashboardPage() {
</section>
<section className="dashboard-grid">
<section className="panel">
<section className="panel chart-panel">
<div className="section-title">
<h3>Recent Jobs</h3>
<Link className="secondary-button" to="/jobs">
@@ -140,6 +159,11 @@ export function DashboardPage() {
</div>
{recentJobs.length ? (
<>
<div className="chart-summary-row">
<span>{recentJobs.length} latest jobs</span>
<strong>{completionRate}% succeeded</strong>
</div>
<StatusStack data={recentJobStatusData} />
<MiniHistogram data={recentJobStatusData} />
<div className="job-tick-chart" aria-label="latest jobs">
{recentJobs.slice(0, 16).map((job) => (
@@ -152,11 +176,15 @@ export function DashboardPage() {
)}
</section>
<section className="panel">
<section className="panel chart-panel">
<div className="section-title">
<h3>Queue Pressure</h3>
<Clock3 size={16} />
</div>
<div className="chart-summary-row">
<span>Active pressure</span>
<strong>{activePressure}</strong>
</div>
<div className="metric-bars">
{pressureData.map((item) => (
<BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} />
@@ -167,12 +195,22 @@ export function DashboardPage() {
</div>
</section>
<section className="panel">
<section className="panel chart-panel">
<div className="section-title">
<h3>Recent Events</h3>
<RotateCcw size={16} />
</div>
{eventData.length ? <MiniHistogram data={eventData} /> : <EmptyState title="No events yet" />}
{eventData.length ? (
<>
<div className="chart-summary-row">
<span>{events.length} latest events</span>
<strong>{topEvent ? topEvent.label : "No events"}</strong>
</div>
<MiniHistogram data={eventData} />
</>
) : (
<EmptyState title="No events yet" />
)}
</section>
</section>
</div>

View File

@@ -4,7 +4,6 @@ import { toast } from "sonner";
import { api } from "../api";
import { eventTitle } from "../constants";
import { DateTime } from "../components/DateTime";
import { EmptyState } from "../components/EmptyState";
import type { JobEvent } from "../types";
@@ -15,6 +14,21 @@ function mergeEvents(current: JobEvent[], incoming: JobEvent[]) {
return [...current, ...incoming.filter((event) => !seen.has(event.id))].sort((a, b) => b.id - a.id);
}
function eventTone(type: string) {
if (type.includes("fail")) return "danger";
if (type.includes("success")) return "success";
if (type.includes("retry") || type.includes("timeout")) return "warning";
if (type.includes("progress") || type.includes("lease")) return "info";
if (type.includes("claim")) return "accent";
return "neutral";
}
function formatLogTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--:--:--";
return date.toLocaleTimeString(undefined, { hour12: false });
}
export function EventsPage() {
const [events, setEvents] = useState<JobEvent[]>([]);
const [nextPageUrl, setNextPageUrl] = useState<string | null>(null);
@@ -22,6 +36,7 @@ export function EventsPage() {
const [loadingMore, setLoadingMore] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<string | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const feedRef = useRef<HTMLDivElement | null>(null);
const refreshFirstPage = useCallback(async (replace = false) => {
if (replace) setLoading(true);
@@ -60,19 +75,12 @@ export function EventsPage() {
}
}, [loadingMore, nextPageUrl]);
useEffect(() => {
const node = sentinelRef.current;
if (!node || !nextPageUrl) return undefined;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
},
{ rootMargin: "280px 0px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore, nextPageUrl]);
const handleFeedScroll = useCallback(() => {
const node = feedRef.current;
if (!node || !nextPageUrl || loadingMore) return;
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
if (distanceFromBottom <= 140) void loadMore();
}, [loadMore, loadingMore, nextPageUrl]);
const newestFirst = useMemo(() => [...events].sort((a, b) => b.id - a.id), [events]);
@@ -89,53 +97,43 @@ export function EventsPage() {
</div>
</header>
<section className="panel">
<div className="section-title">
<h3>Global Event Feed</h3>
<span className="muted-text">Last refresh {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span>
<section className="terminal-panel">
<div className="terminal-header">
<div className="terminal-window-controls" aria-hidden="true">
<i />
<i />
<i />
</div>
<code>job-events.log</code>
<span>refreshed {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Event</th>
<th>Job</th>
<th>Attempt</th>
<th>Worker</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{newestFirst.map((event) => (
<tr key={event.id}>
<td>{event.id}</td>
<td>{eventTitle(event.type)}</td>
<td>
<Link className="secondary-button" to={`/jobs/${event.job}`}>
{event.job.slice(0, 8)}
</Link>
</td>
<td>{event.attempt}</td>
<td>{event.worker_id ?? "-"}</td>
<td>
<DateTime value={event.created_at} />
</td>
</tr>
))}
</tbody>
</table>
<div className="terminal-feed" role="log" aria-live="polite" onScroll={handleFeedScroll} ref={feedRef}>
{newestFirst.map((event) => (
<article className={`terminal-line ${eventTone(event.type)}`} key={event.id}>
<time className="terminal-time" dateTime={event.created_at}>
{formatLogTime(event.created_at)}
</time>
<span className="terminal-prompt">$</span>
<strong className="terminal-event">{eventTitle(event.type)}</strong>
<Link className="terminal-job" to={`/jobs/${event.job}`}>
job:{event.job.slice(0, 8)}
</Link>
<span className="terminal-token">attempt:{event.attempt}</span>
<span className="terminal-worker">worker:{event.worker_id ?? "system"}</span>
{event.message && <span className="terminal-message">{event.message}</span>}
</article>
))}
{loading && !newestFirst.length && <EmptyState title="Loading events" />}
{!loading && !newestFirst.length && <EmptyState title="No events yet" />}
</div>
<div className="infinite-sentinel" ref={sentinelRef}>
{loadingMore && <span>Loading older events...</span>}
{!loadingMore && nextPageUrl && (
<button className="secondary-button" type="button" onClick={() => void loadMore()}>
Load older events
</button>
)}
{!loadingMore && !nextPageUrl && newestFirst.length > 0 && <span>End of event history</span>}
<div className="infinite-sentinel" ref={sentinelRef}>
{loadingMore && <span>Loading older events...</span>}
{!loadingMore && nextPageUrl && (
<button className="secondary-button" type="button" onClick={() => void loadMore()}>
Load older events
</button>
)}
{!loadingMore && !nextPageUrl && newestFirst.length > 0 && <span>End of event history</span>}
</div>
</div>
</section>
</div>

View File

@@ -117,11 +117,11 @@ export function JobDetailPage() {
{job.attempts}/{job.max_attempts}
</strong>
</div>
<div className="stat-card">
<span>Locked by</span>
<code className="code-token wrap">{job.locked_by ?? "-"}</code>
</div>
</section>
<div className="stat-card">
<span>Locked by</span>
<code className="code-token wrap">{job.locked_by ?? "-"}</code>
</div>
<section className="detail-grid">
<section className="panel">

View File

@@ -1,5 +1,5 @@
import { Eye, Plus, RotateCcw } from "lucide-react";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
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";
@@ -21,6 +21,12 @@ const jobTypeOptions: SelectOption[] = [
{ 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 };
@@ -29,11 +35,41 @@ function demoPayload(type: string) {
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);
@@ -42,8 +78,15 @@ export function JobsPage() {
const [availableAt, setAvailableAt] = useState("");
const refresh = useCallback(async () => {
setJobs(await api.listJobs());
}, []);
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)));
@@ -51,13 +94,17 @@ export function JobsPage() {
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]);
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);
@@ -156,7 +203,7 @@ export function JobsPage() {
</tr>
</thead>
<tbody>
{filteredJobs.map((job) => (
{jobs.map((job) => (
<tr key={job.id}>
<td>
<StatusBadge status={job.status} />
@@ -166,7 +213,7 @@ export function JobsPage() {
</td>
<td>{job.priority}</td>
<td>
{job.attempts}/{job.max_attempts}
<AttemptMeter job={job} />
</td>
<td>
<DateTime value={job.available_at} />
@@ -181,13 +228,33 @@ export function JobsPage() {
))}
</tbody>
</table>
{!filteredJobs.length && <EmptyState title="No jobs match the current filter" />}
{!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
@@ -204,7 +271,7 @@ export function JobsPage() {
<button className="secondary-button" type="button" onClick={() => setType("demo.flaky")}>
Flaky Retry
</button>
<button className="secondary-button" type="button" onClick={() => void createBatch()}>
<button className="primary-button" type="button" onClick={() => void createBatch()}>
<RotateCcw size={16} /> 10 Mixed
</button>
</div>
@@ -227,9 +294,6 @@ export function JobsPage() {
<input value={idempotencyKey} onChange={(event) => setIdempotencyKey(event.target.value)} />
</label>
<div className="modal-actions">
<button className="secondary-button" type="button" onClick={() => void createDemo(jobType, priority, maxAttempts)}>
Quick Create
</button>
<button className="secondary-button" type="button" onClick={() => setCreateOpen(false)}>
Cancel
</button>

View File

@@ -35,6 +35,9 @@
--panel-raised: #f9fbfc;
--background: #f4f7f9;
--shadow: 0 18px 46px rgb(35 36 38 / 8%);
--terminal-scroll-thumb: #0bb3f0;
--terminal-scroll-thumb-hover: #f0bb0b;
--terminal-scroll-track: #171b1f;
color: var(--ink);
background: var(--background);
font-synthesis: none;
@@ -60,6 +63,9 @@
--panel-raised: #25282b;
--background: #141618;
--shadow: 0 18px 46px rgb(0 0 0 / 28%);
--terminal-scroll-thumb: #f0bb0b;
--terminal-scroll-thumb-hover: #55c8ff;
--terminal-scroll-track: #101316;
}
* {
@@ -175,6 +181,7 @@ label {
grid-template-columns: 248px minmax(0, 1fr);
grid-template-rows: 72px minmax(0, 1fr);
min-height: 100vh;
transition: grid-template-columns 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.app-shell.sidebar-collapsed {
@@ -255,6 +262,10 @@ label {
padding: 16px;
position: sticky;
top: 72px;
transition:
background-color 220ms ease,
border-color 220ms ease,
padding 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sidebar-header {
@@ -268,6 +279,17 @@ label {
text-transform: uppercase;
}
.sidebar-header span {
max-width: 120px;
opacity: 1;
overflow: hidden;
transition:
max-width 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 160ms ease,
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: nowrap;
}
.sidebar nav {
display: grid;
gap: 8px;
@@ -283,10 +305,26 @@ label {
gap: 10px;
min-height: 42px;
padding: 10px 12px;
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
gap 240ms cubic-bezier(0.22, 1, 0.36, 1),
justify-content 240ms cubic-bezier(0.22, 1, 0.36, 1),
padding 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
.nav-item span {
max-width: 140px;
min-width: 0;
opacity: 1;
overflow: hidden;
text-overflow: ellipsis;
transition:
max-width 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 160ms ease,
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: nowrap;
}
.nav-item.active {
@@ -301,10 +339,13 @@ label {
.sidebar-collapsed .sidebar-header span,
.sidebar-collapsed .nav-item span {
display: none;
max-width: 0;
opacity: 0;
transform: translateX(-4px);
}
.sidebar-collapsed .nav-item {
gap: 0;
justify-content: center;
padding: 10px;
}
@@ -313,6 +354,14 @@ label {
margin: 0 auto;
}
.collapse-button {
transition:
background-color 180ms ease,
border-color 180ms ease,
margin 240ms cubic-bezier(0.22, 1, 0.36, 1),
transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
.content {
min-width: 0;
padding: 20px;
@@ -442,6 +491,49 @@ label {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-panel {
overflow: hidden;
position: relative;
}
.chart-panel::before {
background: linear-gradient(90deg, var(--cyan), var(--green), var(--orange), var(--red));
content: "";
height: 3px;
left: 14px;
opacity: 0.78;
position: absolute;
right: 14px;
top: 0;
}
.chart-summary-row {
align-items: center;
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
min-height: 42px;
padding: 9px 10px;
}
.chart-summary-row span {
color: var(--muted);
font-size: 12px;
font-weight: 900;
}
.chart-summary-row strong {
font-size: 14px;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-grid {
display: grid;
gap: 16px;
@@ -645,18 +737,36 @@ label {
display: flex;
height: 112px;
overflow: hidden;
position: relative;
width: 100%;
}
.histogram-column::before {
background:
linear-gradient(to top, transparent 24%, rgb(127 139 147 / 18%) 25%, transparent 26%),
linear-gradient(to top, transparent 49%, rgb(127 139 147 / 18%) 50%, transparent 51%),
linear-gradient(to top, transparent 74%, rgb(127 139 147 / 18%) 75%, transparent 76%);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
}
.histogram-column i,
.metric-track i {
background: var(--accent);
display: block;
transition:
height 520ms cubic-bezier(0.22, 1, 0.36, 1),
width 520ms cubic-bezier(0.22, 1, 0.36, 1),
background-color 180ms ease;
}
.histogram-column i {
border-radius: 6px 6px 0 0;
position: relative;
width: 100%;
z-index: 1;
}
.histogram-column i.queued,
@@ -703,11 +813,53 @@ label {
margin-top: 14px;
}
.status-stack {
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 999px;
display: flex;
gap: 3px;
height: 14px;
margin-bottom: 12px;
overflow: hidden;
padding: 2px;
}
.status-stack i {
background: var(--subtle);
border-radius: 999px;
display: block;
min-width: 6px;
transition:
width 520ms cubic-bezier(0.22, 1, 0.36, 1),
background-color 180ms ease;
}
.status-stack i.queued,
.status-stack i.running {
background: var(--cyan);
}
.status-stack i.succeeded {
background: var(--green);
}
.status-stack i.failed {
background: var(--red);
}
.job-tick {
background: var(--subtle);
border-radius: 5px;
display: block;
height: 32px;
transition:
background-color 240ms ease,
transform 220ms ease;
}
.job-tick:hover {
transform: translateY(-2px);
}
.job-tick.running {
@@ -968,6 +1120,92 @@ label {
justify-content: flex-end;
}
.pagination-bar {
align-items: center;
border-top: 1px solid var(--border);
color: var(--muted);
display: flex;
flex-wrap: wrap;
font-size: 13px;
font-weight: 900;
gap: 12px;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
}
.pagination-controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.pagination-controls .select-field {
min-width: 120px;
}
.page-number-field {
align-items: center;
display: flex;
flex-direction: row;
gap: 7px;
}
.page-number-field input {
min-height: 38px;
text-align: center;
width: 74px;
}
.attempt-meter {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 5px;
min-width: 128px;
}
.attempt-square {
align-items: center;
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
display: inline-flex;
height: 22px;
justify-content: center;
width: 22px;
}
.attempt-square.succeeded {
background: var(--green-soft);
border-color: color-mix(in srgb, var(--green) 42%, var(--border));
color: var(--green);
}
.attempt-square.failed {
background: var(--red-soft);
border-color: color-mix(in srgb, var(--red) 42%, var(--border));
color: var(--red);
}
.attempt-square.running {
animation: attempt-running 960ms ease-in-out infinite alternate;
background: var(--orange-soft);
border-color: color-mix(in srgb, var(--orange) 42%, var(--border));
color: var(--orange);
}
.attempt-count {
color: var(--muted);
font-size: 12px;
font-weight: 900;
margin-left: 3px;
white-space: nowrap;
}
.event-feed-meta {
align-items: center;
display: flex;
@@ -987,6 +1225,225 @@ label {
padding-top: 12px;
}
.terminal-panel {
background: #101316;
border: 1px solid #2d3439;
border-radius: 8px;
box-shadow: 0 22px 56px rgb(0 0 0 / 28%);
color: #d8e2e8;
overflow: hidden;
}
.terminal-header {
align-items: center;
background: #171b1f;
border-bottom: 1px solid #2d3439;
display: grid;
gap: 12px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 44px;
padding: 9px 12px;
}
.terminal-window-controls {
display: flex;
gap: 6px;
}
.terminal-window-controls i {
border-radius: 999px;
display: block;
height: 11px;
width: 11px;
}
.terminal-window-controls i:nth-child(1) {
background: #ff5f57;
}
.terminal-window-controls i:nth-child(2) {
background: #ffbd2e;
}
.terminal-window-controls i:nth-child(3) {
background: #28c840;
}
.terminal-header code {
background: transparent;
border: 0;
color: #d8e2e8;
font-size: 13px;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-header span {
color: #83919b;
font-size: 12px;
font-weight: 900;
}
.terminal-feed {
display: grid;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
gap: 0;
max-height: calc(100vh - 230px);
min-height: 420px;
overflow: auto;
padding: 8px;
scrollbar-color: var(--terminal-scroll-thumb) var(--terminal-scroll-track);
scrollbar-width: thin;
}
.terminal-feed::-webkit-scrollbar {
height: 10px;
width: 10px;
}
.terminal-feed::-webkit-scrollbar-track {
background: var(--terminal-scroll-track);
}
.terminal-feed::-webkit-scrollbar-thumb {
background: var(--terminal-scroll-thumb);
border: 2px solid var(--terminal-scroll-track);
border-radius: 999px;
}
.terminal-feed::-webkit-scrollbar-thumb:hover {
background: var(--terminal-scroll-thumb-hover);
}
.terminal-feed::-webkit-scrollbar-corner {
background: var(--terminal-scroll-track);
}
.terminal-line {
align-items: baseline;
border-left: 3px solid transparent;
display: grid;
gap: 8px;
grid-template-columns: 80px 16px minmax(130px, 170px) minmax(110px, 150px) minmax(88px, 110px) minmax(150px, 220px) minmax(180px, 1fr);
line-height: 1.55;
min-width: 0;
padding: 7px 8px;
}
.terminal-line:nth-child(odd) {
background: rgb(255 255 255 / 2.5%);
}
.terminal-line:hover {
background: rgb(255 255 255 / 6%);
}
.terminal-line.success {
border-left-color: #42d392;
}
.terminal-line.danger {
border-left-color: #ff6b6b;
}
.terminal-line.warning {
border-left-color: #f5b84b;
}
.terminal-line.info {
border-left-color: #55c8ff;
}
.terminal-line.accent {
border-left-color: #b388ff;
}
.terminal-time,
.terminal-token,
.terminal-worker,
.terminal-message {
color: #8d9aa5;
font-size: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
.terminal-prompt {
color: #63717b;
}
.terminal-event {
color: #d8e2e8;
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.terminal-line.success .terminal-event,
.terminal-line.success .terminal-prompt {
color: #42d392;
}
.terminal-line.danger .terminal-event,
.terminal-line.danger .terminal-prompt {
color: #ff6b6b;
}
.terminal-line.warning .terminal-event,
.terminal-line.warning .terminal-prompt {
color: #f5b84b;
}
.terminal-line.info .terminal-event,
.terminal-line.info .terminal-prompt {
color: #55c8ff;
}
.terminal-line.accent .terminal-event,
.terminal-line.accent .terminal-prompt {
color: #b388ff;
}
.terminal-job {
color: #9ae6ff;
font-size: 12px;
font-weight: 900;
min-width: 0;
overflow-wrap: anywhere;
text-decoration: none;
}
.terminal-job:hover {
color: #ffffff;
}
.terminal-message {
color: #c0ccd4;
}
.terminal-feed .empty-state {
background: rgb(255 255 255 / 4%);
border-color: #2d3439;
color: #8d9aa5;
}
.terminal-panel .infinite-sentinel {
border-top: 1px solid #2d3439;
color: #8d9aa5;
padding: 12px;
}
.terminal-panel .secondary-button {
background: #171b1f;
border-color: #2d3439;
color: #d8e2e8;
}
.filter-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1327,6 +1784,16 @@ code {
}
}
@keyframes attempt-running {
from {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--orange) 18%, transparent);
}
to {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--orange) 4%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -1399,6 +1866,10 @@ code {
.filter-grid {
grid-template-columns: 1fr 1fr;
}
.terminal-line {
grid-template-columns: 80px 16px minmax(120px, 1fr) minmax(100px, 1fr);
}
}
@media (max-width: 720px) {
@@ -1496,4 +1967,28 @@ code {
.dot-chart-row {
grid-template-columns: 1fr;
}
.terminal-header {
grid-template-columns: auto minmax(0, 1fr);
}
.terminal-header span {
grid-column: 2;
}
.terminal-feed {
max-height: none;
min-height: 360px;
}
.terminal-line {
grid-template-columns: 64px 14px minmax(0, 1fr);
}
.terminal-job,
.terminal-token,
.terminal-worker,
.terminal-message {
grid-column: 3;
}
}

View File

@@ -36,6 +36,13 @@ export type CursorPage<T> = {
results: T[];
};
export type LimitOffsetPage<T> = {
count: number;
next: string | null;
previous: string | null;
results: T[];
};
export type JobStats = {
total: number;
by_status: Record<JobStatus, number>;