From 3131d8ce5bdfe06f5bdafec772d4f2829bbe8958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 16 Aug 2026 00:17:49 +0200 Subject: [PATCH] Add search, filtering, sorting, and pagination to Run History The runs page was a flat top-100 list with no way to narrow it down. It's now driven entirely by the URL (?q=&status=&scriptId=&sort=&dir=&page=), so filtered/sorted views are shareable and survive back/forward navigation: - free-text search across script name, triggered-by, and the resolved command - status and script dropdown filters - sortable Script/Status/Triggered-by/Started column headers - offset pagination (25/page) with a real total count, clamped so an out-of-range page falls back to the last valid one instead of showing a misleading "no results" Co-Authored-By: Claude Sonnet 5 --- app/src/app/(app)/runs/page.tsx | 262 +++++++++++++++---- app/src/components/runs/run-status-badge.tsx | 4 +- app/src/components/runs/runs-toolbar.tsx | 100 +++++++ 3 files changed, 318 insertions(+), 48 deletions(-) create mode 100644 app/src/components/runs/runs-toolbar.tsx diff --git a/app/src/app/(app)/runs/page.tsx b/app/src/app/(app)/runs/page.tsx index 1bd31fd..7d4b411 100644 --- a/app/src/app/(app)/runs/page.tsx +++ b/app/src/app/(app)/runs/page.tsx @@ -2,10 +2,14 @@ export const dynamic = "force-dynamic"; import type { Metadata } from "next"; import Link from "next/link"; -import { desc } from "drizzle-orm"; +import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm"; import { getDb } from "@/lib/db/client"; import { runs } from "@/lib/db/schema"; -import { RunStatusBadge } from "@/components/runs/run-status-badge"; +import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge"; +import { RunsToolbar } from "@/components/runs/runs-toolbar"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { Table, TableBody, @@ -17,6 +21,21 @@ import { export const metadata: Metadata = { title: "Run History" }; +const PAGE_SIZE = 25; + +const SORT_COLUMNS = { + script: runs.scriptName, + status: runs.status, + triggeredBy: runs.triggeredBy, + started: runs.startedAt, +} as const; +type SortKey = keyof typeof SORT_COLUMNS; +const DEFAULT_SORT: SortKey = "started"; + +function isSortKey(value: string | undefined): value is SortKey { + return !!value && value in SORT_COLUMNS; +} + function formatDuration(startedAt: Date | null, endedAt: Date | null): string { if (!startedAt) return "-"; const end = endedAt ?? new Date(); @@ -29,62 +48,213 @@ function formatDuration(startedAt: Date | null, endedAt: Date | null): string { return `${minutes}m ${seconds % 60}s`; } -export default function RunsPage() { +interface RunsPageProps { + searchParams: Promise<{ + q?: string; + status?: string; + scriptId?: string; + sort?: string; + dir?: string; + page?: string; + }>; +} + +export default async function RunsPage({ searchParams }: RunsPageProps) { + const params = await searchParams; + const search = params.q?.trim() ?? ""; + const statusFilter = params.status ?? "all"; + const scriptFilter = params.scriptId ?? "all"; + const sort = isSortKey(params.sort) ? params.sort : DEFAULT_SORT; + const dir = params.dir === "asc" ? "asc" : "desc"; + const requestedPage = Math.max(1, Math.floor(Number(params.page)) || 1); + const db = getDb(); + + const conditions: SQL[] = []; + if (search) { + const term = `%${search}%`; + conditions.push( + or( + like(runs.scriptName, term), + like(runs.triggeredBy, term), + like(runs.resolvedCommand, term), + )!, + ); + } + if (statusFilter !== "all" && statusFilter in runStatusLabels) { + conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels)); + } + if (scriptFilter !== "all") { + conditions.push(eq(runs.scriptId, scriptFilter)); + } + const whereClause = conditions.length ? and(...conditions) : undefined; + + const orderColumn = SORT_COLUMNS[sort]; + const orderBy = [ + dir === "asc" ? asc(orderColumn) : desc(orderColumn), + desc(runs.createdAt), + ]; + + const [{ total }] = db + .select({ total: sql`count(*)` }) + .from(runs) + .where(whereClause) + .all(); + const scripts = db + .selectDistinct({ id: runs.scriptId, name: runs.scriptName }) + .from(runs) + .orderBy(asc(runs.scriptName)) + .all(); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const page = Math.min(requestedPage, totalPages); + const rows = db .select() .from(runs) - .orderBy(desc(runs.createdAt)) - .limit(100) + .where(whereClause) + .orderBy(...orderBy) + .limit(PAGE_SIZE) + .offset((page - 1) * PAGE_SIZE) .all(); + function hrefFor(overrides: Record): string { + const query = new URLSearchParams(); + if (search) query.set("q", search); + if (statusFilter !== "all") query.set("status", statusFilter); + if (scriptFilter !== "all") query.set("scriptId", scriptFilter); + if (sort !== DEFAULT_SORT) query.set("sort", sort); + if (dir !== "desc") query.set("dir", dir); + if (page !== 1) query.set("page", String(page)); + for (const [key, value] of Object.entries(overrides)) { + if (value === null) query.delete(key); + else query.set(key, value); + } + const qs = query.toString(); + return qs ? `/runs?${qs}` : "/runs"; + } + + function sortHeader(key: SortKey, label: string) { + const active = sort === key; + const nextDir = active && dir === "asc" ? "desc" : "asc"; + const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; + return ( + + {label} + + + ); + } + return (

