diff --git a/backend/alembic/versions/0109_job_runs.py b/backend/alembic/versions/0109_job_runs.py index 1def28b4..f75f2c5e 100644 --- a/backend/alembic/versions/0109_job_runs.py +++ b/backend/alembic/versions/0109_job_runs.py @@ -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 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 -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 @@ -72,25 +72,27 @@ def upgrade() -> None: ) # 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 - # has no column for), so a rollback and a redeploy keep every run. A status the run table does + # which a downgrade writes back (as it does a run's trigger, target, spend and start, which the + # 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. op.execute( """ 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, coalesce(after::jsonb ->> 'trigger', CASE WHEN actor_id IS NULL THEN 'scheduled' ELSE 'manual' END), actor_id, after::jsonb ->> 'target', after::jsonb ->> 'status', - created_at, + coalesce((after::jsonb ->> 'started_at')::timestamptz, created_at), created_at, 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 ->> '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 WHERE action = 'job_run' AND target_id IS NOT NULL @@ -109,9 +111,9 @@ def upgrade() -> None: def downgrade() -> None: - # A run still `running` is one the rollback stopped: it goes back as interrupted. Its trigger and - # target ride in `after` when the audit row's own columns cannot say them (an ad-hoc run of one - # channel), so the upgrade restores them. + # A run still `running` is one the rollback stopped: it goes back as interrupted. What the audit + # row's own columns cannot say rides in `after` (its trigger, target, spend and start), so the + # upgrade restores them. op.execute( """ 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) ELSE '{}'::jsonb END - || CASE WHEN trigger = 'ad_hoc' - THEN jsonb_build_object('trigger', trigger) ELSE '{}'::jsonb END + || jsonb_build_object('trigger', trigger, 'quota_units', quota_units, + 'started_at', started_at) || CASE WHEN target IS NOT NULL THEN jsonb_build_object('target', target) ELSE '{}'::jsonb END)::json, coalesce(finished_at, started_at) diff --git a/backend/app/jobruns.py b/backend/app/jobruns.py index a51249ae..7beb7572 100644 --- a/backend/app/jobruns.py +++ b/backend/app/jobruns.py @@ -119,16 +119,6 @@ def recent_runs(db: Session, jobs: list[str], per_job: int) -> dict[str, list[Jo 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: """Delete finished runs older than `retention_days` (0 = keep forever). A run still going is never reaped. The caller commits.""" diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 29904095..4e112a56 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -15,7 +15,7 @@ from app import audit, debugsession, jobruns, logs, logsink, progress, quota, sy from app.config import settings from app.db import SessionLocal 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.tmdb import run_enrich as run_tmdb_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. _activity: dict[str, dict] = {} _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 # 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) with _activity_lock: 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) jobs = [] 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 # 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 - # once, so a run still going counts even when a newer one already ended, and the row names it. + # target's own (sync/runner), so "running" is the runs this process has open: two channels can + # 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, []) - going = jobruns.running_one(db, CHANNEL_BACKFILL) - shown = going or (backfills[0] if backfills else None) + going = db.get(JobRun, going_id) if going_id is not None 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 = [ { "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. log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target) 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): # As in _run: only the work is inside the try. try: diff --git a/backend/tests/test_migration_0109.py b/backend/tests/test_migration_0109.py index a597e0d7..9fd08814 100644 --- a/backend/tests/test_migration_0109.py +++ b/backend/tests/test_migration_0109.py @@ -3,6 +3,7 @@ outcome intact, and leave the audit log; an admin action stays where it was. The them back.""" import json +from datetime import timedelta from alembic import command 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() finally: 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] == [ - (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, "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( text( "INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at," - " finished_at) VALUES" - " ('backfill', 'scheduled', NULL, NULL, 'interrupted', now(), now())," - " ('maintenance', 'manual', :u, NULL, 'running', now(), NULL)," - " ('channel_backfill', 'ad_hoc', :u, 'UC_x', 'ok', now(), now())" + " finished_at, quota_units) VALUES" + " ('backfill', 'scheduled', NULL, NULL, 'interrupted', now(), now(), 0)," + " ('maintenance', 'manual', :u, NULL, 'running', now(), NULL, 0)," + " ('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}, ) @@ -120,6 +138,9 @@ def test_a_down_and_up_round_trip_keeps_every_run(db, make_user): runs = conn.execute( text("SELECT job, trigger, actor_id, target, status FROM job_runs ORDER BY id") ).all() + kept = conn.execute( + text("SELECT quota_units, finished_at - started_at FROM job_runs WHERE job = 'enrich'") + ).one() stranded = conn.execute( text("SELECT count(*) FROM audit_log WHERE action = 'job_run'") ).scalar() @@ -127,5 +148,7 @@ def test_a_down_and_up_round_trip_keeps_every_run(db, make_user): ("backfill", "scheduled", None, None, "interrupted"), ("maintenance", "manual", uid, None, "interrupted"), # the rollback stopped it ("channel_backfill", "ad_hoc", uid, "UC_x", "ok"), + ("enrich", "manual", None, None, "ok"), ] + assert tuple(kept) == (42, timedelta(minutes=30)) assert stranded == 0 diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index 767aec5d..cb6f1ef7 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -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 all(j["id"] != "channel_backfill" for j in scheduler.scheduler_snapshot(db)["jobs"]) - jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_shown") - assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is True + seen = [] + 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]: @@ -458,6 +465,8 @@ def test_a_run_whose_outcome_cannot_be_written_is_not_called_failed(db, make_use calls.clear() scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_blip") 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): @@ -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") db.add(Channel(id="UC_long", title="Long one")) db.commit() - jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_long") - scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_short") - (row,) = scheduler.scheduler_snapshot(db)["ad_hoc"] + rows = [] + + 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") + # 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( diff --git a/frontend/src/components/Activity.dom.test.tsx b/frontend/src/components/Activity.dom.test.tsx index e74f7fb2..53c2d1f3 100644 --- a/frontend/src/components/Activity.dom.test.tsx +++ b/frontend/src/components/Activity.dom.test.tsx @@ -45,6 +45,7 @@ vi.mock("../lib/api", async (importOriginal) => { }; }); +import { qk } from "../lib/queryKeys"; import Activity from "./Activity"; import { ChoiceContext } from "./confirmContext"; import { PageToolbarSlot } from "./ui/pageToolbarSlot"; @@ -90,8 +91,7 @@ const page = (items: T[]): HistoryPage => ({ next_cursor: null, }); -function renderPage() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +function renderPage(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { return render( {/* Every confirmation is answered yes. */} @@ -259,6 +259,16 @@ describe("Activity", () => { 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 () => { window.history.replaceState(null, "", "/?page=activity&at=admin"); renderPage(); @@ -285,6 +295,41 @@ describe("Activity", () => { 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 () => { auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }])); window.history.replaceState(null, "", "/?page=activity&at=admin"); diff --git a/frontend/src/components/Activity.tsx b/frontend/src/components/Activity.tsx index d7ff5364..8db4fa7f 100644 --- a/frontend/src/components/Activity.tsx +++ b/frontend/src/components/Activity.tsx @@ -15,7 +15,12 @@ import { type ActivityWindow, } from "../lib/activityUrlState"; 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 { useDebounced } from "../lib/useDebounced"; import { urlWithParams } from "../lib/urlState"; @@ -55,17 +60,23 @@ export default function Activity() { // text then applies at once instead of after the typing debounce. const [jumps, setJumps] = useState(0); // 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 - // dialog closing, onto the same address, and a fresh log would drop its Live and its focus. + // bar when it mounts again. Back/Forward it follows in place — a popstate is also a dialog closing, + // onto the same address, and a fresh log would drop its Live and its focus. 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 panel = useTabPanel(); // `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id. useUrlMirror({ page: "activity", ...activityParamUpdates(state) }); useEffect(() => { const onPop = () => { + const params = new URLSearchParams(window.location.search); setJumps((n) => n + 1); - setState(activityParamsToState(new URLSearchParams(window.location.search))); + setState(activityParamsToState(params)); + setLanded({ logs: logsParamsToState(params) }); }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); @@ -106,7 +117,8 @@ export default function Activity() { - {users.isError && ( + {/* Only when the menu really lacks them: a refresh that failed keeps the list it had. */} + {users.isError && !users.data && ( void users.refetch()}> {t("activity.accountsFailed")} @@ -135,7 +147,7 @@ export default function Activity() { ) : state.tab === "logs" ? ( // Keyed by the pivots: a pivot hands the log its filters through the address bar, which it // reads when it mounts. - + ) : ( )} diff --git a/frontend/src/components/AdminLogs.dom.test.tsx b/frontend/src/components/AdminLogs.dom.test.tsx index fbf18d3b..fde06401 100644 --- a/frontend/src/components/AdminLogs.dom.test.tsx +++ b/frontend/src/components/AdminLogs.dom.test.tsx @@ -295,6 +295,14 @@ describe("AdminLogs", () => { 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 () => { let landErrors!: (page: LogPage) => void; logsMock.mockImplementation((q) => diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index 703dbc76..763212ba 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -1,4 +1,4 @@ -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, @@ -57,9 +57,12 @@ export type LogPivot = Pick; export default function AdminLogs({ shared, + landed, onPivot, }: { 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). */ onPivot: (pivot: LogPivot) => void; }) { @@ -84,18 +87,16 @@ export default function AdminLogs({ const [open, setOpen] = useState(null); useUrlMirror(logsParamUpdates(state)); // 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). - const onLanded = useEffectEvent(() => { - const landed = logsParamsToState(new URLSearchParams(window.location.search)); - if (JSON.stringify(landed) === JSON.stringify(state)) return; - setOpen(null); - setState(landed); - }); - useEffect(() => { - const onPop = () => onLanded(); - window.addEventListener("popstate", onPop); - return () => window.removeEventListener("popstate", onPop); - }, []); + // changes nothing (Live and the open line stay). Adopted during the render that brings it, with + // the page's half. + const [seen, setSeen] = useState(landed); + if (landed !== seen) { + setSeen(landed); + if (landed && JSON.stringify(landed.logs) !== JSON.stringify(state)) { + setOpen(null); + setState(landed.logs); + } + } const update = (patch: Partial) => { setOpen(null); setState((s) => ({ ...s, ...patch })); @@ -127,15 +128,16 @@ export default function AdminLogs({ ? 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 - // 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 ? null : logs.isFetchNextPageError - ? "olderFailed" + ? "adminLogs.olderFailed" : logs.isError || (facets.isError && facets.data) - ? "stale" + ? "adminLogs.stale" : facets.isError - ? "countsFailed" + ? "activity.filtersFailed" : null; const retryFailed = () => { if (logs.isFetchNextPageError) @@ -229,7 +231,7 @@ export default function AdminLogs({ list={logs} rowCount={rows.length} rowsClassName="font-mono text-2xs" - failure={failure && { text: t(`adminLogs.${failure}`), onRetry: retryFailed }} + failure={failure && { text: t(failure), onRetry: retryFailed }} older={ olderCursor !== null ? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage } diff --git a/frontend/src/i18n/locales/en/adminLogs.json b/frontend/src/i18n/locales/en/adminLogs.json index 96b3a827..8071aa3f 100644 --- a/frontend/src/i18n/locales/en/adminLogs.json +++ b/frontend/src/i18n/locales/en/adminLogs.json @@ -18,7 +18,6 @@ "empty": "No lines match the filters.", "stale": "Couldn't refresh — what you see may be out of date", "olderFailed": "Couldn't load the older lines", - "countsFailed": "Couldn't load the filter counts", "count_one": "{{count}} line loaded · newest first", "count_other": "{{count}} lines loaded · newest first", "older": "Load older", diff --git a/frontend/src/i18n/locales/hu/adminLogs.json b/frontend/src/i18n/locales/hu/adminLogs.json index e1ddefc5..c0d97fb6 100644 --- a/frontend/src/i18n/locales/hu/adminLogs.json +++ b/frontend/src/i18n/locales/hu/adminLogs.json @@ -18,7 +18,6 @@ "empty": "Nincs a szűrésnek megfelelő sor.", "stale": "Nem sikerült frissíteni — amit látsz, elavult lehet", "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_other": "{{count}} sor betöltve · a legújabb felül", "older": "Régebbiek betöltése", diff --git a/frontend/src/i18n/unusedKeys.contract.test.ts b/frontend/src/i18n/unusedKeys.contract.test.ts index 2a89b2d4..c7905892 100644 --- a/frontend/src/i18n/unusedKeys.contract.test.ts +++ b/frontend/src/i18n/unusedKeys.contract.test.ts @@ -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 // 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. -const LOG_FAILURE = "AdminLogs reads `adminLogs.${failure}`, a word its query hook returns"; -const ALLOWED: Record = { - "adminLogs.olderFailed": LOG_FAILURE, - "adminLogs.stale": LOG_FAILURE, - "adminLogs.countsFailed": LOG_FAILURE, -}; +const ALLOWED: Record = {}; // Namespaces that are one backend enum each, and held key-for-value to it by backendIds.contract. const BACKEND_ENUMS = ["auditActions.", "quotaActions."];