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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 00:17:49 +02:00
co-authored by Claude Sonnet 5
parent d4e4c2c7d2
commit 3131d8ce5b
3 changed files with 318 additions and 48 deletions
+216 -46
View File
@@ -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<number>`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, string | null>): 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 (
<Link
href={hrefFor({ sort: key, dir: nextDir, page: null })}
className={cn(
"flex items-center gap-1 hover:text-foreground",
active && "text-foreground",
)}
>
{label}
<Icon className="size-3.5" />
</Link>
);
}
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
<RunsToolbar scripts={scripts} />
{rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center">No runs yet.</p>
<p className="text-muted-foreground py-12 text-center">
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all"
? "No runs yet."
: "No runs match these filters."}
</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Script</TableHead>
<TableHead>Status</TableHead>
<TableHead>Triggered by</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
<>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{sortHeader("script", "Script")}</TableHead>
<TableHead>{sortHeader("status", "Status")}</TableHead>
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
<TableHead>{sortHeader("started", "Started")}</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="text-muted-foreground flex items-center justify-between text-sm">
<span>
Page {page} of {totalPages} · {total} run{total === 1 ? "" : "s"}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={hrefFor({ page: String(page - 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ChevronLeft className="size-3.5" />
Previous
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
<ChevronLeft className="size-3.5" />
Previous
</span>
)}
{page < totalPages ? (
<Link
href={hrefFor({ page: String(page + 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Next
<ChevronRight className="size-3.5" />
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
Next
<ChevronRight className="size-3.5" />
</span>
)}
</div>
</div>
</>
)}
</div>
);
+2 -2
View File
@@ -12,7 +12,7 @@ const styles: Record<RunStatus, string> = {
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
};
const labels: Record<RunStatus, string> = {
export const runStatusLabels: Record<RunStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
@@ -31,7 +31,7 @@ export function RunStatusBadge({ status }: { status: RunStatus }) {
{status === "running" && (
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
)}
{labels[status]}
{runStatusLabels[status]}
</Badge>
);
}
+100
View File
@@ -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<string, string | null>) {
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 (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
placeholder="Search script, command, or triggered by..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="sm:max-w-xs"
/>
<Select
value={searchParams.get("status") ?? "all"}
onValueChange={(value) =>
updateParams({ status: value === "all" ? null : value })
}
>
<SelectTrigger className="sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{runStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{runStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={searchParams.get("scriptId") ?? "all"}
onValueChange={(value) =>
updateParams({ scriptId: value === "all" ? null : value })
}
>
<SelectTrigger className="sm:w-48">
<SelectValue placeholder="Script" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All scripts</SelectItem>
{scripts.map((script) => (
<SelectItem key={script.id} value={script.id}>
{script.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}