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 <nasyarobby@gmail.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import * as store from "../../store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string | undefined>} 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<string, string | undefined>} q
|
||||||
|
*/
|
||||||
|
export async function queryRunsFromRequest(q) {
|
||||||
|
return store.queryRuns(parseRunQueryParams(q));
|
||||||
|
}
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
import * as store from "../../store.js";
|
import * as store from "../../store.js";
|
||||||
|
import { queryRunsFromRequest } from "./run-query.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
*/
|
*/
|
||||||
export default async function runsPlugin(fastify) {
|
export default async function runsPlugin(fastify) {
|
||||||
fastify.get("/runs", async (req) => {
|
fastify.get("/runs", async (req) => {
|
||||||
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
return queryRunsFromRequest(/** @type {Record<string, string | undefined>} */ (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 };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/consecutive-failures", async (req) => {
|
fastify.get("/consecutive-failures", async (req) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import usersPlugin from "./src/api/users.js";
|
|||||||
import scriptsPluginFactory from "./src/api/scripts.js";
|
import scriptsPluginFactory from "./src/api/scripts.js";
|
||||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||||
import runsPlugin from "./src/api/runs.js";
|
import runsPlugin from "./src/api/runs.js";
|
||||||
|
import { queryRunsFromRequest } from "./src/api/run-query.js";
|
||||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||||
import secretsPlugin from "./src/api/secrets.js";
|
import secretsPlugin from "./src/api/secrets.js";
|
||||||
import kvPlugin from "./src/api/kv.js";
|
import kvPlugin from "./src/api/kv.js";
|
||||||
@@ -198,16 +199,8 @@ export async function startApp(opts = {}) {
|
|||||||
"/admin/runs",
|
"/admin/runs",
|
||||||
{ onRequest: [server.authenticate] },
|
{ onRequest: [server.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
|
||||||
const limit = q.limit ? Number(q.limit) : undefined;
|
return reply.send(await queryRunsFromRequest(q));
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+101
-14
@@ -222,19 +222,29 @@ export async function insertLogs(rows) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
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 {{
|
* @param {{
|
||||||
* owner?: string,
|
* owner?: string,
|
||||||
* workflow?: string,
|
* workflow?: string,
|
||||||
* status?: string | string[],
|
* status?: string | string[],
|
||||||
* limit?: number,
|
* trigger_type?: string,
|
||||||
|
* after?: string,
|
||||||
* before?: string,
|
* before?: string,
|
||||||
* }} [filters]
|
* }} filters
|
||||||
*/
|
*/
|
||||||
export async function listRuns(filters = {}) {
|
function applyRunFilters(q, filters) {
|
||||||
const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200);
|
if (filters.owner) q.where("owner", filters.owner);
|
||||||
let q = db("workflow_runs").select("*").orderBy("started_at", "desc");
|
|
||||||
if (filters.owner) q = q.where("owner", filters.owner);
|
|
||||||
if (filters.workflow) {
|
if (filters.workflow) {
|
||||||
const key = String(filters.workflow);
|
const key = String(filters.workflow);
|
||||||
if (key.includes("*")) {
|
if (key.includes("*")) {
|
||||||
@@ -243,25 +253,102 @@ export async function listRuns(filters = {}) {
|
|||||||
.replaceAll("%", "\\%")
|
.replaceAll("%", "\\%")
|
||||||
.replaceAll("_", "\\_")
|
.replaceAll("_", "\\_")
|
||||||
.replaceAll("*", "%");
|
.replaceAll("*", "%");
|
||||||
q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]);
|
||||||
} else {
|
} else {
|
||||||
q = q.where("workflow", key);
|
q.where("workflow", key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
if (Array.isArray(filters.status)) {
|
if (Array.isArray(filters.status)) {
|
||||||
q = q.whereIn("status", filters.status);
|
q.whereIn("status", filters.status);
|
||||||
} else {
|
} else {
|
||||||
q = q.where("status", filters.status);
|
q.where("status", filters.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.before) q = q.where("started_at", "<", filters.before);
|
if (filters.trigger_type) q.where("trigger_type", filters.trigger_type);
|
||||||
const rows = await q.limit(limit);
|
if (filters.after) q.where("started_at", ">=", filters.after);
|
||||||
return rows.map((row) => ({
|
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<string, unknown>} row
|
||||||
|
*/
|
||||||
|
function mapRunRow(row) {
|
||||||
|
return {
|
||||||
...row,
|
...row,
|
||||||
input: deserialize(row.input),
|
input: deserialize(row.input),
|
||||||
output: deserialize(row.output),
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -298,16 +298,22 @@ export function useReregisterWorkflows() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRuns(filters = {}) {
|
export function useRuns(filters = {}) {
|
||||||
const { owner, workflow, status, limit } = filters;
|
const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["runs", { owner, workflow, status, limit }],
|
queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = {};
|
const params = {};
|
||||||
if (owner) params.owner = owner;
|
if (owner) params.owner = owner;
|
||||||
if (workflow) params.workflow = workflow;
|
if (workflow) params.workflow = workflow;
|
||||||
if (status) params.status = status;
|
if (status) params.status = status;
|
||||||
if (limit) params.limit = limit;
|
if (trigger) params.trigger = trigger;
|
||||||
return (await api.get("/runs", { params })).data.runs;
|
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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,103 @@
|
|||||||
import { Link, useSearchParams } from "react-router-dom";
|
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";
|
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 (
|
||||||
|
<th>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`font-semibold hover:underline ${active ? "" : "opacity-80"}`}
|
||||||
|
onClick={() => onSort(column)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active ? (order === "asc" ? " ↑" : " ↓") : ""}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EventsPage() {
|
export function EventsPage() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const workflow = params.get("workflow") || "";
|
const workflow = params.get("workflow") || "";
|
||||||
const status = params.get("status") || "";
|
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,
|
workflow: workflow || undefined,
|
||||||
status: status || 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);
|
const next = new URLSearchParams(params);
|
||||||
if (value) next.set(key, value);
|
if (value) next.set(key, value);
|
||||||
else next.delete(key);
|
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);
|
setParams(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-semibold">{status === "failed" ? "Failed events" : "Events"}</h1>
|
<h1 className="text-xl font-semibold">{status === "failed" ? "Failed events" : "Events"}</h1>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
className="input input-sm w-full sm:max-w-sm"
|
className="input input-sm w-full sm:max-w-sm"
|
||||||
placeholder="workflow key (* wildcard)"
|
placeholder="workflow key (* wildcard)"
|
||||||
@@ -35,52 +110,135 @@ export function EventsPage() {
|
|||||||
onChange={(e) => update("status", e.target.value)}
|
onChange={(e) => update("status", e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">all statuses</option>
|
<option value="">all statuses</option>
|
||||||
|
<option value="queued">queued</option>
|
||||||
<option value="running">running</option>
|
<option value="running">running</option>
|
||||||
<option value="success">success</option>
|
<option value="success">success</option>
|
||||||
<option value="failed">failed</option>
|
<option value="failed">failed</option>
|
||||||
<option value="skipped">skipped</option>
|
<option value="skipped">skipped</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
className="select select-sm w-full sm:max-w-xs"
|
||||||
|
value={owner}
|
||||||
|
onChange={(e) => update("owner", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">all owners</option>
|
||||||
|
{owners.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="select select-sm w-full sm:max-w-xs"
|
||||||
|
value={trigger}
|
||||||
|
onChange={(e) => update("trigger", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">all triggers</option>
|
||||||
|
{TRIGGER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<span className="opacity-70 whitespace-nowrap">from</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input input-sm"
|
||||||
|
value={afterDate}
|
||||||
|
onChange={(e) => update("after", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<span className="opacity-70 whitespace-nowrap">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input input-sm"
|
||||||
|
value={beforeDate}
|
||||||
|
onChange={(e) => update("before", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-sm w-full sm:max-w-[8rem]"
|
||||||
|
value={String(limit)}
|
||||||
|
onChange={(e) => update("limit", e.target.value)}
|
||||||
|
>
|
||||||
|
{PAGE_SIZES.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size} / page
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="loading loading-spinner" />
|
<span className="loading loading-spinner" />
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-60">No events match these filters.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
<table className="table table-sm">
|
<div className="overflow-x-auto">
|
||||||
<thead>
|
<table className="table table-sm">
|
||||||
<tr>
|
<thead>
|
||||||
<th>Status</th>
|
<tr>
|
||||||
<th>Workflow</th>
|
{SORT_COLUMNS.map(({ key, label }) => (
|
||||||
<th>Revision</th>
|
<SortHeader
|
||||||
<th>Trigger</th>
|
key={key}
|
||||||
<th>Started</th>
|
column={key}
|
||||||
<th>Duration</th>
|
label={label}
|
||||||
</tr>
|
sort={sort}
|
||||||
</thead>
|
order={order}
|
||||||
<tbody>
|
onSort={setSort}
|
||||||
{runs.map((r) => (
|
/>
|
||||||
<tr key={r.id} className="hover">
|
))}
|
||||||
<td>
|
|
||||||
<StatusBadge status={r.status} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link className="link" to={`/events/${r.id}`}>
|
|
||||||
{r.workflow_name || r.workflow}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="text-xs opacity-70">
|
|
||||||
{r.workflow_revision != null ? `#${r.workflow_revision}` : "unknown"}
|
|
||||||
</td>
|
|
||||||
<td className="text-xs">
|
|
||||||
{r.trigger_type}
|
|
||||||
{r.trigger_detail ? ` · ${r.trigger_detail}` : ""}
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap">{formatTime(r.started_at)}</td>
|
|
||||||
<td>{r.duration_ms != null ? `${r.duration_ms}ms` : "—"}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{runs.map((r) => (
|
||||||
</div>
|
<tr key={r.id} className="hover">
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={r.status} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link className="link" to={`/events/${r.id}`}>
|
||||||
|
{r.workflow_name || r.workflow}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="text-xs opacity-70">
|
||||||
|
{r.workflow_revision != null ? `#${r.workflow_revision}` : "unknown"}
|
||||||
|
</td>
|
||||||
|
<td className="text-xs">
|
||||||
|
{r.trigger_type}
|
||||||
|
{r.trigger_detail ? ` · ${r.trigger_detail}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap">{formatTime(r.started_at)}</td>
|
||||||
|
<td>{r.duration_ms != null ? `${r.duration_ms}ms` : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={offset <= 0}
|
||||||
|
onClick={() => update("offset", String(Math.max(offset - limit, 0)), false)}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={offset + runs.length >= total}
|
||||||
|
onClick={() => update("offset", String(offset + limit), false)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<span className="opacity-60">
|
||||||
|
{total === 0 ? "0" : `${offset + 1}–${offset + runs.length}`} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user