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:
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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."];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user