fix(activity): an ad-hoc run's row is one run's, a rollback keeps a run whole, Back reads one entry's filters

- The channel-backfill row says "running" from the runs this process has open, not from the table:
  a row whose outcome failed to write no longer reads as running until a restart. The run still
  going leads the row, so a newer run's outcome never sits under the older one's channel.
- Migration 0109's downgrade carries every run's trigger, spend and start in the audit row, and the
  upgrade restores them: a manual run whose admin was deleted stays manual.
- Back/Forward is read by the page's one popstate listener, both halves in one render; the server
  log adopts its half during that render, so no read goes out with one entry's filters and the
  other's range.
- The server log's failed facets use the other tabs' words (the menus may be short); the accounts
  message shows only when the menu really lacks them.
This commit is contained in:
2026-10-18 17:32:22 +02:00
parent fad656988a
commit 13b7577d9f
12 changed files with 188 additions and 72 deletions
+13 -11
View File
@@ -13,7 +13,7 @@ log writer runs behind the event).
Runs are not admin actions, so the audit log stops carrying them: the existing `job_run` audit rows Runs are not admin actions, so the audit log stops carrying them: the existing `job_run` audit rows
MOVE into `job_runs` (start = end = when the row was written, the only time it kept), and leave the MOVE into `job_runs` (start = end = when the row was written, the only time it kept), and leave the
audit log. The downgrade moves every run back into the audit log as such a row (one still running as audit log. The downgrade moves every run back into the audit log as such a row (one still running as
interrupted, a run's trigger and target in its `after`), then drops the table. interrupted, what the audit row has no column for in its `after`), then drops the table.
""" """
import sqlalchemy as sa import sqlalchemy as sa
@@ -72,25 +72,27 @@ def upgrade() -> None:
) )
# The audit trail's runs move over: the three statuses the scheduler wrote, and `interrupted`, # The audit trail's runs move over: the three statuses the scheduler wrote, and `interrupted`,
# which a downgrade writes back (as it does a run's `trigger` and `target`, which the audit row # which a downgrade writes back (as it does a run's trigger, target, spend and start, which the
# has no column for), so a rollback and a redeploy keep every run. A status the run table does # audit row has no column for: its actor alone cannot say "manual" once that admin is deleted),
# so a rollback and a redeploy keep every run as it was. A status the run table does
# not know would fail the CHECK. # not know would fail the CHECK.
op.execute( op.execute(
""" """
INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at, finished_at, INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at, finished_at,
summary, result, error, failure) summary, result, error, failure, quota_units)
SELECT target_id, SELECT target_id,
coalesce(after::jsonb ->> 'trigger', coalesce(after::jsonb ->> 'trigger',
CASE WHEN actor_id IS NULL THEN 'scheduled' ELSE 'manual' END), CASE WHEN actor_id IS NULL THEN 'scheduled' ELSE 'manual' END),
actor_id, actor_id,
after::jsonb ->> 'target', after::jsonb ->> 'target',
after::jsonb ->> 'status', after::jsonb ->> 'status',
created_at, coalesce((after::jsonb ->> 'started_at')::timestamptz, created_at),
created_at, created_at,
CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb ->> 'summary' END, CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb ->> 'summary' END,
CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb -> 'result' END, CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb -> 'result' END,
CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb ->> 'summary' END, CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb ->> 'summary' END,
CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb -> 'result' END CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb -> 'result' END,
coalesce((after::jsonb ->> 'quota_units')::int, 0)
FROM audit_log FROM audit_log
WHERE action = 'job_run' WHERE action = 'job_run'
AND target_id IS NOT NULL AND target_id IS NOT NULL
@@ -109,9 +111,9 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# A run still `running` is one the rollback stopped: it goes back as interrupted. Its trigger and # A run still `running` is one the rollback stopped: it goes back as interrupted. What the audit
# target ride in `after` when the audit row's own columns cannot say them (an ad-hoc run of one # row's own columns cannot say rides in `after` (its trigger, target, spend and start), so the
# channel), so the upgrade restores them. # upgrade restores them.
op.execute( op.execute(
""" """
INSERT INTO audit_log (actor_id, action, target_type, target_id, summary, after, created_at) INSERT INTO audit_log (actor_id, action, target_type, target_id, summary, after, created_at)
@@ -132,8 +134,8 @@ def downgrade() -> None:
THEN jsonb_build_object('result', result) THEN jsonb_build_object('result', result)
ELSE '{}'::jsonb ELSE '{}'::jsonb
END END
|| CASE WHEN trigger = 'ad_hoc' || jsonb_build_object('trigger', trigger, 'quota_units', quota_units,
THEN jsonb_build_object('trigger', trigger) ELSE '{}'::jsonb END 'started_at', started_at)
|| CASE WHEN target IS NOT NULL || CASE WHEN target IS NOT NULL
THEN jsonb_build_object('target', target) ELSE '{}'::jsonb END)::json, THEN jsonb_build_object('target', target) ELSE '{}'::jsonb END)::json,
coalesce(finished_at, started_at) coalesce(finished_at, started_at)
-10
View File
@@ -119,16 +119,6 @@ def recent_runs(db: Session, jobs: list[str], per_job: int) -> dict[str, list[Jo
return out return out
def running_one(db: Session, job: str) -> JobRun | None:
"""The newest run of `job` still going, however many runs ended after it started."""
return db.scalars(
select(JobRun)
.where(JobRun.job == job, JobRun.status == RunStatus.RUNNING)
.order_by(JobRun.started_at.desc(), JobRun.id.desc())
.limit(1)
).first()
def gc(db: Session, retention_days: int) -> int: def gc(db: Session, retention_days: int) -> int:
"""Delete finished runs older than `retention_days` (0 = keep forever). A run still going is """Delete finished runs older than `retention_days` (0 = keep forever). A run still going is
never reaped. The caller commits.""" never reaped. The caller commits."""
+26 -5
View File
@@ -15,7 +15,7 @@ from app import audit, debugsession, jobruns, logs, logsink, progress, quota, sy
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads.gc import run_download_gc from app.downloads.gc import run_download_gc
from app.models import Channel from app.models import Channel, JobRun
from app.plex.sync import sync as run_plex_sync from app.plex.sync import sync as run_plex_sync
from app.plex.tmdb import run_enrich as run_tmdb_enrich from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.tmdb import run_people_enrich from app.plex.tmdb import run_people_enrich
@@ -45,6 +45,10 @@ _scheduler: BackgroundScheduler | None = None
# run did is durable instead: its `job_runs` row (app/jobruns.py), which outlives a restart. # run did is durable instead: its `job_runs` row (app/jobruns.py), which outlives a restart.
_activity: dict[str, dict] = {} _activity: dict[str, dict] = {}
_activity_lock = threading.Lock() _activity_lock = threading.Lock()
# The ad-hoc runs this process has open (run id -> job), under the same lock. A row can stay
# `running` in the table after its outcome failed to write (_close), so the table cannot say what is
# going; this can, as the one process per database (app/jobruns.py) is the one running them.
_open_ad_hoc: dict[int, str] = {}
# Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that # Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that
# run — and only that run, not the recurring scheduled ones — posts a completion notification # run — and only that run, not the recurring scheduled ones — posts a completion notification
@@ -243,6 +247,7 @@ def scheduler_snapshot(db) -> dict:
live_intervals[job.id] = round(iv.total_seconds() / 60) live_intervals[job.id] = round(iv.total_seconds() / 60)
with _activity_lock: with _activity_lock:
acts = {k: dict(v) for k, v in _activity.items()} acts = {k: dict(v) for k, v in _activity.items()}
going_id = max((r for r, j in _open_ad_hoc.items() if j == CHANNEL_BACKFILL), default=None)
recent = jobruns.recent_runs(db, [*JOB_INTERVALS, *AD_HOC_JOBS], RECENT_RUNS) recent = jobruns.recent_runs(db, [*JOB_INTERVALS, *AD_HOC_JOBS], RECENT_RUNS)
jobs = [] jobs = []
for job_id, default_interval in JOB_INTERVALS.items(): for job_id, default_interval in JOB_INTERVALS.items():
@@ -259,11 +264,17 @@ def scheduler_snapshot(db) -> dict:
) )
# Work a user starts on one target: no interval, no "run now" (it needs its target), but its # Work a user starts on one target: no interval, no "run now" (it needs its target), but its
# runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the # runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the
# target's own (sync/runner), so "running" is what its rows say: two channels can backfill at # target's own (sync/runner), so "running" is the runs this process has open: two channels can
# once, so a run still going counts even when a newer one already ended, and the row names it. # backfill at once, so a run still going counts even when a newer one already ended. That run then
# leads the row, so its channel, its "no outcome yet" and its dot are one run's, never the
# newer run's outcome under the older one's channel.
backfills = recent.get(CHANNEL_BACKFILL, []) backfills = recent.get(CHANNEL_BACKFILL, [])
going = jobruns.running_one(db, CHANNEL_BACKFILL) going = db.get(JobRun, going_id) if going_id is not None else None
shown = going or (backfills[0] if backfills else None) if going is not None and going.status != RunStatus.RUNNING:
going = None # its outcome is written; only the bookkeeping has not caught up
if going is not None:
backfills = [going, *(r for r in backfills if r.id != going.id)][:RECENT_RUNS]
shown = backfills[0] if backfills else None
ad_hoc = [ ad_hoc = [
{ {
"id": CHANNEL_BACKFILL, "id": CHANNEL_BACKFILL,
@@ -596,6 +607,16 @@ def _ad_hoc(job: str, fn: Callable[[], dict], actor_id: int, target: str) -> Non
# No row to tell it in: the database is what failed. The log is the record. # No row to tell it in: the database is what failed. The log is the record.
log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target) log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target)
return return
with _activity_lock:
_open_ad_hoc[run_id] = job
try:
_ad_hoc_work(job, fn, run_id)
finally:
with _activity_lock:
_open_ad_hoc.pop(run_id, None)
def _ad_hoc_work(job: str, fn: Callable[[], dict], run_id: int) -> None:
with logs.bind(run_id=run_id): with logs.bind(run_id=run_id):
# As in _run: only the work is inside the try. # As in _run: only the work is inside the try.
try: try:
+30 -7
View File
@@ -3,6 +3,7 @@ outcome intact, and leave the audit log; an admin action stays where it was. The
them back.""" them back."""
import json import json
from datetime import timedelta
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
@@ -87,14 +88,28 @@ def test_the_audit_logs_runs_move_into_the_run_table_and_back(db, make_user):
).all() ).all()
finally: finally:
command.upgrade(cfg, "head") command.upgrade(cfg, "head")
# What the audit row has no column for rides along, so the next upgrade restores it.
def run(trigger: str) -> dict:
return {"trigger": trigger, "quota_units": 0, "started_at": "2026-10-01T03:00:00+00:00"}
assert [tuple(r) for r in back] == [ assert [tuple(r) for r in back] == [
(None, "rss_poll", {"status": "ok", "summary": "new=3", "result": {"new": 3}}), (
None,
"rss_poll",
{"status": "ok", "summary": "new=3", "result": {"new": 3}, **run("scheduled")},
),
( (
uid, uid,
"rss_poll", "rss_poll",
{"status": "error", "summary": "boom", "result": {"failure": "rss_all_failed"}}, {
"status": "error",
"summary": "boom",
"result": {"failure": "rss_all_failed"},
**run("manual"),
},
), ),
(uid, "backfill", {"status": "skipped", "summary": "sync paused"}), (uid, "backfill", {"status": "skipped", "summary": "sync paused", **run("manual")}),
] ]
@@ -106,10 +121,13 @@ def test_a_down_and_up_round_trip_keeps_every_run(db, make_user):
conn.execute( conn.execute(
text( text(
"INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at," "INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at,"
" finished_at) VALUES" " finished_at, quota_units) VALUES"
" ('backfill', 'scheduled', NULL, NULL, 'interrupted', now(), now())," " ('backfill', 'scheduled', NULL, NULL, 'interrupted', now(), now(), 0),"
" ('maintenance', 'manual', :u, NULL, 'running', now(), NULL)," " ('maintenance', 'manual', :u, NULL, 'running', now(), NULL, 0),"
" ('channel_backfill', 'ad_hoc', :u, 'UC_x', 'ok', now(), now())" " ('channel_backfill', 'ad_hoc', :u, 'UC_x', 'ok', now(), now(), 0),"
# Its admin is gone (SET NULL): only the row itself still says it was manual.
" ('enrich', 'manual', NULL, NULL, 'ok',"
" '2026-10-01 10:00+00', '2026-10-01 10:30+00', 42)"
), ),
{"u": uid}, {"u": uid},
) )
@@ -120,6 +138,9 @@ def test_a_down_and_up_round_trip_keeps_every_run(db, make_user):
runs = conn.execute( runs = conn.execute(
text("SELECT job, trigger, actor_id, target, status FROM job_runs ORDER BY id") text("SELECT job, trigger, actor_id, target, status FROM job_runs ORDER BY id")
).all() ).all()
kept = conn.execute(
text("SELECT quota_units, finished_at - started_at FROM job_runs WHERE job = 'enrich'")
).one()
stranded = conn.execute( stranded = conn.execute(
text("SELECT count(*) FROM audit_log WHERE action = 'job_run'") text("SELECT count(*) FROM audit_log WHERE action = 'job_run'")
).scalar() ).scalar()
@@ -127,5 +148,7 @@ def test_a_down_and_up_round_trip_keeps_every_run(db, make_user):
("backfill", "scheduled", None, None, "interrupted"), ("backfill", "scheduled", None, None, "interrupted"),
("maintenance", "manual", uid, None, "interrupted"), # the rollback stopped it ("maintenance", "manual", uid, None, "interrupted"), # the rollback stopped it
("channel_backfill", "ad_hoc", uid, "UC_x", "ok"), ("channel_backfill", "ad_hoc", uid, "UC_x", "ok"),
("enrich", "manual", None, None, "ok"),
] ]
assert tuple(kept) == (42, timedelta(minutes=30))
assert stranded == 0 assert stranded == 0
+25 -5
View File
@@ -363,8 +363,15 @@ def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, m
assert len(row["recent"]) == 2 assert len(row["recent"]) == 2
assert all(j["id"] != "channel_backfill" for j in scheduler.scheduler_snapshot(db)["jobs"]) assert all(j["id"] != "channel_backfill" for j in scheduler.scheduler_snapshot(db)["jobs"])
jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_shown") seen = []
assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is True scheduler._ad_hoc(
"channel_backfill",
lambda: seen.append(scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"]) or {},
user.id,
"UC_shown",
)
assert seen == [True]
assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is False
def _finished(db, job) -> list[JobRun]: def _finished(db, job) -> list[JobRun]:
@@ -458,6 +465,8 @@ def test_a_run_whose_outcome_cannot_be_written_is_not_called_failed(db, make_use
calls.clear() calls.clear()
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_blip") scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_blip")
assert calls == ["ok"] assert calls == ["ok"]
# Its row is still `running`, but nothing here is: the page does not call it going.
assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is False
def test_a_run_that_cannot_be_recorded_does_not_wedge_its_job(db, monkeypatch): def test_a_run_that_cannot_be_recorded_does_not_wedge_its_job(db, monkeypatch):
@@ -484,10 +493,21 @@ def test_an_ad_hoc_run_still_going_shows_although_a_newer_one_ended(db, make_use
user = make_user("two@example.com") user = make_user("two@example.com")
db.add(Channel(id="UC_long", title="Long one")) db.add(Channel(id="UC_long", title="Long one"))
db.commit() db.commit()
jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_long") rows = []
scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_short")
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"] def long_one():
scheduler._ad_hoc(
"channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_short"
)
rows.extend(scheduler.scheduler_snapshot(db)["ad_hoc"])
return {}
scheduler._ad_hoc("channel_backfill", long_one, user.id, "UC_long")
(row,) = rows
assert (row["running"], row["target"], row["target_title"]) == (True, "UC_long", "Long one") assert (row["running"], row["target"], row["target_title"]) == (True, "UC_long", "Long one")
# The outcome beside it is the long one's (none yet), never the short one's skip.
assert (row["status"], row["result"], row["last_error"]) == (None, None, None)
assert [r["status"] for r in row["recent"]] == ["running", "ok"]
def test_an_interval_change_lands_with_its_audit_row_or_not_at_all( def test_an_interval_change_lands_with_its_audit_row_or_not_at_all(
+47 -2
View File
@@ -45,6 +45,7 @@ vi.mock("../lib/api", async (importOriginal) => {
}; };
}); });
import { qk } from "../lib/queryKeys";
import Activity from "./Activity"; import Activity from "./Activity";
import { ChoiceContext } from "./confirmContext"; import { ChoiceContext } from "./confirmContext";
import { PageToolbarSlot } from "./ui/pageToolbarSlot"; import { PageToolbarSlot } from "./ui/pageToolbarSlot";
@@ -90,8 +91,7 @@ const page = <T,>(items: T[]): HistoryPage<T> => ({
next_cursor: null, next_cursor: null,
}); });
function renderPage() { function renderPage(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
{/* Every confirmation is answered yes. */} {/* Every confirmation is answered yes. */}
@@ -259,6 +259,16 @@ describe("Activity", () => {
expect(usersMock).toHaveBeenCalledTimes(2); expect(usersMock).toHaveBeenCalledTimes(2);
}); });
it("accounts that could not be refreshed, still listed, are not called unloaded", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } });
qc.setQueryData(qk.adminUsers(), [{ id: 1, email: "admin@example.com" }]);
usersMock.mockRejectedValue(new Error("502"));
renderPage(qc);
await waitFor(() => expect(usersMock).toHaveBeenCalled());
await waitFor(() => expect(qc.getQueryState(qk.adminUsers())?.status).toBe("error"));
expect(screen.queryByText("activity.accountsFailed")).toBeNull();
});
it("clearing the admin actions reads the list again", async () => { it("clearing the admin actions reads the list again", async () => {
window.history.replaceState(null, "", "/?page=activity&at=admin"); window.history.replaceState(null, "", "/?page=activity&at=admin");
renderPage(); renderPage();
@@ -285,6 +295,41 @@ describe("Activity", () => {
expect(after).toBe(live); // the same log, not a fresh one expect(after).toBe(live); // the same log, not a fresh one
}); });
it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => {
// A browser runs the microtasks between two popstate listeners, so each listener's update
// renders on its own; a test dispatch does not. Each listener runs in its own act here.
const listeners: EventListener[] = [];
const addOther = window.addEventListener.bind(window);
const removeOther = window.removeEventListener.bind(window);
const add = vi.spyOn(window, "addEventListener").mockImplementation((type, l, o) => {
if (type === "popstate") listeners.push(l as EventListener);
else addOther(type, l, o);
});
const remove = vi.spyOn(window, "removeEventListener").mockImplementation((type, l, o) => {
if (type === "popstate") listeners.splice(listeners.indexOf(l as EventListener), 1);
else removeOther(type, l, o);
});
window.history.replaceState(null, "", "/?page=activity&at=logs&aw=24h");
renderPage();
await waitFor(() => expect(logsMock).toHaveBeenCalled());
logsMock.mockClear();
// Forward onto a pivot's entry: one run's lines over the log's whole retention.
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
for (const l of [...listeners]) {
await act(async () => l(new PopStateEvent("popstate")));
}
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
);
const days = (since?: string) => (Date.now() - Date.parse(since ?? "")) / 86_400_000;
// Never one run's lines over a day, nor the whole log over 90 days.
expect(logsMock.mock.calls.map(([q]) => [q.run_id ?? null, days(q.since) > 30])).toEqual(
logsMock.mock.calls.map(() => [41, true]),
);
add.mockRestore();
remove.mockRestore();
});
it("an action whose request's lines are gone says so instead of linking to an empty list", async () => { it("an action whose request's lines are gone says so instead of linking to an empty list", async () => {
auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }])); auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }]));
window.history.replaceState(null, "", "/?page=activity&at=admin"); window.history.replaceState(null, "", "/?page=activity&at=admin");
+18 -6
View File
@@ -15,7 +15,12 @@ import {
type ActivityWindow, type ActivityWindow,
} from "../lib/activityUrlState"; } from "../lib/activityUrlState";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history"; import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "../lib/logsUrlState"; import {
DEFAULT_LOGS_STATE,
logsParamsToState,
logsParamUpdates,
type LogsUrlState,
} from "../lib/logsUrlState";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { urlWithParams } from "../lib/urlState"; import { urlWithParams } from "../lib/urlState";
@@ -55,17 +60,23 @@ export default function Activity() {
// text then applies at once instead of after the typing debounce. // text then applies at once instead of after the typing debounce.
const [jumps, setJumps] = useState(0); const [jumps, setJumps] = useState(0);
// Bumped by a pivot only: the server log, already open, takes the pivot's filters from the address // Bumped by a pivot only: the server log, already open, takes the pivot's filters from the address
// bar when it mounts again. Back/Forward it follows in place (AdminLogs) — a popstate is also a // bar when it mounts again. Back/Forward it follows in place — a popstate is also a dialog closing,
// dialog closing, onto the same address, and a fresh log would drop its Live and its focus. // onto the same address, and a fresh log would drop its Live and its focus.
const [pivots, setPivots] = useState(0); const [pivots, setPivots] = useState(0);
// The log's half of the entry Back/Forward landed on, read here with this page's half: one listener,
// so both change in one render. Two listeners rendered in between (a browser runs the microtasks
// after each), and that render read the log with one entry's filters and the other's range.
const [landed, setLanded] = useState<{ logs: LogsUrlState } | null>(null);
const q = useDebounced(state.q, 300, jumps); const q = useDebounced(state.q, 300, jumps);
const panel = useTabPanel(); const panel = useTabPanel();
// `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id. // `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id.
useUrlMirror({ page: "activity", ...activityParamUpdates(state) }); useUrlMirror({ page: "activity", ...activityParamUpdates(state) });
useEffect(() => { useEffect(() => {
const onPop = () => { const onPop = () => {
const params = new URLSearchParams(window.location.search);
setJumps((n) => n + 1); setJumps((n) => n + 1);
setState(activityParamsToState(new URLSearchParams(window.location.search))); setState(activityParamsToState(params));
setLanded({ logs: logsParamsToState(params) });
}; };
window.addEventListener("popstate", onPop); window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
@@ -106,7 +117,8 @@ export default function Activity() {
<SharedBar state={state} onChange={update} /> <SharedBar state={state} onChange={update} />
</div> </div>
</PageToolbar> </PageToolbar>
{users.isError && ( {/* Only when the menu really lacks them: a refresh that failed keeps the list it had. */}
{users.isError && !users.data && (
<StateMessage tone="error" compact onRetry={() => void users.refetch()}> <StateMessage tone="error" compact onRetry={() => void users.refetch()}>
{t("activity.accountsFailed")} {t("activity.accountsFailed")}
</StateMessage> </StateMessage>
@@ -135,7 +147,7 @@ export default function Activity() {
) : state.tab === "logs" ? ( ) : state.tab === "logs" ? (
// Keyed by the pivots: a pivot hands the log its filters through the address bar, which it // Keyed by the pivots: a pivot hands the log its filters through the address bar, which it
// reads when it mounts. // reads when it mounts.
<AdminLogs key={pivots} shared={shared} onPivot={pivot} /> <AdminLogs key={pivots} shared={shared} landed={landed} onPivot={pivot} />
) : ( ) : (
<ActivityQuota shared={shared} /> <ActivityQuota shared={shared} />
)} )}
@@ -295,6 +295,14 @@ describe("AdminLogs", () => {
expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed"); expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed");
}); });
it("facets that failed say the filter menus may be short, in the other tabs' words", async () => {
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
facetsMock.mockRejectedValue(new Error("500"));
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
expect((await screen.findByRole("alert")).textContent).toContain("activity.filtersFailed");
});
it("offers no older page while a filter change loads", async () => { it("offers no older page while a filter change loads", async () => {
let landErrors!: (page: LogPage) => void; let landErrors!: (page: LogPage) => void;
logsMock.mockImplementation((q) => logsMock.mockImplementation((q) =>
+20 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
keepPreviousData, keepPreviousData,
@@ -57,9 +57,12 @@ export type LogPivot = Pick<LogsUrlState, "requestId" | "jobId" | "runId">;
export default function AdminLogs({ export default function AdminLogs({
shared, shared,
landed,
onPivot, onPivot,
}: { }: {
shared: Shared; shared: Shared;
/** The filters of the entry Back/Forward landed on (the page reads the address bar, once). */
landed: { logs: LogsUrlState } | null;
/** Show only one request's, job's or run's lines (the page clears every other filter). */ /** Show only one request's, job's or run's lines (the page clears every other filter). */
onPivot: (pivot: LogPivot) => void; onPivot: (pivot: LogPivot) => void;
}) { }) {
@@ -84,18 +87,16 @@ export default function AdminLogs({
const [open, setOpen] = useState<number | null>(null); const [open, setOpen] = useState<number | null>(null);
useUrlMirror(logsParamUpdates(state)); useUrlMirror(logsParamUpdates(state));
// Back/Forward lands on another entry's filters; a dialog closing lands on these same ones, and // Back/Forward lands on another entry's filters; a dialog closing lands on these same ones, and
// changes nothing (Live and the open line stay). // changes nothing (Live and the open line stay). Adopted during the render that brings it, with
const onLanded = useEffectEvent(() => { // the page's half.
const landed = logsParamsToState(new URLSearchParams(window.location.search)); const [seen, setSeen] = useState(landed);
if (JSON.stringify(landed) === JSON.stringify(state)) return; if (landed !== seen) {
setOpen(null); setSeen(landed);
setState(landed); if (landed && JSON.stringify(landed.logs) !== JSON.stringify(state)) {
}); setOpen(null);
useEffect(() => { setState(landed.logs);
const onPop = () => onLanded(); }
window.addEventListener("popstate", onPop); }
return () => window.removeEventListener("popstate", onPop);
}, []);
const update = (patch: Partial<LogsUrlState>) => { const update = (patch: Partial<LogsUrlState>) => {
setOpen(null); setOpen(null);
setState((s) => ({ ...s, ...patch })); setState((s) => ({ ...s, ...patch }));
@@ -127,15 +128,16 @@ export default function AdminLogs({
? null ? null
: (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null); : (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null);
// A read that failed while lines were on screen: they are kept, and the message says what the // A read that failed while lines were on screen: they are kept, and the message says what the
// failure means for them. (With no lines, the list's own error state says it.) // failure means for them. (With no lines, the list's own error state says it.) Without the facets
// the Source and Event menus offer only "Any", as on the other tabs, so the same words say it.
const failure = !logs.data const failure = !logs.data
? null ? null
: logs.isFetchNextPageError : logs.isFetchNextPageError
? "olderFailed" ? "adminLogs.olderFailed"
: logs.isError || (facets.isError && facets.data) : logs.isError || (facets.isError && facets.data)
? "stale" ? "adminLogs.stale"
: facets.isError : facets.isError
? "countsFailed" ? "activity.filtersFailed"
: null; : null;
const retryFailed = () => { const retryFailed = () => {
if (logs.isFetchNextPageError) if (logs.isFetchNextPageError)
@@ -229,7 +231,7 @@ export default function AdminLogs({
list={logs} list={logs}
rowCount={rows.length} rowCount={rows.length}
rowsClassName="font-mono text-2xs" rowsClassName="font-mono text-2xs"
failure={failure && { text: t(`adminLogs.${failure}`), onRetry: retryFailed }} failure={failure && { text: t(failure), onRetry: retryFailed }}
older={ older={
olderCursor !== null olderCursor !== null
? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage } ? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage }
@@ -18,7 +18,6 @@
"empty": "No lines match the filters.", "empty": "No lines match the filters.",
"stale": "Couldn't refresh — what you see may be out of date", "stale": "Couldn't refresh — what you see may be out of date",
"olderFailed": "Couldn't load the older lines", "olderFailed": "Couldn't load the older lines",
"countsFailed": "Couldn't load the filter counts",
"count_one": "{{count}} line loaded · newest first", "count_one": "{{count}} line loaded · newest first",
"count_other": "{{count}} lines loaded · newest first", "count_other": "{{count}} lines loaded · newest first",
"older": "Load older", "older": "Load older",
@@ -18,7 +18,6 @@
"empty": "Nincs a szűrésnek megfelelő sor.", "empty": "Nincs a szűrésnek megfelelő sor.",
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet", "stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat", "olderFailed": "Nem sikerült betölteni a régebbi sorokat",
"countsFailed": "Nem sikerült betölteni a szűrők számait",
"count_one": "{{count}} sor betöltve · a legújabb felül", "count_one": "{{count}} sor betöltve · a legújabb felül",
"count_other": "{{count}} sor betöltve · a legújabb felül", "count_other": "{{count}} sor betöltve · a legújabb felül",
"older": "Régebbiek betöltése", "older": "Régebbiek betöltése",
@@ -16,12 +16,7 @@ import { describe, expect, it } from "vitest";
// keys — so it covers nothing; a key it reads that nothing else names is listed below. Plural forms // keys — so it covers nothing; a key it reads that nothing else names is listed below. Plural forms
// count through their base key. A key read in a way this cannot see goes in ALLOWED with the reason, // count through their base key. A key read in a way this cannot see goes in ALLOWED with the reason,
// rather than the check being loosened for everyone. // rather than the check being loosened for everyone.
const LOG_FAILURE = "AdminLogs reads `adminLogs.${failure}`, a word its query hook returns"; const ALLOWED: Record<string, string> = {};
const ALLOWED: Record<string, string> = {
"adminLogs.olderFailed": LOG_FAILURE,
"adminLogs.stale": LOG_FAILURE,
"adminLogs.countsFailed": LOG_FAILURE,
};
// Namespaces that are one backend enum each, and held key-for-value to it by backendIds.contract. // Namespaces that are one backend enum each, and held key-for-value to it by backendIds.contract.
const BACKEND_ENUMS = ["auditActions.", "quotaActions."]; const BACKEND_ENUMS = ["auditActions.", "quotaActions."];