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:
+216
-46
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user