From 20654b068236d27764a4c80b4638719c4688aa97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 23:28:35 +0000 Subject: [PATCH] feat(events): add pagination, filters, sort, and page size Extend GET /api/runs with offset/total, date and trigger filters, and configurable column sort. Rebuild Events page with URL-driven state matching the KV page pagination pattern. Co-authored-by: Nasyarobby Putra --- packages/server/src/api/run-query.js | 28 +++ packages/server/src/api/runs.js | 12 +- packages/server/start-app.js | 13 +- packages/server/store.js | 115 ++++++++++-- packages/web/src/api/hooks.js | 14 +- packages/web/src/pages/EventsPage.jsx | 240 +++++++++++++++++++++----- 6 files changed, 343 insertions(+), 79 deletions(-) create mode 100644 packages/server/src/api/run-query.js diff --git a/packages/server/src/api/run-query.js b/packages/server/src/api/run-query.js new file mode 100644 index 0000000..6b50237 --- /dev/null +++ b/packages/server/src/api/run-query.js @@ -0,0 +1,28 @@ +import * as store from "../../store.js"; + +/** + * @param {Record} q + */ +export function parseRunQueryParams(q) { + const limit = q.limit != null ? Number(q.limit) : undefined; + const offset = q.offset != null ? Number(q.offset) : undefined; + return { + owner: q.owner || undefined, + workflow: q.workflow || undefined, + status: q.status || undefined, + trigger_type: q.trigger || undefined, + after: q.after || undefined, + before: q.before || undefined, + limit: Number.isFinite(limit) ? limit : undefined, + offset: Number.isFinite(offset) ? offset : undefined, + sort: q.sort || undefined, + order: q.order || undefined, + }; +} + +/** + * @param {Record} q + */ +export async function queryRunsFromRequest(q) { + return store.queryRuns(parseRunQueryParams(q)); +} diff --git a/packages/server/src/api/runs.js b/packages/server/src/api/runs.js index 1aeb8ba..0b59107 100644 --- a/packages/server/src/api/runs.js +++ b/packages/server/src/api/runs.js @@ -1,20 +1,12 @@ import * as store from "../../store.js"; +import { queryRunsFromRequest } from "./run-query.js"; /** * @param {import("fastify").FastifyInstance} fastify */ export default async function runsPlugin(fastify) { fastify.get("/runs", async (req) => { - const q = /** @type {Record} */ (req.query ?? {}); - const limit = q.limit ? Number(q.limit) : undefined; - const runs = await store.listRuns({ - owner: q.owner, - workflow: q.workflow, - status: q.status, - limit: Number.isFinite(limit) ? limit : undefined, - before: q.before, - }); - return { runs }; + return queryRunsFromRequest(/** @type {Record} */ (req.query ?? {})); }); fastify.get("/consecutive-failures", async (req) => { diff --git a/packages/server/start-app.js b/packages/server/start-app.js index 39f0299..ee58947 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -14,6 +14,7 @@ import usersPlugin from "./src/api/users.js"; import scriptsPluginFactory from "./src/api/scripts.js"; import workflowsPluginFactory from "./src/api/workflows.js"; import runsPlugin from "./src/api/runs.js"; +import { queryRunsFromRequest } from "./src/api/run-query.js"; import dashboardPluginFactory from "./src/api/dashboard.js"; import secretsPlugin from "./src/api/secrets.js"; import kvPlugin from "./src/api/kv.js"; @@ -198,16 +199,8 @@ export async function startApp(opts = {}) { "/admin/runs", { onRequest: [server.authenticate] }, async (req, reply) => { - const q = /** @type {Record} */ (req.query); - const limit = q.limit ? Number(q.limit) : undefined; - const runs = await store.listRuns({ - owner: q.owner, - workflow: q.workflow, - status: q.status, - limit: Number.isFinite(limit) ? limit : undefined, - before: q.before, - }); - return reply.send({ runs }); + const q = /** @type {Record} */ (req.query ?? {}); + return reply.send(await queryRunsFromRequest(q)); }, ); diff --git a/packages/server/store.js b/packages/server/store.js index c680529..a23a1cd 100644 --- a/packages/server/store.js +++ b/packages/server/store.js @@ -222,19 +222,29 @@ export async function insertLogs(rows) { ); } +/** @type {Record} */ +const RUN_SORT_COLUMNS = { + status: "status", + workflow: "workflow_name", + revision: "workflow_revision", + trigger: "trigger_type", + started_at: "started_at", + duration: "duration_ms", +}; + /** + * @param {import("knex").Knex.QueryBuilder} q * @param {{ * owner?: string, * workflow?: string, * status?: string | string[], - * limit?: number, + * trigger_type?: string, + * after?: string, * before?: string, - * }} [filters] + * }} filters */ -export async function listRuns(filters = {}) { - const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); - let q = db("workflow_runs").select("*").orderBy("started_at", "desc"); - if (filters.owner) q = q.where("owner", filters.owner); +function applyRunFilters(q, filters) { + if (filters.owner) q.where("owner", filters.owner); if (filters.workflow) { const key = String(filters.workflow); if (key.includes("*")) { @@ -243,25 +253,102 @@ export async function listRuns(filters = {}) { .replaceAll("%", "\\%") .replaceAll("_", "\\_") .replaceAll("*", "%"); - q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]); + q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]); } else { - q = q.where("workflow", key); + q.where("workflow", key); } } if (filters.status) { if (Array.isArray(filters.status)) { - q = q.whereIn("status", filters.status); + q.whereIn("status", filters.status); } else { - q = q.where("status", filters.status); + q.where("status", filters.status); } } - if (filters.before) q = q.where("started_at", "<", filters.before); - const rows = await q.limit(limit); - return rows.map((row) => ({ + if (filters.trigger_type) q.where("trigger_type", filters.trigger_type); + if (filters.after) q.where("started_at", ">=", filters.after); + if (filters.before) q.where("started_at", "<", filters.before); + return q; +} + +/** + * @param {import("knex").Knex.QueryBuilder} q + * @param {string | undefined} sort + * @param {string | undefined} order + */ +function applyRunSort(q, sort, order) { + const column = RUN_SORT_COLUMNS[sort ?? ""] ?? "started_at"; + const direction = order === "asc" ? "asc" : "desc"; + q.orderBy(column, direction); + if (column !== "started_at") q.orderBy("started_at", "desc"); + return q; +} + +/** + * @param {Record} row + */ +function mapRunRow(row) { + return { ...row, input: deserialize(row.input), output: deserialize(row.output), - })); + }; +} + +/** + * @param {{ + * owner?: string, + * workflow?: string, + * status?: string | string[], + * trigger_type?: string, + * after?: string, + * before?: string, + * limit?: number, + * offset?: number, + * sort?: string, + * order?: string, + * }} [filters] + */ +export async function queryRuns(filters = {}) { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + const offset = Math.max(Number(filters.offset) || 0, 0); + + let q = db("workflow_runs"); + q = applyRunFilters(q, filters); + + const countRow = await q.clone().count({ count: "*" }).first(); + const total = Number(countRow?.count ?? 0); + + let rowsQ = q.clone().select("*"); + rowsQ = applyRunSort(rowsQ, filters.sort, filters.order); + const rows = await rowsQ.limit(limit).offset(offset); + + return { + runs: rows.map(mapRunRow), + total, + limit, + offset, + }; +} + +/** + * @param {{ + * owner?: string, + * workflow?: string, + * status?: string | string[], + * trigger_type?: string, + * after?: string, + * before?: string, + * limit?: number, + * }} [filters] + */ +export async function listRuns(filters = {}) { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + let q = db("workflow_runs").select("*"); + q = applyRunFilters(q, filters); + q = applyRunSort(q, "started_at", "desc"); + const rows = await q.limit(limit); + return rows.map(mapRunRow); } /** diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index cd07e4b..91b9ccd 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -298,16 +298,22 @@ export function useReregisterWorkflows() { } export function useRuns(filters = {}) { - const { owner, workflow, status, limit } = filters; + const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters; return useQuery({ - queryKey: ["runs", { owner, workflow, status, limit }], + queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }], queryFn: async () => { const params = {}; if (owner) params.owner = owner; if (workflow) params.workflow = workflow; if (status) params.status = status; - if (limit) params.limit = limit; - return (await api.get("/runs", { params })).data.runs; + if (trigger) params.trigger = trigger; + if (after) params.after = after; + if (before) params.before = before; + if (limit != null) params.limit = limit; + if (offset != null) params.offset = offset; + if (sort) params.sort = sort; + if (order) params.order = order; + return (await api.get("/runs", { params })).data; }, }); } diff --git a/packages/web/src/pages/EventsPage.jsx b/packages/web/src/pages/EventsPage.jsx index 6e645af..f1ec475 100644 --- a/packages/web/src/pages/EventsPage.jsx +++ b/packages/web/src/pages/EventsPage.jsx @@ -1,28 +1,103 @@ import { Link, useSearchParams } from "react-router-dom"; -import { useRuns } from "../api/hooks.js"; +import { useOwners, useRuns } from "../api/hooks.js"; import { formatTime, StatusBadge } from "../lib/format.jsx"; +const PAGE_SIZES = [25, 50, 100]; +const DEFAULT_LIMIT = 50; +const TRIGGER_TYPES = ["HTTP", "cron", "manual"]; + +const SORT_COLUMNS = [ + { key: "status", label: "Status" }, + { key: "workflow", label: "Workflow" }, + { key: "revision", label: "Revision" }, + { key: "trigger", label: "Trigger" }, + { key: "started_at", label: "Started" }, + { key: "duration", label: "Duration" }, +]; + +function dateToAfterIso(date) { + if (!date) return undefined; + return `${date}T00:00:00.000Z`; +} + +function dateToBeforeIso(date) { + if (!date) return undefined; + const d = new Date(`${date}T00:00:00.000Z`); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString(); +} + +function SortHeader({ column, label, sort, order, onSort }) { + const active = sort === column; + return ( + + + + ); +} + export function EventsPage() { const [params, setParams] = useSearchParams(); const workflow = params.get("workflow") || ""; const status = params.get("status") || ""; - const { data: runs = [], isLoading } = useRuns({ + const owner = params.get("owner") || ""; + const trigger = params.get("trigger") || ""; + const afterDate = params.get("after") || ""; + const beforeDate = params.get("before") || ""; + const offset = Math.max(Number(params.get("offset")) || 0, 0); + const limitParam = Number(params.get("limit")); + const limit = PAGE_SIZES.includes(limitParam) ? limitParam : DEFAULT_LIMIT; + const sort = params.get("sort") || "started_at"; + const order = params.get("order") === "asc" ? "asc" : "desc"; + + const { data: owners = [] } = useOwners(); + const { data, isLoading } = useRuns({ workflow: workflow || undefined, status: status || undefined, - limit: 100, + owner: owner || undefined, + trigger: trigger || undefined, + after: dateToAfterIso(afterDate), + before: dateToBeforeIso(beforeDate), + limit, + offset, + sort, + order, }); - function update(key, value) { + const runs = data?.runs ?? []; + const total = data?.total ?? 0; + + function update(key, value, resetOffset = true) { const next = new URLSearchParams(params); if (value) next.set(key, value); else next.delete(key); + if (resetOffset && key !== "offset") next.delete("offset"); + setParams(next); + } + + function setSort(column) { + const next = new URLSearchParams(params); + if (sort === column) { + next.set("order", order === "asc" ? "desc" : "asc"); + } else { + next.set("sort", column); + next.set("order", "desc"); + } + next.delete("offset"); setParams(next); } return (

{status === "failed" ? "Failed events" : "Events"}

-
+
update("status", e.target.value)} > + + + + + +
{isLoading ? ( + ) : runs.length === 0 ? ( +

No events match these filters.

) : ( -
- - - - - - - - - - - - - {runs.map((r) => ( - - - - - - - + <> +
+
StatusWorkflowRevisionTriggerStartedDuration
- - - - {r.workflow_name || r.workflow} - - - {r.workflow_revision != null ? `#${r.workflow_revision}` : "unknown"} - - {r.trigger_type} - {r.trigger_detail ? ` · ${r.trigger_detail}` : ""} - {formatTime(r.started_at)}{r.duration_ms != null ? `${r.duration_ms}ms` : "—"}
+ + + {SORT_COLUMNS.map(({ key, label }) => ( + + ))} - ))} - -
-
+ + + {runs.map((r) => ( + + + + + + + {r.workflow_name || r.workflow} + + + + {r.workflow_revision != null ? `#${r.workflow_revision}` : "unknown"} + + + {r.trigger_type} + {r.trigger_detail ? ` · ${r.trigger_detail}` : ""} + + {formatTime(r.started_at)} + {r.duration_ms != null ? `${r.duration_ms}ms` : "—"} + + ))} + + +
+
+ + + + {total === 0 ? "0" : `${offset + 1}–${offset + runs.length}`} of {total} + +
+ )}
);