feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar

The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.

Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.

Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
This commit is contained in:
2026-10-18 03:45:18 +02:00
parent d13483a593
commit 24448e7c6f
66 changed files with 2723 additions and 1119 deletions
+104
View File
@@ -0,0 +1,104 @@
// The activity page's view in the address bar (`?page=activity&at=runs&aw=24h&rjob=backfill`), so a
// filtered view is a link: which tab, the filters every tab shares (time range, account, text), and
// each tab's own (the run list's job and status, the audit list's action). The server log's own
// filters keep their `l…` keys (./logsUrlState). setPage clears all of them (ACTIVITY_KEYS); the page
// re-mirrors them while it is open.
import { LOGS_KEYS } from "./logsUrlState";
export type ActivityTab = "runs" | "admin" | "logs" | "quota";
export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d";
/** The run statuses the list can narrow to (the API's `RunStatus`). */
export type RunStatusFilter = "running" | "ok" | "error" | "skipped" | "interrupted";
export interface ActivityUrlState {
tab: ActivityTab;
window: ActivityWindow;
/** An account id, or `none` for what no account did (the background). */
user: string | null;
q: string;
/** Runs: one job's runs. */
job: string | null;
status: RunStatusFilter | null;
/** Admin actions: one action. */
action: string | null;
}
export const ACTIVITY_TABS: readonly ActivityTab[] = ["runs", "admin", "logs", "quota"];
export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d"];
export const RUN_STATUSES: readonly RunStatusFilter[] = [
"error",
"interrupted",
"skipped",
"running",
"ok",
];
export const WINDOW_HOURS: Record<ActivityWindow, number> = {
"1h": 1,
"24h": 24,
"7d": 24 * 7,
"30d": 24 * 30,
"90d": 24 * 90,
};
export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = {
tab: "runs",
window: "7d",
user: null,
q: "",
job: null,
status: null,
action: null,
};
const OWN_KEYS = ["at", "aw", "au", "aq", "rjob", "rst", "aact"] as const;
export const ACTIVITY_KEYS = [...OWN_KEYS, ...LOGS_KEYS] as const;
// The pages this one replaced: a link or a bookmark to one opens its tab here (urlState's RETIRED
// sends the page id itself here).
const RETIRED_TAB: Readonly<Record<string, ActivityTab>> = { audit: "admin", logs: "logs" };
// The same shapes the API validates: a hand-edited link degrades to "no filter" here instead of to
// a 422 on every request the page makes.
const USER_RE = /^(none|\d{1,10})$/;
const ID_RE = /^[a-z][a-z0-9_]*$/;
const Q_MAX = 200;
const pick = (value: string | null, re: RegExp, max: number) =>
value && value.length <= max && re.test(value) ? value : null;
const oneOf = <T extends string>(value: string | null, list: readonly T[]): T | null =>
list.includes(value as T) ? (value as T) : null;
export function activityParamsToState(params: URLSearchParams): ActivityUrlState {
return {
tab:
oneOf(params.get("at"), ACTIVITY_TABS) ??
RETIRED_TAB[params.get("page") ?? ""] ??
DEFAULT_ACTIVITY_STATE.tab,
window: oneOf(params.get("aw"), ACTIVITY_WINDOWS) ?? DEFAULT_ACTIVITY_STATE.window,
user: pick(params.get("au"), USER_RE, 10),
q: (params.get("aq") ?? "").slice(0, Q_MAX),
job: pick(params.get("rjob"), ID_RE, 40),
status: oneOf(params.get("rst"), RUN_STATUSES),
action: pick(params.get("aact"), ID_RE, 48),
};
}
/** The URL updates for a state: a filter at its default clears its key. */
export function activityParamUpdates(state: ActivityUrlState): Record<string, string | null> {
const d = DEFAULT_ACTIVITY_STATE;
return {
at: state.tab === d.tab ? null : state.tab,
aw: state.window === d.window ? null : state.window,
au: state.user,
aq: state.q.trim() ? state.q : null,
rjob: state.job,
rst: state.status,
aact: state.action,
};
}
/** Where the window starts, as the API takes it. */
export function sinceOf(window: ActivityWindow, now = Date.now()): string {
return new Date(now - WINDOW_HOURS[window] * 3_600_000).toISOString();
}