feat(jobs): paginate jobs list
This commit is contained in:
@@ -6,6 +6,36 @@ from rest_framework.test import APIClient
|
|||||||
from jobs.services import create_job
|
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
|
@pytest.mark.django_db
|
||||||
def test_global_events_use_cursor_pagination():
|
def test_global_events_use_cursor_pagination():
|
||||||
create_job(job_type="demo.success")
|
create_job(job_type="demo.success")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.db import connection
|
from django.db import connection
|
||||||
from rest_framework import generics, status
|
from rest_framework import generics, status
|
||||||
from rest_framework.exceptions import NotFound, ValidationError
|
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.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
@@ -17,6 +17,11 @@ class JobEventCursorPagination(CursorPagination):
|
|||||||
ordering = "-id"
|
ordering = "-id"
|
||||||
|
|
||||||
|
|
||||||
|
class JobLimitOffsetPagination(LimitOffsetPagination):
|
||||||
|
default_limit = 25
|
||||||
|
max_limit = 100
|
||||||
|
|
||||||
|
|
||||||
class HealthAPIView(APIView):
|
class HealthAPIView(APIView):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
try:
|
try:
|
||||||
@@ -35,9 +40,15 @@ class JobListCreateAPIView(APIView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
queryset = Job.objects.order_by("-created_at")
|
queryset = Job.objects.order_by("-created_at")
|
||||||
status_filter = request.query_params.get("status")
|
status_filter = request.query_params.get("status")
|
||||||
|
type_filter = request.query_params.get("type")
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=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):
|
def post(self, request):
|
||||||
serializer = JobCreateSerializer(data=request.data)
|
serializer = JobCreateSerializer(data=request.data)
|
||||||
|
|||||||
@@ -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";
|
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;
|
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 = {
|
export const api = {
|
||||||
health: () => request<Health>("/health/"),
|
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}/`),
|
getJob: (jobId: string) => request<Job>(`/jobs/${jobId}/`),
|
||||||
createJob: (body: CreateJobBody) => request<Job>("/jobs/", { method: "POST", body: JSON.stringify(body) }),
|
createJob: (body: CreateJobBody) => request<Job>("/jobs/", { method: "POST", body: JSON.stringify(body) }),
|
||||||
retryJob: (jobId: string) => request<Job>(`/jobs/${jobId}/retry/`, { method: "POST" }),
|
retryJob: (jobId: string) => request<Job>(`/jobs/${jobId}/retry/`, { method: "POST" }),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Eye, Plus, RotateCcw } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Eye, Plus, RotateCcw } from "lucide-react";
|
||||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -21,6 +21,12 @@ const jobTypeOptions: SelectOption[] = [
|
|||||||
{ value: "demo.flaky", label: "Flaky retry" }
|
{ 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) {
|
function demoPayload(type: string) {
|
||||||
if (type === "demo.slow") return { sleep_seconds: 8 };
|
if (type === "demo.slow") return { sleep_seconds: 8 };
|
||||||
if (type === "demo.timeout") return { sleep_seconds: 45 };
|
if (type === "demo.timeout") return { sleep_seconds: 45 };
|
||||||
@@ -31,9 +37,12 @@ function demoPayload(type: string) {
|
|||||||
|
|
||||||
export function JobsPage() {
|
export function JobsPage() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
const [totalJobs, setTotalJobs] = useState(0);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("all");
|
const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("all");
|
||||||
const [typeFilter, setTypeFilter] = useState("");
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [jobType, setJobType] = useState("demo.success");
|
const [jobType, setJobType] = useState("demo.success");
|
||||||
const [payload, setPayload] = useState(JSON.stringify(demoPayload("demo.success"), null, 2));
|
const [payload, setPayload] = useState(JSON.stringify(demoPayload("demo.success"), null, 2));
|
||||||
const [priority, setPriority] = useState(50);
|
const [priority, setPriority] = useState(50);
|
||||||
@@ -42,8 +51,15 @@ export function JobsPage() {
|
|||||||
const [availableAt, setAvailableAt] = useState("");
|
const [availableAt, setAvailableAt] = useState("");
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
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(() => {
|
useEffect(() => {
|
||||||
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
||||||
@@ -51,13 +67,17 @@ export function JobsPage() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const filteredJobs = useMemo(() => {
|
useEffect(() => {
|
||||||
return jobs.filter((job) => {
|
setCurrentPage(1);
|
||||||
if (statusFilter !== "all" && job.status !== statusFilter) return false;
|
}, [pageSize, statusFilter, typeFilter]);
|
||||||
if (typeFilter && !job.type.toLowerCase().includes(typeFilter.toLowerCase())) return false;
|
|
||||||
return true;
|
const totalPages = Math.max(1, Math.ceil(totalJobs / pageSize));
|
||||||
});
|
const firstItem = totalJobs ? (currentPage - 1) * pageSize + 1 : 0;
|
||||||
}, [jobs, statusFilter, typeFilter]);
|
const lastItem = Math.min(totalJobs, currentPage * pageSize);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
function setType(nextType: string) {
|
function setType(nextType: string) {
|
||||||
setJobType(nextType);
|
setJobType(nextType);
|
||||||
@@ -142,6 +162,40 @@ export function JobsPage() {
|
|||||||
<SelectField value={statusFilter} options={statusOptions} onChange={(value) => setStatusFilter(value as JobStatus | "all")} />
|
<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)} />
|
<input placeholder="Filter by type" value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pagination-bar top">
|
||||||
|
<span>
|
||||||
|
Showing {firstItem}-{lastItem} of {totalJobs}
|
||||||
|
</span>
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<SelectField value={String(pageSize)} options={pageSizeOptions} onChange={(value) => setPageSize(Number(value))} />
|
||||||
|
<button className="icon-button" disabled={currentPage <= 1} type="button" aria-label="previous page" onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<label className="page-number-field">
|
||||||
|
Page
|
||||||
|
<input
|
||||||
|
max={totalPages}
|
||||||
|
min={1}
|
||||||
|
type="number"
|
||||||
|
value={currentPage}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
if (!Number.isNaN(value)) setCurrentPage(Math.min(totalPages, Math.max(1, value)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>of {totalPages}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
type="button"
|
||||||
|
aria-label="next page"
|
||||||
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -156,7 +210,7 @@ export function JobsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredJobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<tr key={job.id}>
|
<tr key={job.id}>
|
||||||
<td>
|
<td>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
@@ -181,7 +235,25 @@ export function JobsPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1114,6 +1114,54 @@ label {
|
|||||||
justify-content: flex-end;
|
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-bar.top {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.event-feed-meta {
|
.event-feed-meta {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export type CursorPage<T> = {
|
|||||||
results: T[];
|
results: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LimitOffsetPage<T> = {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: T[];
|
||||||
|
};
|
||||||
|
|
||||||
export type JobStats = {
|
export type JobStats = {
|
||||||
total: number;
|
total: number;
|
||||||
by_status: Record<JobStatus, number>;
|
by_status: Record<JobStatus, number>;
|
||||||
|
|||||||
Reference in New Issue
Block a user