Run History

+ {rows.length === 0 ? ( -

No runs yet.

+

+ {total === 0 && !search && statusFilter === "all" && scriptFilter === "all" + ? "No runs yet." + : "No runs match these filters."} +

) : ( -
- - - - Script - Status - Triggered by - Started - Duration - - - - {rows.map((run) => ( - - - - {run.scriptName} - - - - - - - {run.triggeredBy} - - - {run.startedAt - ? new Date(run.startedAt).toLocaleString() - : "-"} - - - {formatDuration(run.startedAt, run.endedAt)} - + <> +
+
+ + + {sortHeader("script", "Script")} + {sortHeader("status", "Status")} + {sortHeader("triggeredBy", "Triggered by")} + {sortHeader("started", "Started")} + Duration - ))} - -
-
+ + + {rows.map((run) => ( + + + + {run.scriptName} + + + + + + + {run.triggeredBy} + + + {run.startedAt + ? new Date(run.startedAt).toLocaleString() + : "-"} + + + {formatDuration(run.startedAt, run.endedAt)} + + + ))} + + +
+
+ + Page {page} of {totalPages} ยท {total} run{total === 1 ? "" : "s"} + +
+ {page > 1 ? ( + + + Previous + + ) : ( + + + Previous + + )} + {page < totalPages ? ( + + Next + + + ) : ( + + Next + + + )} +
+
+ )} ); diff --git a/app/src/components/runs/run-status-badge.tsx b/app/src/components/runs/run-status-badge.tsx index 22309fe..d75ea74 100644 --- a/app/src/components/runs/run-status-badge.tsx +++ b/app/src/components/runs/run-status-badge.tsx @@ -12,7 +12,7 @@ const styles: Record = { interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400", }; -const labels: Record = { +export const runStatusLabels: Record = { queued: "Queued", running: "Running", succeeded: "Succeeded", @@ -31,7 +31,7 @@ export function RunStatusBadge({ status }: { status: RunStatus }) { {status === "running" && ( )} - {labels[status]} + {runStatusLabels[status]} ); } diff --git a/app/src/components/runs/runs-toolbar.tsx b/app/src/components/runs/runs-toolbar.tsx new file mode 100644 index 0000000..8775345 --- /dev/null +++ b/app/src/components/runs/runs-toolbar.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { runStatusValues } from "@/lib/db/schema"; +import { runStatusLabels } from "./run-status-badge"; + +interface RunsToolbarProps { + scripts: { id: string; name: string }[]; +} + +export function RunsToolbar({ scripts }: RunsToolbarProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const urlQ = searchParams.get("q") ?? ""; + const [search, setSearch] = useState(urlQ); + // Tracks the URL value `search` was last synced from, so a change to the URL from elsewhere + // (back/forward, clearing filters) can be reflected without stomping on in-progress typing. + const [syncedQ, setSyncedQ] = useState(urlQ); + if (urlQ !== syncedQ) { + setSyncedQ(urlQ); + setSearch(urlQ); + } + + function updateParams(updates: Record) { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value === null) params.delete(key); + else params.set(key, value); + } + // Any filter/search change makes the current page number meaningless. + params.delete("page"); + router.push(`${pathname}?${params.toString()}`); + } + + useEffect(() => { + const timeout = setTimeout(() => { + if (search === urlQ) return; + updateParams({ q: search || null }); + }, 300); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-debounce when the typed value changes + }, [search]); + + return ( +
+ setSearch(event.target.value)} + className="sm:max-w-xs" + /> + + +
+ ); +}