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:
+180
-10
@@ -2,10 +2,14 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
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 { getDb } from "@/lib/db/client";
|
||||||
import { runs } from "@/lib/db/schema";
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -17,6 +21,21 @@ import {
|
|||||||
|
|
||||||
export const metadata: Metadata = { title: "Run History" };
|
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 {
|
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
|
||||||
if (!startedAt) return "-";
|
if (!startedAt) return "-";
|
||||||
const end = endedAt ?? new Date();
|
const end = endedAt ?? new Date();
|
||||||
@@ -29,29 +48,130 @@ function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
|
|||||||
return `${minutes}m ${seconds % 60}s`;
|
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 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
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
.from(runs)
|
.from(runs)
|
||||||
.orderBy(desc(runs.createdAt))
|
.where(whereClause)
|
||||||
.limit(100)
|
.orderBy(...orderBy)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
.all();
|
.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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
|
||||||
|
<RunsToolbar scripts={scripts} />
|
||||||
{rows.length === 0 ? (
|
{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">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Script</TableHead>
|
<TableHead>{sortHeader("script", "Script")}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>{sortHeader("status", "Status")}</TableHead>
|
||||||
<TableHead>Triggered by</TableHead>
|
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
|
||||||
<TableHead>Started</TableHead>
|
<TableHead>{sortHeader("started", "Started")}</TableHead>
|
||||||
<TableHead>Duration</TableHead>
|
<TableHead>Duration</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -85,6 +205,56 @@ export default function RunsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const styles: Record<RunStatus, string> = {
|
|||||||
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
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",
|
queued: "Queued",
|
||||||
running: "Running",
|
running: "Running",
|
||||||
succeeded: "Succeeded",
|
succeeded: "Succeeded",
|
||||||
@@ -31,7 +31,7 @@ export function RunStatusBadge({ status }: { status: RunStatus }) {
|
|||||||
{status === "running" && (
|
{status === "running" && (
|
||||||
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
|
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
|
||||||
)}
|
)}
|
||||||
{labels[status]}
|
{runStatusLabels[status]}
|
||||||
</Badge>
|
</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