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
+18 -2
View File
@@ -49,7 +49,8 @@ export default function ActivityQuota({ shared }: { shared: Shared }) {
)}
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t("activity.quota.total", {
{t(shared.window === "all" ? "activity.quota.totalAll" : "activity.quota.total", {
count: grandTotal,
window: t(`activity.windows.${shared.window}`),
units: formatNumber(grandTotal, lang),
})}
@@ -57,18 +58,33 @@ export default function ActivityQuota({ shared }: { shared: Shared }) {
{data.daily.length === 0 ? (
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
) : (
<div className="flex items-end gap-0.5 h-24">
// Each day's figure is in its bar's text too (read out, not shown): the tooltip only
// reaches a pointer.
<div
className="flex items-end gap-0.5 h-24"
role="list"
aria-label={t("activity.quota.daily")}
>
{data.daily.map((d) => (
<div
key={d.day}
role="listitem"
// The day under the pointer is lit by the B3 layer over its whole column, not by
// dimming the bar: an opacity dim reads as "disabled".
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
title={t("stats.dailyTooltip", {
count: d.total,
day: d.day,
units: formatNumber(d.total, lang),
})}
>
<span className="sr-only">
{t("stats.dailyTooltip", {
count: d.total,
day: d.day,
units: formatNumber(d.total, lang),
})}
</span>
<div
className="w-full bg-accent rounded-t"
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}