feat(scheduler): every background run is a durable row
What a job last did lived in the API process's memory and was gone at every restart; the audit log kept a run only when it was manual, failed or changed something. Now each run is a job_runs row written at its start and its end in its own session: who started it, its outcome as data, what it spent of the YouTube quota (quota_events.job_run_id) and the lines it logged (log_events.run_id, filterable in the log console). - a run a stopped process left open is marked interrupted at startup - "run now" is a one-shot job on the scheduler, which now always starts (interval jobs only with SCHEDULER_ENABLED); the manual actor is reset after the run, since pool threads are reused - the dashboard's last run comes from job_runs, so it survives a deploy - runs leave the audit log: migration 0109 moves the old job_run rows over (and back on downgrade); the audit page's run folding goes - job_run_retention_days (default 30) prunes old runs in audit_gc
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import { Button } from "./ui/Button";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
/** A folded run of one job's successful runs (lib/auditGroups): the row shows the newest, this
|
||||
* opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */
|
||||
export default function AuditEarlierRuns({
|
||||
runs,
|
||||
detailOf,
|
||||
}: {
|
||||
runs: AuditRow[];
|
||||
detailOf: (run: AuditRow) => string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const earlier = runs.slice(1);
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<Button
|
||||
variant="link-muted"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
icon={<ChevronRight className={clsx("transition-transform", open && "rotate-90")} />}
|
||||
>
|
||||
{t("audit.earlierRuns", { count: earlier.length })}
|
||||
</Button>
|
||||
{open && (
|
||||
<ul className="mt-1 space-y-0.5 text-2xs text-muted">
|
||||
{earlier.map((run) => (
|
||||
<li key={run.id} className="flex gap-2">
|
||||
<RelativeTime
|
||||
at={run.created_at}
|
||||
seconds
|
||||
empty="—"
|
||||
className="shrink-0 tabular-nums"
|
||||
/>
|
||||
<span className="min-w-0 wrap-break-word">{detailOf(run)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,9 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { compareText } from "../lib/collate";
|
||||
import { foldJobRuns } from "../lib/auditGroups";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions (never per-row; a
|
||||
// background run is not one — runs have their own history). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
||||
export default function AuditLog() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -26,8 +25,7 @@ export default function AuditLog() {
|
||||
const confirm = useConfirm();
|
||||
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
||||
const data = q.data;
|
||||
// Back-to-back successful runs of one job fold into one row (lib/auditGroups).
|
||||
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
|
||||
const rows = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const actionOptions = useMemo(() => {
|
||||
// By what the reader sees — the named action, in their alphabet — not by the code.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// An audit row whose server summary is a fixed English line ("rss_poll: ok — new=5, failed=0",
|
||||
// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's
|
||||
// An audit row whose server summary is a fixed English line ("log_level = DEBUG", "rss_poll → 15
|
||||
// min/run", "Background sync paused") reads in the reader's language, rebuilt from the row's
|
||||
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import i18n, { setLanguage } from "../i18n";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
@@ -20,7 +20,7 @@ const row = (over: Partial<AuditRow>): AuditRow => ({
|
||||
id: 1,
|
||||
created_at: null,
|
||||
actor: null,
|
||||
action: "job_run",
|
||||
action: "sync_pause",
|
||||
target_type: null,
|
||||
target_id: null,
|
||||
summary: null,
|
||||
@@ -38,27 +38,6 @@ function details(r: AuditRow): string {
|
||||
}
|
||||
|
||||
describe("an audit row's details", () => {
|
||||
it("name a job run and its result in the reader's language", () => {
|
||||
const r = row({
|
||||
target_type: "job",
|
||||
target_id: "rss_poll",
|
||||
summary: "rss_poll: ok — new=5, failed=0",
|
||||
after: { status: "ok", summary: "new=5, failed=0" },
|
||||
});
|
||||
expect(details(r)).toBe("RSS-lekérdezés (új feltöltések) kész | új videó: 5 · sikertelen: 0");
|
||||
});
|
||||
|
||||
it("keep a failed run's error text, which is diagnostic", () => {
|
||||
const r = row({
|
||||
target_type: "job",
|
||||
target_id: "enrich",
|
||||
after: { status: "error", summary: "HTTP 403: quotaExceeded" },
|
||||
});
|
||||
expect(details(r)).toBe(
|
||||
"Nem sikerült lefuttatni: Adatdúsítás + élő frissítés | HTTP 403: quotaExceeded",
|
||||
);
|
||||
});
|
||||
|
||||
it("name a config change's field and values", () => {
|
||||
const set = row({
|
||||
action: "config_set",
|
||||
@@ -87,26 +66,6 @@ describe("an audit row's details", () => {
|
||||
expect(details(secret)).toMatch(/ \| titok frissítve/);
|
||||
});
|
||||
|
||||
it("name a nested result and a skipped run from the row's data", () => {
|
||||
const nested = row({
|
||||
target_type: "job",
|
||||
target_id: "people_enrich",
|
||||
after: {
|
||||
status: "ok",
|
||||
summary: "bios={'enriched': 12}, portraits={'cached': 40}",
|
||||
result: { bios: { enriched: 12 }, portraits: { cached: 40 } },
|
||||
},
|
||||
});
|
||||
expect(details(nested)).not.toContain("{");
|
||||
expect(details(nested)).toMatch(/^Személyadatok és portrék \(Plex\) kész \| /);
|
||||
const skipped = row({
|
||||
target_type: "job",
|
||||
target_id: "rss_poll",
|
||||
after: { status: "skipped", summary: "sync paused" },
|
||||
});
|
||||
expect(details(skipped)).toBe("RSS-lekérdezés (új feltöltések) kihagyva | a szinkron szünetel");
|
||||
});
|
||||
|
||||
it("tell a secret's reset from its update, and name a choice", () => {
|
||||
const reset = row({
|
||||
action: "config_reset",
|
||||
@@ -157,49 +116,3 @@ describe("an audit row's details", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A folded run (lib/auditGroups) shows its newest run and opens to the earlier ones; a failed run
|
||||
// is the line to see in a page of routine ones.
|
||||
describe("a folded run of one job", () => {
|
||||
const ok = (id: number, enriched: number) =>
|
||||
row({
|
||||
id,
|
||||
target_type: "job",
|
||||
target_id: "enrich",
|
||||
after: { status: "ok", result: { enriched } },
|
||||
});
|
||||
|
||||
it("names how many earlier runs it holds, and opens to each", () => {
|
||||
setLanguage("hu");
|
||||
const t = i18n.getFixedT("hu");
|
||||
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
||||
const runs = [ok(3, 50), ok(2, 40), ok(1, 30)];
|
||||
const { getByRole, container } = render(<>{col.render!({ ...runs[0]!, runs })}</>);
|
||||
const toggle = getByRole("button", { name: "2 korábbi futás" });
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.querySelector("li")).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
expect([...container.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
|
||||
"—dúsítva: 40",
|
||||
"—dúsítva: 30",
|
||||
]);
|
||||
});
|
||||
|
||||
it("has no toggle for a lone run", () => {
|
||||
const t = i18n.getFixedT("hu");
|
||||
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
||||
const { container } = render(<>{col.render!(ok(1, 5))}</>);
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("paints a failed run's line in the danger colour", () => {
|
||||
const t = i18n.getFixedT("hu");
|
||||
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
||||
const failed = row({ target_type: "job", target_id: "enrich", after: { status: "error" } });
|
||||
const { container } = render(<>{col.render!(failed)}</>);
|
||||
expect(container.querySelector(".wrap-break-word")!.className).toContain("text-danger");
|
||||
const { container: fine } = render(<>{col.render!(ok(2, 5))}</>);
|
||||
expect(fine.querySelector(".wrap-break-word")!.className).not.toContain("text-danger");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import clsx from "clsx";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import type { AuditListRow } from "../lib/auditGroups";
|
||||
import AuditEarlierRuns from "./AuditEarlierRuns";
|
||||
import { formatNumber } from "../lib/format";
|
||||
import type { JobResult } from "../lib/api";
|
||||
import { jobResultText, jobRunText } from "../lib/jobResult";
|
||||
import { jobResultText } from "../lib/jobResult";
|
||||
import type { Column } from "./ui/DataTable";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
@@ -35,8 +32,7 @@ function counts(after: Record<string, unknown>): JobResult | null {
|
||||
}
|
||||
|
||||
/** The details of the rows whose server `summary` is a fixed English line, rebuilt from the row's
|
||||
* data in the reader's language: a job run names the job and its result (as the inbox does), a config
|
||||
* change names the field and its values, an interval or batch change its new value, a count-bearing
|
||||
* data in the reader's language: a config change names the field and its values, an interval or batch change its new value, a count-bearing
|
||||
* action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or
|
||||
* an older row without the data); the generic summary + change rendering applies. */
|
||||
function localDetails(
|
||||
@@ -46,10 +42,6 @@ function localDetails(
|
||||
): { head: string | null; sub: string | null } | undefined {
|
||||
const after = row.after ?? {};
|
||||
const before = row.before ?? {};
|
||||
if (row.action === "job_run" && row.target_type === "job" && row.target_id) {
|
||||
const run = jobRunText(t, { job_id: row.target_id, ...after }, lang);
|
||||
return { head: run.title, sub: run.body };
|
||||
}
|
||||
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
|
||||
const key = row.target_id;
|
||||
const head = t(`config.fields.${key}.label`, key);
|
||||
@@ -104,7 +96,7 @@ export function auditColumns(
|
||||
t: TFunction,
|
||||
lang: string,
|
||||
actionOptions: { value: string; label: string }[],
|
||||
): Column<AuditListRow>[] {
|
||||
): Column<AuditRow>[] {
|
||||
return [
|
||||
{
|
||||
key: "when",
|
||||
@@ -146,23 +138,13 @@ export function auditColumns(
|
||||
const local = localDetails(t, r, lang);
|
||||
const summary = local ? local.head : r.summary;
|
||||
const change = local ? local.sub : changeText(r);
|
||||
// A failed run is the row to see in a page of routine ones: its line in the danger colour.
|
||||
const failed = r.action === "job_run" && r.after?.status === "error";
|
||||
return (
|
||||
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
||||
// sizes to its longest row, so one long summary/change stretched the whole table past the
|
||||
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
||||
<div className="min-w-0">
|
||||
{summary && (
|
||||
<div className={clsx("wrap-break-word", failed && "text-danger")}>{summary}</div>
|
||||
)}
|
||||
{summary && <div className="wrap-break-word">{summary}</div>}
|
||||
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
|
||||
{r.runs && r.runs.length > 1 && (
|
||||
<AuditEarlierRuns
|
||||
runs={r.runs}
|
||||
detailOf={(run) => localDetails(t, run, lang)?.sub ?? run.summary}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user