fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone

Backend
- A run's outcome write sits outside the job's own try: a database blip after a run that
  succeeded no longer records it as failed (the row waits for the next start's sweep).
- An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does).
- The Scheduler's ad-hoc row says running while any backfill runs, not only the newest.
- recent_runs reads each job's last runs off its index instead of ranking the whole table.
- An interval change is stored with its audit row in one transaction, then rescheduled.
- One API instance per database is the stated contract (.env.example, jobruns).
- Turning full history on for a channel already complete records no run.
- Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are
  test-held to the enums; the account filter is one helper for the four activity reads.
- An audit row says how many of its request's log lines are still kept.

Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
  Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
  opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
  frame with the other tabs; an empty list is announced; each day's quota is readable.
This commit is contained in:
2026-10-18 15:38:32 +02:00
parent 3021be5166
commit fad656988a
44 changed files with 1101 additions and 426 deletions
+24 -14
View File
@@ -13,6 +13,7 @@ import { qk } from "../lib/queryKeys";
import { useHistoryList } from "../lib/useHistoryList";
import type { Shared } from "./ActivityRuns";
import { useConfirm } from "./confirmContext";
import { StateMessage } from "./ui/QueryState";
import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow";
import { RelativeTime } from "./ui/RelativeTime";
@@ -67,8 +68,8 @@ export default function ActivityAudit({
const clear = useMutation({
mutationFn: () => api.clearAudit(),
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: ["admin-audit"] });
void qc.invalidateQueries({ queryKey: ["admin-audit-facets"] });
void qc.invalidateQueries({ queryKey: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
},
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
@@ -115,6 +116,12 @@ export default function ActivityAudit({
{t("audit.clear")}
</Button>
</div>
{/* The action menu above is built from these counts: without them it offers only "Any". */}
{facets.isError && (
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
{t("activity.filtersFailed")}
</StateMessage>
)}
<HistoryList
state={state}
empty={t("audit.empty")}
@@ -161,7 +168,6 @@ export default function ActivityAudit({
);
})}
</HistoryList>
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
</div>
);
}
@@ -203,17 +209,21 @@ function AuditDetail({
{JSON.stringify({ before: row.before, after: row.after }, null, 2)}
</pre>
)}
{row.request_id && (
<div>
<Button
variant="link"
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
onClick={() => onRequest(row.request_id!)}
>
{t("activity.audit.requestLines")}
</Button>
</div>
)}
{/* A request's lines are kept days, its row months: a link to none would open an empty list. */}
{row.request_id &&
(row.log_lines > 0 ? (
<div>
<Button
variant="link"
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
onClick={() => onRequest(row.request_id!)}
>
{t("activity.audit.requestLines")}
</Button>
</div>
) : (
<p>{t("activity.audit.noRequestLines")}</p>
))}
</div>
);
}