From c72ca7203f4d54c12b95e31eac4700eed1db0fec Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 21 Jun 2026 09:12:46 +0330 Subject: [PATCH] feat(jobs): paginate jobs list --- backend/jobs/tests/test_api.py | 30 ++++++++++ backend/jobs/views.py | 15 ++++- frontend/src/api.ts | 19 ++++++- frontend/src/pages/JobsPage.tsx | 98 ++++++++++++++++++++++++++++----- frontend/src/styles.css | 48 ++++++++++++++++ frontend/src/types.ts | 7 +++ 6 files changed, 200 insertions(+), 17 deletions(-) diff --git a/backend/jobs/tests/test_api.py b/backend/jobs/tests/test_api.py index f99d05a..578746c 100644 --- a/backend/jobs/tests/test_api.py +++ b/backend/jobs/tests/test_api.py @@ -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") diff --git a/backend/jobs/views.py b/backend/jobs/views.py index ff28863..87249f6 100644 --- a/backend/jobs/views.py +++ b/backend/jobs/views.py @@ -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) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 96a2c01..89896d7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(value: CursorPage | T[]): CursorPage { return value; } +function normalizeLimitOffsetPage(value: LimitOffsetPage | T[]): LimitOffsetPage { + if (Array.isArray(value)) { + return { count: value.length, next: null, previous: null, results: value }; + } + return value; +} + export const api = { health: () => request("/health/"), - listJobs: () => request("/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 | Job[]>(`/jobs/${query ? `?${query}` : ""}`).then(normalizeLimitOffsetPage); + }, getJob: (jobId: string) => request(`/jobs/${jobId}/`), createJob: (body: CreateJobBody) => request("/jobs/", { method: "POST", body: JSON.stringify(body) }), retryJob: (jobId: string) => request(`/jobs/${jobId}/retry/`, { method: "POST" }), diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx index 81e71de..cc49702 100644 --- a/frontend/src/pages/JobsPage.tsx +++ b/frontend/src/pages/JobsPage.tsx @@ -1,5 +1,5 @@ -import { Eye, Plus, RotateCcw } from "lucide-react"; -import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight, Eye, Plus, RotateCcw } 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 }; @@ -31,9 +37,12 @@ function demoPayload(type: string) { 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(10); + 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 +51,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 +67,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); @@ -142,6 +162,40 @@ export function JobsPage() { setStatusFilter(value as JobStatus | "all")} /> setTypeFilter(event.target.value)} /> +
+ + Showing {firstItem}-{lastItem} of {totalJobs} + +
+ setPageSize(Number(value))} /> + + + +
+
@@ -156,7 +210,7 @@ export function JobsPage() { - {filteredJobs.map((job) => ( + {jobs.map((job) => (
@@ -181,7 +235,25 @@ export function JobsPage() { ))}
- {!filteredJobs.length && } + {!jobs.length && } +
+
+ + Page {currentPage} of {totalPages} + +
+ + +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3f5d19d..f1ba34f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1114,6 +1114,54 @@ 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-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 { align-items: center; display: flex; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 60b4b13..952b8b6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -36,6 +36,13 @@ export type CursorPage = { results: T[]; }; +export type LimitOffsetPage = { + count: number; + next: string | null; + previous: string | null; + results: T[]; +}; + export type JobStats = { total: number; by_status: Record;