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

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

Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
  Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
  opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
  frame with the other tabs; an empty list is announced; each day's quota is readable.
This commit is contained in:
2026-10-18 15:38:32 +02:00
parent 3021be5166
commit fad656988a
44 changed files with 1101 additions and 426 deletions
+5 -3
View File
@@ -68,9 +68,11 @@ SMTP_FROM=
# --- Scheduler --- # --- Scheduler ---
# The background scheduler (subscription sync, backfill, enrichment) runs inside the app. # The background scheduler (subscription sync, backfill, enrichment) runs inside the app.
# DATABASE_URL is set for you by docker-compose to the bundled `db` service. If you ever run # DATABASE_URL is set for you by docker-compose to the bundled `db` service. Run ONE app instance
# more than one instance against the same database, keep SCHEDULER_ENABLED=true on exactly one # per database: a starting instance closes every run still open as interrupted (it assumes the
# of them and false on the rest, to avoid double quota use and write races. # previous process is gone), and "is this job already running" is held in the process. A second
# instance would cut the first one's runs short and could run the same job twice.
# SCHEDULER_ENABLED=false keeps the recurring jobs off; "run now" still works.
SCHEDULER_ENABLED=true SCHEDULER_ENABLED=true
# --- Files library & downloads --- # --- Files library & downloads ---
+29 -16
View File
@@ -12,7 +12,8 @@ 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, then drops the table. 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.
""" """
import sqlalchemy as sa import sqlalchemy as sa
@@ -70,15 +71,19 @@ def upgrade() -> None:
postgresql_where=sa.text("run_id IS NOT NULL"), postgresql_where=sa.text("run_id IS NOT NULL"),
) )
# The audit trail's runs move over. A status the run table does not know (none was ever # The audit trail's runs move over: the three statuses the scheduler wrote, and `interrupted`,
# written) would fail the CHECK, so the move is limited to the three the scheduler wrote. # 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
# not know would fail the CHECK.
op.execute( op.execute(
""" """
INSERT INTO job_runs (job, trigger, actor_id, status, started_at, finished_at, summary, INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at, finished_at,
result, error, failure) summary, result, error, failure)
SELECT target_id, SELECT target_id,
CASE WHEN actor_id IS NULL THEN 'scheduled' ELSE 'manual' END, coalesce(after::jsonb ->> 'trigger',
CASE WHEN actor_id IS NULL THEN 'scheduled' ELSE 'manual' END),
actor_id, actor_id,
after::jsonb ->> 'target',
after::jsonb ->> 'status', after::jsonb ->> 'status',
created_at, created_at,
created_at, created_at,
@@ -89,7 +94,7 @@ def upgrade() -> None:
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
AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped') AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped', 'interrupted')
ORDER BY id ORDER BY id
""" """
) )
@@ -98,12 +103,15 @@ def upgrade() -> None:
DELETE FROM audit_log DELETE FROM audit_log
WHERE action = 'job_run' WHERE action = 'job_run'
AND target_id IS NOT NULL AND target_id IS NOT NULL
AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped') AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped', 'interrupted')
""" """
) )
def downgrade() -> 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.
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)
@@ -111,21 +119,26 @@ def downgrade() -> None:
'job_run', 'job_run',
'job', 'job',
job, job,
left(job || ': ' || status left(job || ': ' || st
|| coalesce(' — ' || CASE WHEN status = 'error' THEN error ELSE summary END, ''), || coalesce(' — ' || CASE WHEN st = 'error' THEN error ELSE summary END, ''),
255), 255),
(jsonb_build_object( (jsonb_build_object(
'status', status, 'status', st,
'summary', CASE WHEN status = 'error' THEN error ELSE summary END) 'summary', CASE WHEN st = 'error' THEN error ELSE summary END)
|| CASE || CASE
WHEN status = 'error' AND failure IS NOT NULL WHEN st = 'error' AND failure IS NOT NULL
THEN jsonb_build_object('result', failure) THEN jsonb_build_object('result', failure)
WHEN status <> 'error' AND result IS NOT NULL WHEN st <> 'error' AND result IS NOT NULL
THEN jsonb_build_object('result', result) THEN jsonb_build_object('result', result)
ELSE '{}'::jsonb ELSE '{}'::jsonb
END)::json, END
|| CASE WHEN trigger = 'ad_hoc'
THEN jsonb_build_object('trigger', trigger) ELSE '{}'::jsonb END
|| CASE WHEN target IS NOT NULL
THEN jsonb_build_object('target', target) ELSE '{}'::jsonb END)::json,
coalesce(finished_at, started_at) coalesce(finished_at, started_at)
FROM job_runs FROM (SELECT *, CASE WHEN status = 'running' THEN 'interrupted' ELSE status END AS st
FROM job_runs) AS r
ORDER BY id ORDER BY id
""" """
) )
+42 -15
View File
@@ -5,13 +5,14 @@ OWN short session, committed at once, for the reason `quota.record_usage` gives:
may roll back (its `except: db.rollback()`), and the history of what happened must not go with it — may roll back (its `except: db.rollback()`), and the history of what happened must not go with it —
nor may this bookkeeping commit the job's half-done session. nor may this bookkeeping commit the job's half-done session.
Only the API process runs jobs (the worker never starts the scheduler), so at its startup nothing can Only the API process runs jobs (the worker never starts the scheduler), and one API process serves a
still be running: `interrupt_leftovers` closes what a stopped process left open. database (`.env.example`, the Scheduler section: the single-run guard is in its memory too), so at its
startup nothing can still be running: `interrupt_leftovers` closes what a stopped process left open.
""" """
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select, update from sqlalchemy import delete, func, select, union_all, update
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -70,30 +71,46 @@ def finish(
def interrupt_leftovers() -> int: def interrupt_leftovers() -> int:
"""Every row a previous process left `running` → `interrupted`. Called once at startup, before """Every row a previous process left `running` → `interrupted`, with what it spent until then
the scheduler can open a new run. Returns how many.""" (its quota rows outlive it, as `finish` sums them). Called once at startup, before the scheduler
can open a new run. Returns how many."""
spent = (
select(func.coalesce(func.sum(QuotaEvent.units), 0))
.where(QuotaEvent.job_run_id == JobRun.id)
.scalar_subquery()
)
with SessionLocal() as s: with SessionLocal() as s:
res = s.execute( res = s.execute(
update(JobRun) update(JobRun)
.where(JobRun.status == RunStatus.RUNNING) .where(JobRun.status == RunStatus.RUNNING)
.values(status=RunStatus.INTERRUPTED, finished_at=datetime.now(timezone.utc)) .values(
status=RunStatus.INTERRUPTED,
finished_at=datetime.now(timezone.utc),
quota_units=spent,
)
) )
s.commit() s.commit()
return res.rowcount if isinstance(res, CursorResult) else 0 return res.rowcount if isinstance(res, CursorResult) else 0
def recent_runs(db: Session, per_job: int) -> dict[str, list[JobRun]]: def recent_runs(db: Session, jobs: list[str], per_job: int) -> dict[str, list[JobRun]]:
"""Each job's last `per_job` runs, newest first, by job id — one query (a window per job).""" """Each of `jobs`' last `per_job` runs, newest first, by job id — one query, each job's part a
ranked = select( short walk of `ix_job_runs_job_started`, so a poll costs the same at any history length (a window
JobRun.id, over the whole table grew with every run kept)."""
func.row_number() if not jobs:
.over(partition_by=JobRun.job, order_by=(JobRun.started_at.desc(), JobRun.id.desc())) return {}
.label("rank"), newest = union_all(
*(
select(JobRun.id)
.where(JobRun.job == job)
.order_by(JobRun.started_at.desc(), JobRun.id.desc())
.limit(per_job)
for job in jobs
)
).subquery() ).subquery()
rows = db.execute( rows = db.execute(
select(JobRun) select(JobRun)
.join(ranked, ranked.c.id == JobRun.id) .where(JobRun.id.in_(select(newest.c.id)))
.where(ranked.c.rank <= per_job)
.order_by(JobRun.job, JobRun.started_at.desc(), JobRun.id.desc()) .order_by(JobRun.job, JobRun.started_at.desc(), JobRun.id.desc())
).scalars() ).scalars()
out: dict[str, list[JobRun]] = {} out: dict[str, list[JobRun]] = {}
@@ -102,6 +119,16 @@ def recent_runs(db: Session, per_job: int) -> dict[str, list[JobRun]]:
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."""
+1 -1
View File
@@ -720,7 +720,7 @@ class JobRun(Base):
CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"), CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"),
# Only a run still going has no end. # Only a run still going has no end.
CheckConstraint( CheckConstraint(
"(status = 'running') = (finished_at IS NULL)", name="ck_job_runs_finished" f"(status = '{RunStatus.RUNNING}') = (finished_at IS NULL)", name="ck_job_runs_finished"
), ),
) )
+17
View File
@@ -0,0 +1,17 @@
"""The account filter the activity page's lists share (runs, admin actions, server log, quota): one
filter bar drives them all, so the value it sends means the same on every tab.
The value is an account id, or `none` — the rows no account made (the background) — a value to filter
on, not the absence of a filter.
"""
from sqlalchemy import ColumnElement
NONE = "none"
# The `user` query parameter's shape on every route that takes it.
PATTERN = r"^(none|[0-9]{1,10})$"
def condition(column, value: str) -> ColumnElement[bool]:
"""`column` (an account id column) narrowed to `value`."""
return column.is_(None) if value == NONE else column == int(value)
+6 -9
View File
@@ -25,6 +25,7 @@ from app import logsink
from app.auth import admin_user from app.auth import admin_user
from app.db import get_db from app.db import get_db
from app.models import LogEvent, User from app.models import LogEvent, User
from app.routes import _account as account
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
from app.utils import like_escape from app.utils import like_escape
@@ -37,9 +38,9 @@ FACET_EVENTS_MAX = 100
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# The filters a facet can leave out of its own count (`LogFilters.conditions`). # The filters a facet can leave out of its own count (`LogFilters.conditions`).
Facet = Literal["levels", "sources", "user", "event"] Facet = Literal["levels", "sources", "user", "event"]
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous # `none` selects the rows that have no event code, as the account filter's `none` (app/routes/_account)
# request) — a real value to filter on, not the absence of a filter. # selects the rows of no account — a real value to filter on, not the absence of a filter.
NONE = "none" NONE = account.NONE
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -67,11 +68,7 @@ class LogFilters:
if self.sources and skip != "sources": if self.sources and skip != "sources":
out.append(LogEvent.source.in_(self.sources)) out.append(LogEvent.source.in_(self.sources))
if self.user is not None and skip != "user": if self.user is not None and skip != "user":
out.append( out.append(account.condition(LogEvent.user_id, self.user))
LogEvent.user_id.is_(None)
if self.user == NONE
else LogEvent.user_id == int(self.user)
)
if self.event is not None and skip != "event": if self.event is not None and skip != "event":
out.append( out.append(
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
@@ -103,7 +100,7 @@ class LogFilters:
def log_filters( def log_filters(
levels: list[Level] = Query(default=[]), levels: list[Level] = Query(default=[]),
sources: list[str] = Query(default=[], max_length=8), sources: list[str] = Query(default=[], max_length=8),
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"), user: str | None = Query(default=None, pattern=account.PATTERN),
event: str | None = Query( event: str | None = Query(
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$" default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
), ),
+18 -12
View File
@@ -15,17 +15,15 @@ from app import audit
from app.audit import AuditAction from app.audit import AuditAction
from app.auth import admin_user from app.auth import admin_user
from app.db import get_db from app.db import get_db
from app.models import AuditLog, User from app.models import AuditLog, LogEvent, User
from app.routes import _account as account
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
from app.utils import iso, like_escape from app.utils import iso, like_escape
router = APIRouter(prefix="/api/admin/audit", tags=["admin"]) router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
# `none` selects the rows no account made (the background) — a value to filter on, not "any".
NONE = "none"
def _serialize(row: AuditLog, lines: dict[str, int]) -> dict:
def _serialize(row: AuditLog) -> dict:
return { return {
"id": row.id, "id": row.id,
"created_at": iso(row.created_at), "created_at": iso(row.created_at),
@@ -38,6 +36,9 @@ def _serialize(row: AuditLog) -> dict:
"before": row.before, "before": row.before,
"after": row.after, "after": row.after,
"request_id": row.request_id, "request_id": row.request_id,
# How many of that request's log lines are still kept: they go after days, the row after
# months, and a link to none would open an empty list.
"log_lines": lines.get(row.request_id, 0) if row.request_id else 0,
} }
@@ -56,11 +57,7 @@ class AuditFilters:
if self.action is not None and skip != "action": if self.action is not None and skip != "action":
out.append(AuditLog.action == self.action) out.append(AuditLog.action == self.action)
if self.actor is not None: if self.actor is not None:
out.append( out.append(account.condition(AuditLog.actor_id, self.actor))
AuditLog.actor_id.is_(None)
if self.actor == NONE
else AuditLog.actor_id == int(self.actor)
)
if self.since is not None: if self.since is not None:
out.append(AuditLog.created_at >= utc(self.since)) out.append(AuditLog.created_at >= utc(self.since))
if self.until is not None: if self.until is not None:
@@ -82,7 +79,7 @@ class AuditFilters:
def audit_filters( def audit_filters(
action: str | None = Query(default=None, max_length=48, pattern=r"^[a-z][a-z0-9_]*$"), action: str | None = Query(default=None, max_length=48, pattern=r"^[a-z][a-z0-9_]*$"),
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"), user: str | None = Query(default=None, pattern=account.PATTERN),
q: str | None = Query(default=None, max_length=200), q: str | None = Query(default=None, max_length=200),
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
@@ -109,8 +106,17 @@ def list_audit(
limit, limit,
lambda r: r.created_at, lambda r: r.created_at,
) )
requests = {r.request_id for r in rows if r.request_id}
lines: dict[str, int] = {}
if requests:
counted = db.execute(
select(LogEvent.request_id, func.count())
.where(LogEvent.request_id.in_(requests))
.group_by(LogEvent.request_id)
)
lines = {rid: n for rid, n in counted.tuples() if rid is not None}
return { return {
"items": [_serialize(r) for r in rows], "items": [_serialize(r, lines) for r in rows],
"accounts": accounts(db, {r.actor_id for r in rows if r.actor_id is not None}), "accounts": accounts(db, {r.actor_id for r in rows if r.actor_id is not None}),
"next_cursor": cursor, "next_cursor": cursor,
} }
+3 -1
View File
@@ -512,7 +512,9 @@ def update_channel(
# so the whole catalogue lands in seconds instead of over the scheduler's paced 10-minute ticks. # so the whole catalogue lands in seconds instead of over the scheduler's paced 10-minute ticks.
# The request returns right away; the channel page's live poll shows the count climb. The # The request returns right away; the channel page's live poll shows the count climb. The
# scheduler still finishes a very large channel's tail on its normal runs (deep_requested stays set). # scheduler still finishes a very large channel's tail on its normal runs (deep_requested stays set).
if deep_turned_on: # A channel whose history is all in gets no run: it would only say "nothing to do", one kept row
# per flip of the flag.
if deep_turned_on and not (channel is not None and channel.backfill_done):
scheduler.run_ad_hoc( scheduler.run_ad_hoc(
scheduler.CHANNEL_BACKFILL, scheduler.CHANNEL_BACKFILL,
partial(backfill_channel_now, channel_id, user.id), partial(backfill_channel_now, channel_id, user.id),
+3 -4
View File
@@ -14,6 +14,7 @@ from app import quota
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.models import QuotaEvent, User from app.models import QuotaEvent, User
from app.routes import _account as account
from app.routes._keyset import utc from app.routes._keyset import utc
router = APIRouter(prefix="/api/quota", tags=["quota"]) router = APIRouter(prefix="/api/quota", tags=["quota"])
@@ -48,7 +49,7 @@ def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db))
def admin_usage( def admin_usage(
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"), user: str | None = Query(default=None, pattern=account.PATTERN),
_: User = Depends(admin_user), _: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -60,9 +61,7 @@ def admin_usage(
if until is not None: if until is not None:
window.append(QuotaEvent.created_at < utc(until)) window.append(QuotaEvent.created_at < utc(until))
if user is not None: if user is not None:
window.append( window.append(account.condition(QuotaEvent.user_id, user))
QuotaEvent.user_id.is_(None) if user == "none" else QuotaEvent.user_id == int(user)
)
totals = db.execute( totals = db.execute(
select(QuotaEvent.user_id, func.sum(QuotaEvent.units)) select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
+3 -8
View File
@@ -19,14 +19,13 @@ from app import scheduler
from app.auth import admin_user from app.auth import admin_user
from app.db import get_db from app.db import get_db
from app.models import Channel, JobRun, LogEvent, User from app.models import Channel, JobRun, LogEvent, User
from app.routes import _account as account
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
from app.runstatus import RunStatus from app.runstatus import RunStatus
from app.utils import iso, like_escape from app.utils import iso, like_escape
router = APIRouter(prefix="/api/admin/runs", tags=["admin"]) router = APIRouter(prefix="/api/admin/runs", tags=["admin"])
# `none` selects the runs no account started (the scheduled ones).
NONE = "none"
Facet = Literal["job", "status"] Facet = Literal["job", "status"]
@@ -68,11 +67,7 @@ class RunFilters:
if self.status is not None and skip != "status": if self.status is not None and skip != "status":
out.append(JobRun.status == self.status) out.append(JobRun.status == self.status)
if self.actor is not None: if self.actor is not None:
out.append( out.append(account.condition(JobRun.actor_id, self.actor))
JobRun.actor_id.is_(None)
if self.actor == NONE
else JobRun.actor_id == int(self.actor)
)
if self.since is not None: if self.since is not None:
out.append(JobRun.started_at >= utc(self.since)) out.append(JobRun.started_at >= utc(self.since))
if self.until is not None: if self.until is not None:
@@ -93,7 +88,7 @@ class RunFilters:
def run_filters( def run_filters(
job: str | None = Query(default=None, max_length=40, pattern=r"^[a-z][a-z0-9_]*$"), job: str | None = Query(default=None, max_length=40, pattern=r"^[a-z][a-z0-9_]*$"),
status: RunStatus | None = None, status: RunStatus | None = None,
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"), user: str | None = Query(default=None, pattern=account.PATTERN),
q: str | None = Query(default=None, max_length=200), q: str | None = Query(default=None, max_length=200),
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
+6 -4
View File
@@ -17,8 +17,9 @@ from app.scheduler import (
JOB_INTERVALS, JOB_INTERVALS,
MAX_INTERVAL, MAX_INTERVAL,
MIN_INTERVAL, MIN_INTERVAL,
apply_interval, reschedule,
scheduler_snapshot, scheduler_snapshot,
stage_interval,
trigger_all, trigger_all,
trigger_job, trigger_job,
) )
@@ -42,14 +43,14 @@ def set_job_interval(
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied """Change how often a scheduler job runs (minutes). Stored as an override together with its
to the live scheduler immediately.""" audit row, then applied to the live scheduler."""
if job_id not in JOB_INTERVALS: if job_id not in JOB_INTERVALS:
raise not_found("job") raise not_found("job")
minutes = body.interval_minutes minutes = body.interval_minutes
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL): if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL) raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
applied = apply_interval(db, job_id, minutes) applied = stage_interval(db, job_id, minutes)
audit.record( audit.record(
db, db,
admin.id, admin.id,
@@ -60,6 +61,7 @@ def set_job_interval(
after={"interval_minutes": applied}, after={"interval_minutes": applied},
) )
db.commit() db.commit()
reschedule(job_id, applied)
return {"ok": True, "id": job_id, "interval_minutes": applied} return {"ok": True, "id": job_id, "interval_minutes": applied}
+5 -3
View File
@@ -1,9 +1,11 @@
"""The words a background run's row is written with — the `job_runs` table's status and trigger. """The words a background run's row is written with — the `job_runs` table's status and trigger.
One source for the literals: the scheduler writes them, the startup sweep rewrites `running`, the One source for the literals: the scheduler writes them, the startup sweep rewrites `running`, the
model's CHECK constraints are built from these sets, and the dashboard reads them back. A run's model's CHECK constraints are built from these sets, and the dashboard reads them back (the frontend's
status is never typed as a string anywhere else (the download pipeline's `cancelled`/`canceled` copies are held to them by `backendIds.contract.test.ts`). The migrated database's CHECKs are frozen
drift, `downloads/status.py`, is why). in its migration: a test holds them equal to these sets, so a new word fails until a migration adds
it (`test_runstatus.py`). The download pipeline's `cancelled`/`canceled` drift (`downloads/status.py`)
is why a status is never typed as a bare string.
""" """
from enum import StrEnum from enum import StrEnum
+68 -41
View File
@@ -3,7 +3,7 @@ recent-first / deep backfill. Runs inside the API process (single worker)."""
import contextvars import contextvars
import threading import threading
from datetime import datetime, timezone from datetime import timezone
from typing import Callable from typing import Callable
from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.executors.pool import ThreadPoolExecutor
@@ -27,6 +27,7 @@ from app.sync.autotag import run_autotag_all
from app.sync.explore import purge_ephemeral from app.sync.explore import purge_ephemeral
from app.sync.maintenance import run_maintenance from app.sync.maintenance import run_maintenance
from app.sync.playlists import sync_all_playlists from app.sync.playlists import sync_all_playlists
from app.utils import iso
from app.sync.runner import ( from app.sync.runner import (
run_deep_backfill, run_deep_backfill,
run_enrich, run_enrich,
@@ -67,18 +68,21 @@ def load_intervals(db) -> dict[str, int]:
return {job: sysconfig.get_int(db, sysconfig.interval_key(job)) for job in JOB_INTERVALS} return {job: sysconfig.get_int(db, sysconfig.interval_key(job)) for job in JOB_INTERVALS}
def apply_interval(db, job_id: str, minutes: int) -> int: def stage_interval(db, job_id: str, minutes: int) -> int:
"""Persist a job's interval override and reschedule the live job immediately (if the """Stage a job's interval override (clamped) in `db`; returns the value. The caller commits it
scheduler runs in this process). Returns the applied value.""" with its audit row, then calls `reschedule` — the live job follows only what was stored."""
if job_id not in JOB_INTERVALS: if job_id not in JOB_INTERVALS:
raise KeyError(job_id) raise KeyError(job_id)
minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes))) minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes)))
sysconfig.set_value(db, sysconfig.interval_key(job_id), minutes) sysconfig.set_value(db, sysconfig.interval_key(job_id), minutes)
db.commit() return minutes
def reschedule(job_id: str, minutes: int) -> None:
"""Move the live interval job to `minutes` (if this process registered it)."""
if _scheduler is not None and _scheduler.get_job(job_id) is not None: if _scheduler is not None and _scheduler.get_job(job_id) is not None:
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes) _scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
log.info("rescheduled job %s to every %s min", job_id, minutes) log.info("rescheduled job %s to every %s min", job_id, minutes)
return minutes
def _record(name: str, **fields) -> None: def _record(name: str, **fields) -> None:
@@ -87,7 +91,12 @@ def _record(name: str, **fields) -> None:
def _notify_done( def _notify_done(
db, actor_id: int, job_id: str, status: str, summary: str | None, result: dict | None = None db,
actor_id: int,
job_id: str,
status: RunStatus,
summary: str | None,
result: dict | None = None,
) -> None: ) -> None:
"""Post a completion notice to the triggering admin's inbox (manual runs only). Best """Post a completion notice to the triggering admin's inbox (manual runs only). Best
effort — a notification failure must never mask the job's own outcome.""" effort — a notification failure must never mask the job's own outcome."""
@@ -139,24 +148,40 @@ def _run(db, name: str, fn, run_id: int, actor_id: int | None) -> None:
def sink(current, total, phase): def sink(current, total, phase):
_record(name, progress={"current": current, "total": total, "phase": phase}) _record(name, progress={"current": current, "total": total, "phase": phase})
# Only the work is inside the try: writing its outcome is not the job, and a write that fails
# must not turn a run that succeeded into a failed one (see _close).
result = None
try: try:
if is_sync_paused(db): paused = is_sync_paused(db)
log.info("job %s skipped (sync paused)", name) if not paused:
jobruns.finish(run_id, RunStatus.SKIPPED, summary="sync paused", result=_PAUSED)
if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED)
return
with progress.bind(sink): with progress.bind(sink):
result = fn(db) result = fn(db)
summary, data = _finish_ok(name, run_id, result)
if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary, data)
except Exception as exc: except Exception as exc:
db.rollback() db.rollback()
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name)) log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
err, failure = _finish_error(run_id, exc) err, failure = _failure(exc)
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
if actor_id is not None: if actor_id is not None:
_notify_done(db, actor_id, name, "error", err, failure) _notify_done(db, actor_id, name, RunStatus.ERROR, err, failure)
return
if paused:
log.info("job %s skipped (sync paused)", name)
_close(run_id, RunStatus.SKIPPED, summary="sync paused", result=_PAUSED)
if actor_id is not None:
_notify_done(db, actor_id, name, RunStatus.SKIPPED, "sync paused", _PAUSED)
return
summary, data = _finish_ok(name, run_id, result)
if actor_id is not None:
_notify_done(db, actor_id, name, RunStatus.OK, summary, data)
def _close(run_id: int, status: RunStatus, **outcome) -> None:
"""Write a run's outcome. Its own failure (the database) is the log's to tell, never the job's:
the row then stays `running` until the next start's sweep closes it as interrupted."""
try:
jobruns.finish(run_id, status, **outcome)
except Exception:
log.exception("run %s: its outcome (%s) could not be recorded", run_id, status)
def _finish_ok(name: str, run_id: int, result) -> tuple[str | None, dict | None]: def _finish_ok(name: str, run_id: int, result) -> tuple[str | None, dict | None]:
@@ -164,16 +189,15 @@ def _finish_ok(name: str, run_id: int, result) -> tuple[str | None, dict | None]
summary = _summarize(result) summary = _summarize(result)
data = _result_data(result) data = _result_data(result)
log.info("job %s -> %s", name, result) log.info("job %s -> %s", name, result)
jobruns.finish(run_id, RunStatus.OK, summary=summary, result=data) _close(run_id, RunStatus.OK, summary=summary, result=data)
return summary, data return summary, data
def _finish_error(run_id: int, exc: Exception) -> tuple[str, dict | None]: def _failure(exc: Exception) -> tuple[str, dict | None]:
"""Close a run that raised. A failure the job named (progress.JobFailed) rides along as data, as """A raised run's English error line, and the failure the job named (progress.JobFailed) as
a result does, so the client says it in the reader's language; the error stays the English line.""" data, as a result is, so the client says it in the reader's language."""
err = str(exc) or exc.__class__.__name__ err = str(exc) or exc.__class__.__name__
failure = {"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None failure = {"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
jobruns.finish(run_id, RunStatus.ERROR, error=err, failure=failure)
return err, failure return err, failure
@@ -201,10 +225,6 @@ def _summarize(result) -> str | None:
return str(result) return str(result)
def _iso(ts: datetime | None) -> str | None:
return ts.astimezone(timezone.utc).isoformat() if ts else None
def scheduler_snapshot(db) -> dict: def scheduler_snapshot(db) -> dict:
"""What the scheduler is doing, for the admin dashboard: each job's live state from this process """What the scheduler is doing, for the admin dashboard: each job's live state from this process
and its last runs from `job_runs`, so a restart no longer blanks "when did it last run, and how and its last runs from `job_runs`, so a restart no longer blanks "when did it last run, and how
@@ -223,7 +243,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()}
recent = jobruns.recent_runs(db, 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():
a = acts.get(job_id, {}) a = acts.get(job_id, {})
@@ -239,17 +259,19 @@ 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 newest row says. # 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.
backfills = recent.get(CHANNEL_BACKFILL, []) backfills = recent.get(CHANNEL_BACKFILL, [])
newest = backfills[0] if backfills else None going = jobruns.running_one(db, CHANNEL_BACKFILL)
shown = going or (backfills[0] if backfills else None)
ad_hoc = [ ad_hoc = [
{ {
"id": CHANNEL_BACKFILL, "id": CHANNEL_BACKFILL,
"running": newest is not None and newest.status == RunStatus.RUNNING, "running": going is not None,
"target": newest.target if newest else None, "target": shown.target if shown else None,
"target_title": ( "target_title": (
db.scalar(select(Channel.title).where(Channel.id == newest.target)) db.scalar(select(Channel.title).where(Channel.id == shown.target))
if newest is not None and newest.target if shown is not None and shown.target
else None else None
), ),
**_last_runs(backfills), **_last_runs(backfills),
@@ -275,8 +297,8 @@ def _last_runs(runs: list) -> dict:
ended = run if run is not None and run.status != RunStatus.RUNNING else None ended = run if run is not None and run.status != RunStatus.RUNNING else None
return { return {
"status": ended.status if ended else None, "status": ended.status if ended else None,
"last_started": _iso(run.started_at) if run else None, "last_started": iso(run.started_at) if run else None,
"last_finished": _iso(run.finished_at) if run else None, "last_finished": iso(run.finished_at) if run else None,
"last_result": ended.summary if ended else None, "last_result": ended.summary if ended else None,
# The outcome as data: the result, or on a failed run the failure the job named. # The outcome as data: the result, or on a failed run the failure the job named.
"result": _outcome_data(ended), "result": _outcome_data(ended),
@@ -293,8 +315,8 @@ def _run_brief(run) -> dict:
"id": run.id, "id": run.id,
"status": run.status, "status": run.status,
"trigger": run.trigger, "trigger": run.trigger,
"started_at": _iso(run.started_at), "started_at": iso(run.started_at),
"finished_at": _iso(run.finished_at), "finished_at": iso(run.finished_at),
} }
@@ -575,11 +597,15 @@ def _ad_hoc(job: str, fn: Callable[[], dict], actor_id: int, target: str) -> Non
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 logs.bind(run_id=run_id): with logs.bind(run_id=run_id):
# As in _run: only the work is inside the try.
try: try:
_finish_ok(job, run_id, fn()) result = fn()
except Exception as exc: except Exception as exc:
log.exception("job %s failed", job, extra=logs.event("scheduler.job.failed", job=job)) log.exception("job %s failed", job, extra=logs.event("scheduler.job.failed", job=job))
_finish_error(run_id, exc) err, failure = _failure(exc)
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
return
_finish_ok(job, run_id, result)
# Room for every job at once (a "run all") beside a few ad-hoc runs: APScheduler's default pool of # Room for every job at once (a "run all") beside a few ad-hoc runs: APScheduler's default pool of
@@ -590,7 +616,8 @@ _POOL_SIZE = len(JOB_FUNCS) + 8
def start_scheduler() -> None: def start_scheduler() -> None:
"""Start this process's scheduler. It always starts, so "run now" works on every install; only """Start this process's scheduler. It always starts, so "run now" works on every install; only
with SCHEDULER_ENABLED does it also register the recurring interval jobs. Before anything can with SCHEDULER_ENABLED does it also register the recurring interval jobs. Before anything can
run, every run a previous process left open is closed as interrupted.""" run, every run a previous process left open is closed as interrupted — one API process serves a
database (app/jobruns.py), so an open run here is a dead process's."""
global _scheduler global _scheduler
if _scheduler is not None: if _scheduler is not None:
return return
+3 -1
View File
@@ -29,7 +29,9 @@ _coerce_warned: set[tuple[str, str]] = set()
# When a changed value takes effect. "live": the next read obeys it — every consumer reads the key # When a changed value takes effect. "live": the next read obeys it — every consumer reads the key
# where it uses it, never once at import or boot (test-held). "restart": the process has to start # where it uses it, never once at import (test-held: a read outside a function fails). A value read
# once at startup is live only when its writer also applies it (the job intervals reschedule, the log
# level is set as it is saved). "restart": the process has to start
# again. No key needs a restart today, and the Configuration page has no way to say so yet, so a # again. No key needs a restart today, and the Configuration page has no way to say so yet, so a
# "restart" key is refused until the page can (test-held). # "restart" key is refused until the page can (test-held).
Applies = Literal["live", "restart"] Applies = Literal["live", "restart"]
+50
View File
@@ -7,7 +7,10 @@ with rows that share a timestamp — the case an offset or a timestamp-only curs
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app.auth import admin_user
from app.main import app
from app.models import AuditLog, Channel, JobRun, LogEvent, QuotaEvent from app.models import AuditLog, Channel, JobRun, LogEvent, QuotaEvent
from tests.test_body_limit import _route_objects
T0 = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc) T0 = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
@@ -88,6 +91,32 @@ def test_an_admin_action_keeps_the_request_it_was_made_in(db, make_user, as_user
assert row["request_id"] == r.headers["x-request-id"] assert row["request_id"] == r.headers["x-request-id"]
def test_an_admin_action_says_how_many_of_its_requests_lines_are_still_kept(db, make_user, as_user):
"""A request's lines are kept days, its audit row months: a link to lines gone would open an
empty list, so the row says how many are left, as a run does."""
admin = make_user(role="admin")
kept = _audit(db, "config_set", actor=admin.id, request_id="req-kept-0001")
gone = _audit(db, "config_set", actor=admin.id, request_id="req-gone-0001")
db.add_all(
[
LogEvent(
ts=T0,
level="INFO",
sev=20,
source="api",
logger="t",
msg="m",
request_id="req-kept-0001",
)
for _ in range(3)
]
)
db.commit()
items = as_user(admin).get("/api/admin/audit").json()["items"]
lines = {r["id"]: r["log_lines"] for r in items}
assert (lines[kept], lines[gone]) == (3, 0)
def test_the_run_list_filters_pages_and_names_the_channel_and_the_log_lines(db, make_user, as_user): def test_the_run_list_filters_pages_and_names_the_channel_and_the_log_lines(db, make_user, as_user):
admin = make_user(role="admin") admin = make_user(role="admin")
db.add(Channel(id="UCchan", title="Aileen Wang")) db.add(Channel(id="UCchan", title="Aileen Wang"))
@@ -146,6 +175,8 @@ def test_the_quota_breakdown_covers_its_range_and_one_account(db, make_user, as_
def totals(**params) -> dict: def totals(**params) -> dict:
body = client.get("/api/quota/admin", params=params).json() body = client.get("/api/quota/admin", params=params).json()
# The chart is the same filter's: its days add up to the table's total.
assert sum(d["total"] for d in body["daily"]) == sum(r["total"] for r in body["rows"])
return {r["user_id"]: r["total"] for r in body["rows"]} return {r["user_id"]: r["total"] for r in body["rows"]}
assert totals() == {admin.id: 150, None: 7} # the last 30 days by default assert totals() == {admin.id: 150, None: 7} # the last 30 days by default
@@ -153,3 +184,22 @@ def test_the_quota_breakdown_covers_its_range_and_one_account(db, make_user, as_
assert totals(until=(now - timedelta(days=1)).isoformat()) == {admin.id: 50} assert totals(until=(now - timedelta(days=1)).isoformat()) == {admin.id: 50}
assert totals(user="none") == {None: 7} assert totals(user="none") == {None: 7}
assert totals(user=str(admin.id)) == {admin.id: 150} assert totals(user=str(admin.id)) == {admin.id: 150}
def test_every_admin_read_and_write_is_admin_only():
"""The route table, not a list kept by hand: every /api/admin route and the quota breakdown
resolve `admin_user`, so a dropped dependency cannot hand an account's runs, errors and e-mail
addresses to any signed-in user."""
routes = _route_objects(app)
admin = {k: r for k, r in routes.items() if k[1].startswith(("/api/admin", "/api/quota/admin"))}
assert ("GET", "/api/admin/runs") in admin and ("GET", "/api/quota/admin") in admin
open_ = []
for key, route in admin.items():
stack, calls = [route.dependant], set()
while stack:
d = stack.pop()
calls.add(d.call)
stack.extend(d.dependencies)
if admin_user not in calls:
open_.append(key)
assert open_ == []
+1 -1
View File
@@ -1,6 +1,6 @@
"""An admin action that counts something keeps the count as data in its audit row's `after`, under """An admin action that counts something keeps the count as data in its audit row's `after`, under
the key the audit log names in the reader's language (`scheduler.result.<key>`, frontend the key the audit log names in the reader's language (`scheduler.result.<key>`, frontend
auditColumns.tsx) — not only in the English `summary`. A renamed key would fall back to the raw word. lib/jobResult.ts, called from lib/auditText.ts) — not only in the English `summary`. A renamed key would fall back to the raw word.
DB lane (Postgres). DB lane (Postgres).
""" """
+13
View File
@@ -136,6 +136,19 @@ def test_deep_request_starts_the_backfill_as_the_users_run_of_that_channel(
assert calls == [(ch.id, me.id)] assert calls == [(ch.id, me.id)]
def test_turning_full_history_on_for_a_finished_channel_records_no_run(
db, monkeypatch, override_dep
):
"""Its whole history is in already: a run would only say "nothing to do", and switching the flag
off and on again would otherwise write one such row each time, kept for months."""
ch, me = _setup_channel_and_sub(db, email="done@example.com")
ch.backfill_done = True
db.commit()
resp, submitted, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
assert resp.status_code == 200, resp.text
assert (submitted, calls) == ([], [])
class TestImmediateBackfillGuard: class TestImmediateBackfillGuard:
"""backfill_channel_now coordinates with the paced scheduler through _backfill_in_progress (per- """backfill_channel_now coordinates with the paced scheduler through _backfill_in_progress (per-
channel mutual exclusion, both directions) + _immediate_running (a cap on interactive runs so a channel mutual exclusion, both directions) + _immediate_running (a cap on interactive runs so a
+33
View File
@@ -96,3 +96,36 @@ def test_the_audit_logs_runs_move_into_the_run_table_and_back(db, make_user):
), ),
(uid, "backfill", {"status": "skipped", "summary": "sync paused"}), (uid, "backfill", {"status": "skipped", "summary": "sync paused"}),
] ]
def test_a_down_and_up_round_trip_keeps_every_run(db, make_user):
"""A rollback then a redeploy: an interrupted run, a run caught mid-way and a user's run of one
channel come back as runs — not stranded in the audit log under an action no code knows."""
uid = make_user().id
with engine.begin() as conn:
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())"
),
{"u": uid},
)
cfg = Config("alembic.ini")
command.downgrade(cfg, "0108_folder_links")
command.upgrade(cfg, "head")
with engine.connect() as conn:
runs = conn.execute(
text("SELECT job, trigger, actor_id, target, status FROM job_runs ORDER BY id")
).all()
stranded = conn.execute(
text("SELECT count(*) FROM audit_log WHERE action = 'job_run'")
).scalar()
assert [tuple(r) for r in runs] == [
("backfill", "scheduled", None, None, "interrupted"),
("maintenance", "manual", uid, None, "interrupted"), # the rollback stopped it
("channel_backfill", "ad_hoc", uid, "UC_x", "ok"),
]
assert stranded == 0
+27
View File
@@ -0,0 +1,27 @@
"""The run words and the migrated database, DB-lane: the `job_runs` CHECKs are frozen in a migration,
and the model's are built from the enums, so nothing else would notice the two drifting apart until a
write of the new word failed in production."""
import re
from sqlalchemy import text
from app.runstatus import RunStatus, RunTrigger
def _words(definition: str) -> set[str]:
return set(re.findall(r"'([a-z_]+)'", definition))
def test_the_databases_checks_allow_exactly_the_run_words(db):
defs = dict(
db.execute(
text(
"SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint"
" WHERE conname LIKE 'ck_job_runs_%'"
)
).all()
)
assert _words(defs["ck_job_runs_status"]) == set(RunStatus)
assert _words(defs["ck_job_runs_trigger"]) == set(RunTrigger)
assert _words(defs["ck_job_runs_finished"]) == {RunStatus.RUNNING}
+94
View File
@@ -14,6 +14,7 @@ from sqlalchemy import select
from app import jobruns, logs, logsink, quota, scheduler, state, sysconfig from app import jobruns, logs, logsink, quota, scheduler, state, sysconfig
from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent
from app.routes import scheduler as scheduler_routes
from app.runstatus import RunStatus, RunTrigger from app.runstatus import RunStatus, RunTrigger
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs _JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
@@ -256,6 +257,9 @@ def test_retention_reaps_finished_runs_only(db):
] ]
) )
db.commit() db.commit()
reaped = next(r for r in _runs(db) if r.status == "ok" and r.started_at < old + timedelta(1))
with logs.bind(run_id=reaped.id):
quota.record_usage(7)
assert jobruns.gc(db, 0) == 0 # 0 = keep forever assert jobruns.gc(db, 0) == 0 # 0 = keep forever
assert jobruns.gc(db, 30) == 1 assert jobruns.gc(db, 30) == 1
db.commit() db.commit()
@@ -263,6 +267,8 @@ def test_retention_reaps_finished_runs_only(db):
("backfill", "running"), ("backfill", "running"),
("rss_poll", "ok"), ("rss_poll", "ok"),
] ]
# Its spend stays in the budget's history (kept on its own retention), no longer anyone's run.
assert db.execute(select(QuotaEvent.units, QuotaEvent.job_run_id)).all() == [(7, None)]
# ---- "run now" through the scheduler -------------------------------------------------------------- # ---- "run now" through the scheduler --------------------------------------------------------------
@@ -413,6 +419,94 @@ def test_startup_closes_what_the_previous_process_left_running(db, monkeypatch):
assert run.status == "interrupted" assert run.status == "interrupted"
def test_an_interrupted_run_keeps_what_it_spent(db):
"""Its spend is in the quota history, tagged with its id: the sweep sums it as `finish` would."""
run_id = jobruns.start("backfill", RunTrigger.SCHEDULED, None)
with logs.bind(run_id=run_id):
quota.record_usage(40)
quota.record_usage(2)
assert jobruns.interrupt_leftovers() == 1
(run,) = _runs(db, "backfill")
assert (run.status, run.quota_units) == ("interrupted", 42)
def _flaky_finish(monkeypatch) -> list:
"""`jobruns.finish` whose first write fails (the database blinked); the statuses it was asked for."""
calls: list = []
real = jobruns.finish
def finish(run_id, status, **outcome):
calls.append(status)
if len(calls) == 1:
raise RuntimeError("db blip")
real(run_id, status, **outcome)
monkeypatch.setattr(jobruns, "finish", finish)
return calls
def test_a_run_whose_outcome_cannot_be_written_is_not_called_failed(db, make_user, monkeypatch):
"""The job succeeded; only its bookkeeping did not. Writing ERROR then would say the work failed."""
calls = _flaky_finish(monkeypatch)
scheduler._job("autotag", lambda _db: {"tagged": 1})
assert calls == ["ok"]
assert not scheduler._is_running("autotag")
(run,) = _runs(db, "autotag")
assert run.status == "running" # left for the next start's sweep, not rewritten as a failure
user = make_user("blip@example.com")
calls.clear()
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_blip")
assert calls == ["ok"]
def test_a_run_that_cannot_be_recorded_does_not_wedge_its_job(db, monkeypatch):
def broken(*_a, **_k):
raise RuntimeError("db down")
monkeypatch.setattr(jobruns, "start", broken)
scheduler._job("autotag", lambda _db: pytest.fail("no row, no run"))
assert not scheduler._is_running("autotag")
monkeypatch.undo()
scheduler._job("autotag", lambda _db: {"tagged": 1})
assert [r.status for r in _runs(db, "autotag")] == ["ok"]
def test_the_page_says_where_the_interval_jobs_do_not_run(db, live_scheduler, monkeypatch):
"""The scheduler starts on every install now; only SCHEDULER_ENABLED says the jobs run here."""
assert scheduler.scheduler_snapshot(db)["running_here"] is False
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", True)
assert scheduler.scheduler_snapshot(db)["running_here"] is True
def test_an_ad_hoc_run_still_going_shows_although_a_newer_one_ended(db, make_user):
"""Two channels can backfill at once; a later run that ended at once must not hide the first."""
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"]
assert (row["running"], row["target"], row["target_title"]) == (True, "UC_long", "Long one")
def test_an_interval_change_lands_with_its_audit_row_or_not_at_all(
db, make_user, as_user, live_scheduler, monkeypatch
):
live_scheduler.add_job(lambda: None, "interval", minutes=10, id="backfill")
admin = as_user(make_user(role="admin"))
def broken(*_a, **_k):
raise RuntimeError("audit write failed")
monkeypatch.setattr(scheduler_routes.audit, "record", broken)
with pytest.raises(RuntimeError): # the handler raises: there is no response to check
_ = admin.patch("/api/admin/scheduler/jobs/backfill", json={"interval_minutes": 45})
db.expire_all()
assert sysconfig.get_int(db, "job_interval_backfill") == scheduler.JOB_INTERVALS["backfill"]
assert live_scheduler.get_job("backfill").trigger.interval == timedelta(minutes=10)
def test_every_job_has_an_interval_and_every_interval_a_job(): def test_every_job_has_an_interval_and_every_interval_a_job():
assert set(scheduler.JOB_FUNCS) == set(scheduler.JOB_INTERVALS) assert set(scheduler.JOB_FUNCS) == set(scheduler.JOB_INTERVALS)
+3 -4
View File
@@ -275,10 +275,9 @@ Also in force:
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade - **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6] a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller. The band
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below` holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading` view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading`
and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is
+148 -3
View File
@@ -6,7 +6,7 @@
// the account filter reaches the quota tab; a // the account filter reaches the quota tab; a
// failed read is an error with a retry, never an empty list. // failed read is an error with a retry, never an empty list.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../lib/api"; import type { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../lib/api";
@@ -23,6 +23,8 @@ const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPa
const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>(); const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>();
const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise<unknown>>(); const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise<unknown>>();
const quotaMock = vi.fn<(since: string, user: string | null) => Promise<unknown>>(); const quotaMock = vi.fn<(since: string, user: string | null) => Promise<unknown>>();
const runFacetsMock = vi.fn<() => Promise<unknown>>();
const usersMock = vi.fn<() => Promise<unknown>>();
vi.mock("../lib/api", async (importOriginal) => { vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>(); const actual = await importOriginal<typeof import("../lib/api")>();
return { return {
@@ -30,19 +32,21 @@ vi.mock("../lib/api", async (importOriginal) => {
api: { api: {
...actual.api, ...actual.api,
adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before), adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before),
adminRunFacets: () => Promise.resolve({ jobs: [], statuses: [], total: 0 }), adminRunFacets: () => runFacetsMock(),
adminAudit: (q: AuditQuery, before: string | null) => auditMock(q, before), adminAudit: (q: AuditQuery, before: string | null) => auditMock(q, before),
adminAuditFacets: () => Promise.resolve({ actions: [] }), adminAuditFacets: () => Promise.resolve({ actions: [] }),
adminLogs: (q: LogQuery, before: string | null) => logsMock(q, before), adminLogs: (q: LogQuery, before: string | null) => logsMock(q, before),
adminLogFacets: () => adminLogFacets: () =>
Promise.resolve({ levels: [], sources: [], events: [], users: [], accounts: {}, total: 0 }), Promise.resolve({ levels: [], sources: [], events: [], users: [], accounts: {}, total: 0 }),
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]), adminUsers: () => usersMock(),
adminQuota: (since: string, user: string | null) => quotaMock(since, user), adminQuota: (since: string, user: string | null) => quotaMock(since, user),
clearAudit: () => Promise.resolve({ ok: true, cleared: 1 }),
}, },
}; };
}); });
import Activity from "./Activity"; import Activity from "./Activity";
import { ChoiceContext } from "./confirmContext";
import { PageToolbarSlot } from "./ui/pageToolbarSlot"; import { PageToolbarSlot } from "./ui/pageToolbarSlot";
// The filter bar lives in the toolbar, which portals into the shell's band: give it one. // The filter bar lives in the toolbar, which portals into the shell's band: give it one.
@@ -77,6 +81,7 @@ const ACTION: AuditRow = {
before: null, before: null,
after: { interval_minutes: 25 }, after: { interval_minutes: 25 },
request_id: "0efdd960c9ad1977", request_id: "0efdd960c9ad1977",
log_lines: 4,
}; };
const page = <T,>(items: T[]): HistoryPage<T> => ({ const page = <T,>(items: T[]): HistoryPage<T> => ({
@@ -89,9 +94,12 @@ function renderPage() {
const 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. */}
<ChoiceContext.Provider value={() => Promise.resolve("confirm")}>
<PageToolbarSlot.Provider value={slot}> <PageToolbarSlot.Provider value={slot}>
<Activity /> <Activity />
</PageToolbarSlot.Provider> </PageToolbarSlot.Provider>
</ChoiceContext.Provider>
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -107,6 +115,8 @@ beforeEach(() => {
auditMock.mockResolvedValue(page([ACTION])); auditMock.mockResolvedValue(page([ACTION]));
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null }); logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
quotaMock.mockResolvedValue({ rows: [], daily: [] }); quotaMock.mockResolvedValue({ rows: [], daily: [] });
runFacetsMock.mockResolvedValue({ jobs: [], statuses: [], total: 0 });
usersMock.mockResolvedValue([{ id: 1, email: "admin@example.com" }]);
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -135,6 +145,52 @@ describe("Activity", () => {
expect([url().get("rjob"), url().get("rst")]).toEqual(["backfill", "error"]); expect([url().get("rjob"), url().get("rst")]).toEqual(["backfill", "error"]);
}); });
it("Older reads the next page from its cursor, and offers none past the last", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockImplementation((_q, before) =>
Promise.resolve(
before === null
? { ...page([RUN]), next_cursor: "c1" }
: page([{ ...RUN, id: 40, error: "the older one" }]),
),
);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "activity.older" }));
await waitFor(() => expect(screen.getByText("the older one")).toBeTruthy());
expect(runsMock).toHaveBeenLastCalledWith(expect.anything(), "c1");
expect(screen.getByText("quotaExceeded")).toBeTruthy();
expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull();
});
it("an older page that fails says so and keeps what was loaded", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockImplementation((_q, before) =>
before === null
? Promise.resolve({ ...page([RUN]), next_cursor: "c1" })
: Promise.reject(new Error("500")),
);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "activity.older" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("activity.olderFailed");
expect(screen.getByText("quotaExceeded")).toBeTruthy();
});
it("offers no older page while a filter change is still loading", async () => {
window.history.replaceState(null, "", "/?page=activity");
// The cursor on screen is the previous filter's: it would continue the wrong list.
runsMock.mockImplementation((q) =>
q.status ? new Promise(() => {}) : Promise.resolve({ ...page([RUN]), next_cursor: "c1" }),
);
renderPage();
await screen.findByRole("button", { name: "activity.older" });
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: "activity.older" })).toBeNull(),
);
expect(screen.getByText("quotaExceeded")).toBeTruthy(); // the old rows stay, dimmed
});
it("a run opens to its log lines, and Back returns to the runs", async () => { it("a run opens to its log lines, and Back returns to the runs", async () => {
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota"); window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota");
renderPage(); renderPage();
@@ -168,6 +224,77 @@ describe("Activity", () => {
expect(screen.getByText("quotaExceeded")).toBeTruthy(); expect(screen.getByText("quotaExceeded")).toBeTruthy();
}); });
it("a run's outcome is its result in the reader's words, its failure in the danger colour", async () => {
runsMock.mockResolvedValue(
page<RunRow>([
{ ...RUN, id: 1, status: "ok", error: null, result: { new: 3 }, summary: "new=3" },
// Moved over from the audit log before the result rode along as data: the one-liner only.
{ ...RUN, id: 2, status: "ok", error: null, result: null, summary: "failed=2" },
{ ...RUN, id: 3, status: "interrupted", error: null },
{ ...RUN, id: 4 },
]),
);
renderPage();
await waitFor(() => expect(screen.getByText("scheduler.result.new: 3")).toBeTruthy());
expect(screen.queryByText("new=3")).toBeNull();
expect(screen.getByText("scheduler.result.failed: 2")).toBeTruthy();
expect(screen.getByText("scheduler.interrupted").className).toContain("text-warn");
expect(screen.getByText("quotaExceeded").className).toContain("text-danger");
});
it("filter choices that failed to load say so beside the filters, with a retry", async () => {
runFacetsMock.mockRejectedValueOnce(new Error("500"));
usersMock.mockRejectedValueOnce(new Error("500"));
renderPage();
await waitFor(() =>
expect(screen.getAllByRole("alert").map((a) => a.textContent)).toEqual([
expect.stringContaining("activity.accountsFailed"),
expect.stringContaining("activity.filtersFailed"),
]),
);
for (const retry of screen.getAllByRole("button", { name: "common.retry" }))
fireEvent.click(retry);
await waitFor(() => expect(screen.queryAllByRole("alert")).toEqual([]));
expect(runFacetsMock).toHaveBeenCalledTimes(2);
expect(usersMock).toHaveBeenCalledTimes(2);
});
it("clearing the admin actions reads the list again", async () => {
window.history.replaceState(null, "", "/?page=activity&at=admin");
renderPage();
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
auditMock.mockResolvedValue(page<AuditRow>([]));
fireEvent.click(screen.getByRole("button", { name: "audit.clear" }));
await waitFor(() => expect(screen.getByText("audit.empty")).toBeTruthy());
// An emptied list is announced, as the error and loading states are.
expect(screen.getByText("audit.empty").closest('[role="status"]')).toBeTruthy();
});
it("closing a dialog over the server log leaves Live on", async () => {
window.history.replaceState(null, "", "/?page=activity&at=logs");
renderPage();
const live = await screen.findByRole("button", { name: "adminLogs.live" });
fireEvent.click(live);
expect(live.getAttribute("aria-pressed")).toBe("true");
// A closing dialog steps back over its own history entry: a popstate onto the same address.
act(() => {
window.dispatchEvent(new PopStateEvent("popstate"));
});
const after = screen.getByRole("button", { name: "adminLogs.live" });
expect(after.getAttribute("aria-pressed")).toBe("true");
expect(after).toBe(live); // the same log, not a fresh one
});
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");
renderPage();
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
fireEvent.click(rowButton(/scheduler_interval/));
expect(screen.getByText("activity.audit.noRequestLines")).toBeTruthy();
expect(screen.queryByRole("button", { name: "activity.audit.requestLines" })).toBeNull();
});
it("the admin tab lists the actions, and an action opens its request's lines", async () => { it("the admin tab lists the actions, and an action opens its request's lines", async () => {
window.history.replaceState(null, "", "/?page=activity&at=admin"); window.history.replaceState(null, "", "/?page=activity&at=admin");
renderPage(); renderPage();
@@ -196,6 +323,24 @@ describe("Activity", () => {
expect(Math.round(days)).toBe(30); expect(Math.round(days)).toBe(30);
}); });
it("each day's spend is readable without a pointer", async () => {
window.history.replaceState(null, "", "/?page=activity&at=quota");
quotaMock.mockResolvedValue({
rows: [{ user_id: null, email: "System", total: 9, by_action: {} }],
daily: [
{ day: "2026-10-16", total: 4 },
{ day: "2026-10-17", total: 5 },
],
});
renderPage();
const chart = await screen.findByRole("list", { name: "activity.quota.daily" });
expect(
within(chart)
.getAllByRole("listitem")
.map((d) => d.textContent),
).toEqual(["stats.dailyTooltip", "stats.dailyTooltip"]);
});
// The runs moved over from the audit log kept one instant for start and end: their length is // The runs moved over from the audit log kept one instant for start and end: their length is
// unknown, and a "1 sec" would be invented. // unknown, and a "1 sec" would be invented.
it("a run whose start and end are one instant has no length", async () => { it("a run whose start and end are one instant has no length", async () => {
+17 -5
View File
@@ -29,6 +29,7 @@ import FilterSelect from "./ui/FilterSelect";
import { InnerInput } from "./ui/Input"; import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles"; import { fieldClass } from "./ui/fieldStyles";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { StateMessage } from "./ui/QueryState";
import Tabs from "./ui/Tabs"; import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel"; import { useTabPanel } from "./ui/tabPanel";
@@ -51,9 +52,12 @@ export default function Activity() {
activityParamsToState(new URLSearchParams(window.location.search)), activityParamsToState(new URLSearchParams(window.location.search)),
); );
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the search // Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the search
// text then applies at once instead of after the typing debounce, and the server log re-reads its // text then applies at once instead of after the typing debounce.
// own filters from the address bar.
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
// 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.
const [pivots, setPivots] = useState(0);
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.
@@ -81,10 +85,13 @@ export default function Activity() {
); );
notifyNav(); notifyNav();
setJumps((n) => n + 1); setJumps((n) => n + 1);
setPivots((n) => n + 1);
setState(next); setState(next);
}; };
const shared = { window: state.window, user: state.user, q }; const shared = { window: state.window, user: state.user, q };
// The account menu's list (SharedBar reads the same query): without it the menu offers only "Any".
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
return ( return (
<div {...panel.panelProps(state.tab)} className="px-4 pb-4 pt-3 max-w-6xl w-full mx-auto"> <div {...panel.panelProps(state.tab)} className="px-4 pb-4 pt-3 max-w-6xl w-full mx-auto">
<PageToolbar> <PageToolbar>
@@ -99,6 +106,11 @@ export default function Activity() {
<SharedBar state={state} onChange={update} /> <SharedBar state={state} onChange={update} />
</div> </div>
</PageToolbar> </PageToolbar>
{users.isError && (
<StateMessage tone="error" compact onRetry={() => void users.refetch()}>
{t("activity.accountsFailed")}
</StateMessage>
)}
{/* The quota tab says what its numbers are itself, beside them. */} {/* The quota tab says what its numbers are itself, beside them. */}
{state.tab !== "quota" && ( {state.tab !== "quota" && (
<p className="mb-3 text-xs leading-relaxed text-muted"> <p className="mb-3 text-xs leading-relaxed text-muted">
@@ -121,9 +133,9 @@ export default function Activity() {
onRequest={(requestId) => pivot({ requestId, jobId: null, runId: null })} onRequest={(requestId) => pivot({ requestId, jobId: null, runId: null })}
/> />
) : state.tab === "logs" ? ( ) : state.tab === "logs" ? (
// Keyed by the jumps: a pivot or Back/Forward hands the log its filters through the address // Keyed by the pivots: a pivot hands the log its filters through the address bar, which it
// bar, which it reads when it mounts. // reads when it mounts.
<AdminLogs key={jumps} shared={shared} onPivot={pivot} /> <AdminLogs key={pivots} shared={shared} onPivot={pivot} />
) : ( ) : (
<ActivityQuota shared={shared} /> <ActivityQuota shared={shared} />
)} )}
+15 -5
View File
@@ -13,6 +13,7 @@ import { qk } from "../lib/queryKeys";
import { useHistoryList } from "../lib/useHistoryList"; import { useHistoryList } from "../lib/useHistoryList";
import type { Shared } from "./ActivityRuns"; import type { Shared } from "./ActivityRuns";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { StateMessage } from "./ui/QueryState";
import HistoryList from "./HistoryList"; import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow"; import HistoryRow from "./HistoryRow";
import { RelativeTime } from "./ui/RelativeTime"; import { RelativeTime } from "./ui/RelativeTime";
@@ -67,8 +68,8 @@ export default function ActivityAudit({
const clear = useMutation({ const clear = useMutation({
mutationFn: () => api.clearAudit(), mutationFn: () => api.clearAudit(),
onSuccess: (r) => { onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: ["admin-audit"] }); void qc.invalidateQueries({ queryKey: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: ["admin-audit-facets"] }); void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
}, },
onError: (e) => notifyActionError(e, t("audit.clearFailed")), onError: (e) => notifyActionError(e, t("audit.clearFailed")),
@@ -115,6 +116,12 @@ export default function ActivityAudit({
{t("audit.clear")} {t("audit.clear")}
</Button> </Button>
</div> </div>
{/* The action menu above is built from these counts: without them it offers only "Any". */}
{facets.isError && (
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
{t("activity.filtersFailed")}
</StateMessage>
)}
<HistoryList <HistoryList
state={state} state={state}
empty={t("audit.empty")} empty={t("audit.empty")}
@@ -161,7 +168,6 @@ export default function ActivityAudit({
); );
})} })}
</HistoryList> </HistoryList>
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
</div> </div>
); );
} }
@@ -203,7 +209,9 @@ function AuditDetail({
{JSON.stringify({ before: row.before, after: row.after }, null, 2)} {JSON.stringify({ before: row.before, after: row.after }, null, 2)}
</pre> </pre>
)} )}
{row.request_id && ( {/* A request's lines are kept days, its row months: a link to none would open an empty list. */}
{row.request_id &&
(row.log_lines > 0 ? (
<div> <div>
<Button <Button
variant="link" variant="link"
@@ -213,7 +221,9 @@ function AuditDetail({
{t("activity.audit.requestLines")} {t("activity.audit.requestLines")}
</Button> </Button>
</div> </div>
)} ) : (
<p>{t("activity.audit.noRequestLines")}</p>
))}
</div> </div>
); );
} }
+18 -2
View File
@@ -49,7 +49,8 @@ export default function ActivityQuota({ shared }: { shared: Shared }) {
)} )}
<div className={`${SURFACE.card} p-3 mb-4`}> <div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2"> <div className="text-xs text-muted mb-2">
{t("activity.quota.total", { {t(shared.window === "all" ? "activity.quota.totalAll" : "activity.quota.total", {
count: grandTotal,
window: t(`activity.windows.${shared.window}`), window: t(`activity.windows.${shared.window}`),
units: formatNumber(grandTotal, lang), units: formatNumber(grandTotal, lang),
})} })}
@@ -57,18 +58,33 @@ export default function ActivityQuota({ shared }: { shared: Shared }) {
{data.daily.length === 0 ? ( {data.daily.length === 0 ? (
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div> <div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
) : ( ) : (
<div className="flex items-end gap-0.5 h-24"> // Each day's figure is in its bar's text too (read out, not shown): the tooltip only
// reaches a pointer.
<div
className="flex items-end gap-0.5 h-24"
role="list"
aria-label={t("activity.quota.daily")}
>
{data.daily.map((d) => ( {data.daily.map((d) => (
<div <div
key={d.day} key={d.day}
role="listitem"
// The day under the pointer is lit by the B3 layer over its whole column, not by // The day under the pointer is lit by the B3 layer over its whole column, not by
// dimming the bar: an opacity dim reads as "disabled". // dimming the bar: an opacity dim reads as "disabled".
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer" className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
title={t("stats.dailyTooltip", { title={t("stats.dailyTooltip", {
count: d.total,
day: d.day, day: d.day,
units: formatNumber(d.total, lang), units: formatNumber(d.total, lang),
})} })}
> >
<span className="sr-only">
{t("stats.dailyTooltip", {
count: d.total,
day: d.day,
units: formatNumber(d.total, lang),
})}
</span>
<div <div
className="w-full bg-accent rounded-t" className="w-full bg-accent rounded-t"
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }} style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
+15 -3
View File
@@ -10,11 +10,12 @@ import {
type RunStatusFilter, type RunStatusFilter,
} from "../lib/activityUrlState"; } from "../lib/activityUrlState";
import { formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format"; import { formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
import { jobFailureText, jobResultText } from "../lib/jobResult"; import { jobFailureText, jobResultText, parseSummary } from "../lib/jobResult";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useHistoryList } from "../lib/useHistoryList"; import { useHistoryList } from "../lib/useHistoryList";
import HistoryList from "./HistoryList"; import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow"; import HistoryRow from "./HistoryRow";
import { StateMessage } from "./ui/QueryState";
import { RelativeTime } from "./ui/RelativeTime"; import { RelativeTime } from "./ui/RelativeTime";
import { StatusDot } from "./RunStatusDot"; import { StatusDot } from "./RunStatusDot";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
@@ -106,6 +107,12 @@ export default function ActivityRuns({
]} ]}
/> />
</div> </div>
{/* The menus above are built from these counts: without them they offer only "Any". */}
{facets.isError && (
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
{t("activity.filtersFailed")}
</StateMessage>
)}
<HistoryList <HistoryList
state={state} state={state}
empty={t("activity.runs.empty")} empty={t("activity.runs.empty")}
@@ -136,7 +143,6 @@ export default function ActivityRuns({
</HistoryRow> </HistoryRow>
))} ))}
</HistoryList> </HistoryList>
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
</div> </div>
); );
} }
@@ -160,7 +166,13 @@ function useOutcome(run: RunRow): { text: string; tone: string } {
}; };
if (run.status === "interrupted") return { text: t("scheduler.interrupted"), tone: "text-warn" }; if (run.status === "interrupted") return { text: t("scheduler.interrupted"), tone: "text-warn" };
if (run.status === "running") return { text: t("scheduler.runningNow"), tone: "text-accent" }; if (run.status === "running") return { text: t("scheduler.runningNow"), tone: "text-accent" };
if (run.result) return { text: jobResultText(t, run.result, lang), tone: "text-muted" }; // A run moved over from the audit log before its result rode along as data holds only the
// server's English one-liner: read back into a result, as the old audit rows were.
const result =
run.result ??
parseSummary(run.summary) ??
(run.status === "skipped" && run.summary ? { skipped: run.summary } : null);
if (result) return { text: jobResultText(t, result, lang), tone: "text-muted" };
return { text: run.summary ?? "", tone: "text-muted" }; return { text: run.summary ?? "", tone: "text-muted" };
} }
+32 -46
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
keepPreviousData, keepPreviousData,
@@ -18,14 +18,13 @@ import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../lib/l
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useUrlMirror } from "../lib/useUrlMirror"; import { useUrlMirror } from "../lib/useUrlMirror";
import { HistoryFrame } from "./HistoryList";
import LogLine from "./LogLine"; import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup"; import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import { FilterFoldButton } from "./ui/FilterFold"; import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold"; import { useFilterFold } from "../lib/useFilterFold";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip"; import { DismissibleChip } from "./ui/Chip";
import type { Shared } from "./ActivityRuns"; import type { Shared } from "./ActivityRuns";
@@ -84,6 +83,19 @@ 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
// 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);
}, []);
const update = (patch: Partial<LogsUrlState>) => { const update = (patch: Partial<LogsUrlState>) => {
setOpen(null); setOpen(null);
setState((s) => ({ ...s, ...patch })); setState((s) => ({ ...s, ...patch }));
@@ -212,8 +224,21 @@ export default function AdminLogs({
}; };
return ( return (
<div data-testid="admin-logs" className="overflow-clip rounded-xl border border-border bg-bg"> <HistoryFrame
{/* The page's own band carries the shared filters and stays in reach; this one scrolls. */} testId="admin-logs"
list={logs}
rowCount={rows.length}
rowsClassName="font-mono text-2xs"
failure={failure && { text: t(`adminLogs.${failure}`), onRetry: retryFailed }}
older={
olderCursor !== null
? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage }
: null
}
count={t("adminLogs.count", { count: rows.length })}
empty={t("adminLogs.empty")}
header={
// The page's own band carries the shared filters and stays in reach; this one scrolls.
<div> <div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2"> <div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterChipGroup <FilterChipGroup
@@ -299,30 +324,8 @@ export default function AdminLogs({
</div> </div>
)} )}
</div> </div>
}
{failure && (
<StateMessage tone="error" compact onRetry={retryFailed} className="border-b border-border">
{t(`adminLogs.${failure}`)}
</StateMessage>
)}
<div
className={`font-mono text-2xs ${logs.isPlaceholderData ? "opacity-60" : ""}`}
data-testid="admin-logs-lines"
aria-busy={logs.isPlaceholderData}
> >
{logs.isError && !logs.data ? (
<StateMessage tone="error" onRetry={() => void logs.refetch()}>
{t("common.loadError")}
</StateMessage>
) : logs.isLoading ? (
<LoadingState shape="table" />
) : rows.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{t("adminLogs.empty")}
</p>
) : (
<NavGrid>
{rows.map((row, index) => ( {rows.map((row, index) => (
<LogLine <LogLine
navIndex={index} navIndex={index}
@@ -344,24 +347,7 @@ export default function AdminLogs({
/> />
</LogLine> </LogLine>
))} ))}
</NavGrid> </HistoryFrame>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>{t("adminLogs.count", { count: rows.length })}</span>
{olderCursor !== null && (
<Button
variant="outline"
size="xs"
disabled={logs.isFetchingNextPage}
onClick={loadOlder}
>
{t("adminLogs.older")}
</Button>
)}
</div>
</div>
); );
} }
+108 -48
View File
@@ -7,9 +7,99 @@ import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
// The frame of a history list on the activity page: the rows (or the honest state instead of them — // The frame of a history list on the activity page — the runs, the admin actions, the server log:
// a failed read is an error with a retry, never an empty list), the count, and "Older". // its own bar (if any), the rows or the honest state instead of them (a failed read is an error with a
// retry, never an empty list), a read that failed over rows still on screen, the count, and "Older".
// `HistoryFrame` draws it; each list says only what its failure and its "Older" are — the server log's
// count its filter facets and stop while Live (AdminLogs), the others not (HistoryList, below).
/** What a read that failed while rows stayed on screen means, and how to try it again. */
export interface FrameFailure {
text: string;
onRetry: () => void;
}
export function HistoryFrame({
list,
rowCount,
header,
failure,
older,
count,
empty,
rowsClassName = "",
testId,
children,
}: {
/** The list's read. */
list: {
data?: unknown;
isError: boolean;
isLoading: boolean;
isPlaceholderData: boolean;
refetch: () => unknown;
};
rowCount: number;
/** The list's own bar above the rows. */
header?: ReactNode;
failure: FrameFailure | null;
/** "Older", when there is an older page to ask for. */
older: { label: string; onClick: () => void; disabled: boolean } | null;
/** "N runs shown", for the footer. */
count: string;
/** Shown when the filters match nothing. */
empty: string;
rowsClassName?: string;
testId?: string;
/** The rows. */
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<div data-testid={testId} className="overflow-clip rounded-xl border border-border bg-bg">
{header}
{failure && (
<StateMessage
tone="error"
compact
onRetry={failure.onRetry}
className="border-b border-border"
>
{failure.text}
</StateMessage>
)}
<div
className={`${rowsClassName} ${list.isPlaceholderData ? "opacity-60" : ""}`}
data-testid={testId && `${testId}-lines`}
aria-busy={list.isPlaceholderData}
>
{list.isError && !list.data ? (
<StateMessage tone="error" onRetry={() => void list.refetch()}>
{t("common.loadError")}
</StateMessage>
) : list.isLoading ? (
<LoadingState shape="table" />
) : rowCount === 0 ? (
<StateMessage compact className="font-sans">
{empty}
</StateMessage>
) : (
<NavGrid>{children}</NavGrid>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>{count}</span>
{older && (
<Button variant="outline" size="xs" disabled={older.disabled} onClick={older.onClick}>
{older.label}
</Button>
)}
</div>
</div>
);
}
/** A history list read through `useHistoryList` (the runs, the admin actions). */
export default function HistoryList<T>({ export default function HistoryList<T>({
state, state,
empty, empty,
@@ -26,59 +116,29 @@ export default function HistoryList<T>({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { list, rows, hasOlder } = state; const { list, rows, hasOlder } = state;
const fetchOlder = () => void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage));
// A read that failed while rows were on screen keeps them, and says what the failure means. // A read that failed while rows were on screen keeps them, and says what the failure means.
const failure = !list.data const failure: FrameFailure | null = !list.data
? null ? null
: list.isFetchNextPageError : list.isFetchNextPageError
? "olderFailed" ? { text: t("activity.olderFailed"), onRetry: fetchOlder }
: list.isError : list.isError
? "stale" ? { text: t("activity.stale"), onRetry: () => void list.refetch() }
: null; : null;
return ( return (
<div className="overflow-clip rounded-xl border border-border bg-bg"> <HistoryFrame
{failure && ( list={list}
<StateMessage rowCount={rows.length}
tone="error" failure={failure}
compact older={
onRetry={() => hasOlder
failure === "olderFailed" ? { label: t("activity.older"), onClick: fetchOlder, disabled: list.isFetchingNextPage }
? void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage)) : null
: void list.refetch()
} }
className="border-b border-border" count={count}
empty={empty}
> >
{failure === "olderFailed" ? t("activity.olderFailed") : t("activity.stale")} {children}
</StateMessage> </HistoryFrame>
)}
<div
className={list.isPlaceholderData ? "opacity-60" : ""}
aria-busy={list.isPlaceholderData}
>
{list.isError && !list.data ? (
<StateMessage tone="error" onRetry={() => void list.refetch()}>
{t("common.loadError")}
</StateMessage>
) : list.isLoading ? (
<LoadingState shape="table" />
) : rows.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted">{empty}</p>
) : (
<NavGrid>{children}</NavGrid>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>{count}</span>
{hasOlder && (
<Button
variant="outline"
size="xs"
disabled={list.isFetchingNextPage}
onClick={() => void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage))}
>
{t("activity.older")}
</Button>
)}
</div>
</div>
); );
} }
+15 -7
View File
@@ -3,9 +3,10 @@ import type { ReactNode } from "react";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
// One dense row of a history list on the activity page — a background run, an admin action: its // One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
// cells on one line, and the row opens in place to its details, as a server log line does // its lines through this): its cells on one line, and the row opens in place to its details. One frame
// (./LogLine). Inside a NavGrid the keyboard walks the rows (`j`/`k`) and Enter opens one. // for all of them, so a row opens, announces itself and takes the keyboard the same on every tab.
// Inside a NavGrid the keyboard walks the rows (`j`/`k`) and Enter opens one.
export default function HistoryRow({ export default function HistoryRow({
cells, cells,
@@ -15,6 +16,7 @@ export default function HistoryRow({
open, open,
onToggle, onToggle,
title, title,
line = false,
children, children,
}: { }: {
/** The row's cells, laid out by `grid`. */ /** The row's cells, laid out by `grid`. */
@@ -28,6 +30,8 @@ export default function HistoryRow({
onToggle: () => void; onToggle: () => void;
/** The row button's tooltip — the full date. */ /** The row button's tooltip — the full date. */
title?: string; title?: string;
/** A log line: tighter, its cells on one text baseline, its size the (monospace) list's own. */
line?: boolean;
/** The detail shown while the row is open. */ /** The detail shown while the row is open. */
children: ReactNode; children: ReactNode;
}) { }) {
@@ -43,13 +47,17 @@ export default function HistoryRow({
aria-expanded={open} aria-expanded={open}
onClick={onToggle} onClick={onToggle}
title={title} title={title}
className={`grid w-full items-center gap-x-2 px-3 py-1.5 text-left text-xs touch-target pointer-coarse:content-center ${grid} ${ className={`grid w-full gap-x-2 px-3 text-left touch-target pointer-coarse:content-center ${
open ? "bg-accent/10" : "" line ? "items-baseline py-1" : "items-center py-1.5 text-xs"
}`} } ${grid} ${open ? "bg-accent/10" : ""}`}
> >
{cells} {cells}
</Pressable> </Pressable>
{open && <div className="bg-accent/5 px-3 py-2 text-xs text-muted">{children}</div>} {open && (
<div className={`bg-accent/5 px-3 py-2 text-muted ${line ? "" : "text-xs"}`}>
{children}
</div>
)}
</div> </div>
); );
} }
+17 -20
View File
@@ -1,14 +1,14 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Level } from "../lib/log"; import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav"; import HistoryRow from "./HistoryRow";
import { LEVEL_TAG, logClock } from "./logLineParts"; import { LEVEL_TAG, logClock } from "./logLineParts";
import { Pressable } from "./ui/Pressable";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin // One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens // diagnostics console (the server's stored log): time, level, event code, message, and the line opens
// in place to whatever detail its screen has. One component, so a level reads the same on both. // in place to whatever detail its screen has. One component, so a level reads the same on both.
// Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does. // The row frame is the history lists' (./HistoryRow): inside a NavGrid the keyboard walks the lines
// (`j`/`k`) and Enter opens one, as a click does.
export default function LogLine({ export default function LogLine({
ts, ts,
@@ -36,29 +36,26 @@ export default function LogLine({
}) { }) {
const tag = LEVEL_TAG[level]; const tag = LEVEL_TAG[level];
return ( return (
<div <HistoryRow
className="border-b border-border/50 last:border-b-0" line
{...navItemAttrs(navIndex, `${event} ${msg}`, { inset: true })} grid="grid-cols-[6.5em_3.5em_minmax(0,1fr)] sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)]"
> navIndex={navIndex}
<Pressable navText={`${event} ${msg}`}
selectable open={open}
layer={!open} onToggle={onToggle}
data-nav-open
aria-expanded={open}
onClick={onToggle}
title={title} title={title}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 touch-target pointer-coarse:content-center ${ cells={
open ? "bg-accent/10" : "" <>
}`}
>
<span className="tabular-nums text-muted">{logClock(ts)}</span> <span className="tabular-nums text-muted">{logClock(ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span> <span className={`font-semibold ${tag.className}`}>{tag.text}</span>
<span className="truncate">{event}</span> <span className="truncate">{event}</span>
{/* A phone has no room for a fourth column: the message takes a line of its own under the {/* A phone has no room for a fourth column: the message takes a line of its own under the
code, full width — sharing the code's line left it a few characters. */} code, full width — sharing the code's line left it a few characters. */}
<span className="col-span-3 truncate sm:col-span-1">{msg}</span> <span className="col-span-3 truncate sm:col-span-1">{msg}</span>
</Pressable> </>
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>} }
</div> >
{children}
</HistoryRow>
); );
} }
@@ -31,6 +31,8 @@ import { useBackToClose } from "../lib/history";
import { PageHeading } from "./ui/Headings"; import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { LS, setAccountRaw } from "../lib/storage";
import { PAGE_KEY, START_ON_HOME } from "./navigationInit";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs /** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */ * to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
@@ -239,6 +241,35 @@ describe("NavigationProvider · Back", () => {
]); ]);
}); });
it("carries an old server-log link's account, text and range onto the page's own filters", () => {
window.history.replaceState(null, "", "/?page=logs&lu=3&lq=boom&lwin=1h");
render();
const url = new URLSearchParams(window.location.search);
expect([url.get("at"), url.get("au"), url.get("aq"), url.get("aw")]).toEqual([
"logs",
"3",
"boom",
"1h",
]);
// …and the dead keys leave the address bar instead of riding along to every page after.
expect(["lu", "lq", "lwin"].filter((k) => url.has(k))).toEqual([]);
});
it("an old server-log link without a range keeps the one it meant", () => {
window.history.replaceState(null, "", "/?page=logs");
render();
expect(new URLSearchParams(window.location.search).get("aw")).toBe("24h");
});
it("a remembered retired page opens its tab", () => {
setAccountRaw(LS.startOnHome, START_ON_HOME.off);
setAccountRaw(PAGE_KEY, "logs");
window.history.replaceState(null, "", "/");
const { result } = render();
expect(result.current.page).toBe("activity");
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
});
it("opens a retired page's tab when Back lands on its entry", () => { it("opens a retired page's tab when Back lands on its entry", () => {
const { result } = render(); const { result } = render();
act(() => result.current.setPage("plex")); act(() => result.current.setPage("plex"));
@@ -6,7 +6,7 @@ import { resolvePage, retiredParams, urlWithParams, type Page } from "../lib/url
import { ACTIVITY_KEYS } from "../lib/activityUrlState"; import { ACTIVITY_KEYS } from "../lib/activityUrlState";
import { pageTitleKey } from "../lib/pageMeta"; import { pageTitleKey } from "../lib/pageMeta";
import { useDocumentObjectTitle } from "../lib/documentTitle"; import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage"; import { getAccountRaw, setAccountRaw } from "../lib/storage";
import { import {
backStaysInDocument, backStaysInDocument,
closeOverlaysThenNavigate, closeOverlaysThenNavigate,
@@ -193,9 +193,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the // Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read. // ?chan / ?v / ?pl / filter params the loaders already read.
// A retired page's link — or the remembered last page, when that is what opened — also opens its
// part of the page that replaced it (an `at` tab).
const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY);
const url = urlWithParams({ const url = urlWithParams({
// A retired page's link also opens its part of the page that replaced it (an `at` tab). ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
...retiredParams(new URLSearchParams(window.location.search).get("page")),
page: mountPage === "feed" ? null : mountPage, page: mountPage === "feed" ? null : mountPage,
}); });
if (st._yt && st._ytScrape) { if (st._yt && st._ytScrape) {
@@ -1,5 +1,7 @@
import { readdirSync, readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { RUN_STATUSES } from "../lib/activityUrlState";
import { vocabKey } from "../lib/vocab"; import { vocabKey } from "../lib/vocab";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these // Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
@@ -72,6 +74,15 @@ const jobs = [
const enumValues = (file: string) => const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!); [...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
const auditActions = enumValues("audit.py"); const auditActions = enumValues("audit.py");
// A background run's words (backend/app/runstatus.py): its status and what started it.
const runEnum = (name: string) =>
[
...(
backend("runstatus.py").split(`class ${name}(StrEnum):`)[1]?.split("\nclass ")[0] ?? ""
).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm),
].map((m) => m[1]!);
const runStatuses = runEnum("RunStatus");
const runTriggers = runEnum("RunTrigger");
const quotaActions = enumValues("quota.py"); const quotaActions = enumValues("quota.py");
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from // The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict // `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
@@ -260,6 +271,8 @@ describe("backend ids have their names in both languages", () => {
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"])); expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"])); expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
expect(quotaActions.length).toBeGreaterThan(0); expect(quotaActions.length).toBeGreaterThan(0);
expect(runStatuses).toEqual(expect.arrayContaining(["running", "interrupted"]));
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"])); expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
@@ -288,10 +301,23 @@ describe("backend ids have their names in both languages", () => {
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]); expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
}); });
it("the run list's status chips are the server's run statuses", () => {
expect([...RUN_STATUSES].sort()).toEqual([...runStatuses].sort());
});
for (const lang of ["en", "hu"]) { for (const lang of ["en", "hu"]) {
const keys = keysOf(lang); const keys = keysOf(lang);
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k)); const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
it(`${lang}: every run status and trigger`, () => {
expect(
missing([
...runStatuses.flatMap((s) => [`activity.runs.status.${s}`, `scheduler.dot.${s}`]),
...runTriggers.map((s) => `scheduler.trigger.${s}`),
]),
).toEqual([]);
});
it(`${lang}: every config key and group`, () => { it(`${lang}: every config key and group`, () => {
expect( expect(
missing([ missing([
+10 -3
View File
@@ -18,7 +18,8 @@
"24h": "24 hours", "24h": "24 hours",
"7d": "7 days", "7d": "7 days",
"30d": "30 days", "30d": "30 days",
"90d": "90 days" "90d": "90 days",
"all": "All"
}, },
"account": "Account", "account": "Account",
"any": "Any", "any": "Any",
@@ -26,7 +27,8 @@
"older": "Load older", "older": "Load older",
"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 rows", "olderFailed": "Couldn't load the older rows",
"countsFailed": "Couldn't load the filter counts", "filtersFailed": "Couldn't load the filter choices: the menus may be missing some.",
"accountsFailed": "Couldn't load the accounts for the account filter.",
"runs": { "runs": {
"job": "Job", "job": "Job",
"statusLabel": "Filter by status", "statusLabel": "Filter by status",
@@ -57,10 +59,15 @@
"action": "Action", "action": "Action",
"target": "Target", "target": "Target",
"requestLines": "Its request's log lines →", "requestLines": "Its request's log lines →",
"noRequestLines": "Its request's log lines are no longer kept",
"count_one": "{{count}} action shown · newest first", "count_one": "{{count}} action shown · newest first",
"count_other": "{{count}} actions shown · newest first" "count_other": "{{count}} actions shown · newest first"
}, },
"quota": { "quota": {
"total": "Spend over {{window}}: {{units}} units" "daily": "Spend per day",
"total_one": "Spend over {{window}}: {{units}} unit",
"total_other": "Spend over {{window}}: {{units}} units",
"totalAll_one": "Spend over all kept history: {{units}} unit",
"totalAll_other": "Spend over all kept history: {{units}} units"
} }
} }
+3 -2
View File
@@ -4,7 +4,6 @@
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.", "introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
"loading": "Loading…", "loading": "Loading…",
"noUsageInRange": "No usage in this range.", "noUsageInRange": "No usage in this range.",
"dailyTooltip": "{{day}}: {{units}} units",
"system": "System (background)", "system": "System (background)",
"sync": { "sync": {
"myStatus": "My sync status", "myStatus": "My sync status",
@@ -41,5 +40,7 @@
"fullHistoryRequested_one": "Full history requested for {{count}} channel", "fullHistoryRequested_one": "Full history requested for {{count}} channel",
"fullHistoryRequested_other": "Full history requested for {{count}} channels", "fullHistoryRequested_other": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history" "fullHistoryFailed": "Couldn't request full history"
} },
"dailyTooltip_one": "{{day}}: {{units}} unit",
"dailyTooltip_other": "{{day}}: {{units}} units"
} }
+10 -3
View File
@@ -18,7 +18,8 @@
"24h": "24 óra", "24h": "24 óra",
"7d": "7 nap", "7d": "7 nap",
"30d": "30 nap", "30d": "30 nap",
"90d": "90 nap" "90d": "90 nap",
"all": "Mind"
}, },
"account": "Fiók", "account": "Fiók",
"any": "Bármelyik", "any": "Bármelyik",
@@ -26,7 +27,8 @@
"older": "Régebbiek", "older": "Régebbiek",
"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 darabszámait", "filtersFailed": "Nem sikerült betölteni a szűrők választékát: a menükből hiányozhatnak elemek.",
"accountsFailed": "Nem sikerült betölteni a fiókokat a fiókszűrőhöz.",
"runs": { "runs": {
"job": "Feladat", "job": "Feladat",
"statusLabel": "Szűrés állapotra", "statusLabel": "Szűrés állapotra",
@@ -57,10 +59,15 @@
"action": "Művelet", "action": "Művelet",
"target": "Cél", "target": "Cél",
"requestLines": "A kérés naplósorai →", "requestLines": "A kérés naplósorai →",
"noRequestLines": "A kérés naplósorai már nincsenek meg",
"count_one": "{{count}} művelet látszik · a legújabb elöl", "count_one": "{{count}} művelet látszik · a legújabb elöl",
"count_other": "{{count}} művelet látszik · a legújabb elöl" "count_other": "{{count}} művelet látszik · a legújabb elöl"
}, },
"quota": { "quota": {
"total": "Felhasználás ({{window}}): {{units}} egység" "daily": "Felhasználás naponta",
"total_one": "Felhasználás ({{window}}): {{units}} egység",
"total_other": "Felhasználás ({{window}}): {{units}} egység",
"totalAll_one": "Felhasználás (a teljes megőrzött idő): {{units}} egység",
"totalAll_other": "Felhasználás (a teljes megőrzött idő): {{units}} egység"
} }
} }
+3 -2
View File
@@ -4,7 +4,6 @@
"introAfter": " a megosztott háttérmunka (ütemezett behúzás, dúsítás, újraszinkronizálás), amely nem köthető egy személyhez.", "introAfter": " a megosztott háttérmunka (ütemezett behúzás, dúsítás, újraszinkronizálás), amely nem köthető egy személyhez.",
"loading": "Betöltés…", "loading": "Betöltés…",
"noUsageInRange": "Nincs használat ebben az időszakban.", "noUsageInRange": "Nincs használat ebben az időszakban.",
"dailyTooltip": "{{day}}: {{units}} egység",
"system": "Rendszer (háttér)", "system": "Rendszer (háttér)",
"sync": { "sync": {
"myStatus": "Saját szinkronizálási állapot", "myStatus": "Saját szinkronizálási állapot",
@@ -41,5 +40,7 @@
"fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához", "fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához", "fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni" "fullHistoryFailed": "Nem sikerült teljes előzményt kérni"
} },
"dailyTooltip_one": "{{day}}: {{units}} egység",
"dailyTooltip_other": "{{day}}: {{units}} egység"
} }
@@ -9,6 +9,7 @@ import {
activityParamUpdates, activityParamUpdates,
DEFAULT_ACTIVITY_STATE, DEFAULT_ACTIVITY_STATE,
sinceOf, sinceOf,
ACTIVITY_WINDOWS,
type ActivityUrlState, type ActivityUrlState,
} from "./activityUrlState"; } from "./activityUrlState";
@@ -54,4 +55,11 @@ describe("activityUrlState", () => {
expect(sinceOf("24h", now)).toBe("2026-10-17T12:00:00.000Z"); expect(sinceOf("24h", now)).toBe("2026-10-17T12:00:00.000Z");
expect(sinceOf("90d", now)).toBe("2026-07-20T12:00:00.000Z"); expect(sinceOf("90d", now)).toBe("2026-07-20T12:00:00.000Z");
}); });
it("offers everything kept, past the longest range", () => {
// Runs and admin actions are kept 180 days by default, or for ever: a 90-day cap hid the rest.
expect(ACTIVITY_WINDOWS[ACTIVITY_WINDOWS.length - 1]).toBe("all");
expect(activityParamsToState(new URLSearchParams("aw=all")).window).toBe("all");
expect(sinceOf("all")).toBe("1970-01-01T00:00:00.000Z");
});
}); });
+13 -7
View File
@@ -3,12 +3,13 @@
// each tab's own (the run list's job and status, the audit list's action). The server log's own // each tab's own (the run list's job and status, the audit list's action). The server log's own
// filters keep their `l…` keys (./logsUrlState). setPage clears all of them (ACTIVITY_KEYS); the page // filters keep their `l…` keys (./logsUrlState). setPage clears all of them (ACTIVITY_KEYS); the page
// re-mirrors them while it is open. // re-mirrors them while it is open.
import type { RunStatus } from "./api";
import { LOGS_KEYS } from "./logsUrlState"; import { LOGS_KEYS } from "./logsUrlState";
export type ActivityTab = "runs" | "admin" | "logs" | "quota"; export type ActivityTab = "runs" | "admin" | "logs" | "quota";
export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d"; export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
/** The run statuses the list can narrow to (the API's `RunStatus`). */ /** The run statuses the list can narrow to: the API's `RunStatus` (held by backendIds.contract.test). */
export type RunStatusFilter = "running" | "ok" | "error" | "skipped" | "interrupted"; export type RunStatusFilter = RunStatus;
export interface ActivityUrlState { export interface ActivityUrlState {
tab: ActivityTab; tab: ActivityTab;
@@ -24,7 +25,9 @@ export interface ActivityUrlState {
} }
export const ACTIVITY_TABS: readonly ActivityTab[] = ["runs", "admin", "logs", "quota"]; export const ACTIVITY_TABS: readonly ActivityTab[] = ["runs", "admin", "logs", "quota"];
export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d"]; // `all` = everything still kept: runs and admin actions are kept 180 days by default (0 = for ever),
// longer than any fixed range.
export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d", "all"];
export const RUN_STATUSES: readonly RunStatusFilter[] = [ export const RUN_STATUSES: readonly RunStatusFilter[] = [
"error", "error",
"interrupted", "interrupted",
@@ -33,12 +36,13 @@ export const RUN_STATUSES: readonly RunStatusFilter[] = [
"ok", "ok",
]; ];
const WINDOW_HOURS: Record<ActivityWindow, number> = { const WINDOW_HOURS: Record<ActivityWindow, number | null> = {
"1h": 1, "1h": 1,
"24h": 24, "24h": 24,
"7d": 24 * 7, "7d": 24 * 7,
"30d": 24 * 30, "30d": 24 * 30,
"90d": 24 * 90, "90d": 24 * 90,
all: null,
}; };
export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = { export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = {
@@ -92,7 +96,9 @@ export function activityParamUpdates(state: ActivityUrlState): Record<string, st
}; };
} }
/** Where the window starts, as the API takes it. */ /** Where the window starts, as the API takes it: `all` from the epoch (the quota read would default
* to its last 30 days without one). */
export function sinceOf(window: ActivityWindow, now = Date.now()): string { export function sinceOf(window: ActivityWindow, now = Date.now()): string {
return new Date(now - WINDOW_HOURS[window] * 3_600_000).toISOString(); const hours = WINDOW_HOURS[window];
return new Date(hours === null ? 0 : now - hours * 3_600_000).toISOString();
} }
+7 -2
View File
@@ -105,6 +105,8 @@ export interface AuditRow {
after: Record<string, unknown> | null; after: Record<string, unknown> | null;
/** The request the change was made in — its log lines are one pivot away. Null on older rows. */ /** The request the change was made in — its log lines are one pivot away. Null on older rows. */
request_id: string | null; request_id: string | null;
/** How many of that request's log lines are still kept (they go after days, the row after months). */
log_lines: number;
} }
/** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */ /** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */
@@ -142,7 +144,8 @@ export interface RunRow {
summary: string | null; summary: string | null;
result: JobResult | null; result: JobResult | null;
error: string | null; error: string | null;
failure: { code: string; [key: string]: unknown } | null; /** A failure the job named (`progress.JobFailed`): its reason, and the values it is said with. */
failure: { failure: string; [key: string]: unknown } | null;
quota_units: number; quota_units: number;
/** How many stored log lines the run left (WARNING and up only). */ /** How many stored log lines the run left (WARNING and up only). */
log_lines: number; log_lines: number;
@@ -249,8 +252,10 @@ function queryString(values: object): string {
export type JobResult = { [key: string]: number | string | JobResult }; export type JobResult = { [key: string]: number | string | JobResult };
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`: /** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
* the process stopped mid-run (a deploy, a crash), and its next start closed the row. */ * the process stopped mid-run (a deploy, a crash), and its next start closed the row. The values are
* held to the server's by backendIds.contract.test (through RUN_STATUSES, typed as `RunStatus`). */
export type RunOutcome = "ok" | "error" | "skipped" | "interrupted"; export type RunOutcome = "ok" | "error" | "skipped" | "interrupted";
export type RunStatus = RunOutcome | "running";
/** One past run, as the row's history dots show it. */ /** One past run, as the row's history dots show it. */
export interface RunBrief { export interface RunBrief {
+1
View File
@@ -25,6 +25,7 @@ const row = (over: Partial<AuditRow>): AuditRow => ({
before: null, before: null,
after: null, after: null,
request_id: null, request_id: null,
log_lines: 0,
...over, ...over,
}); });
+6 -5
View File
@@ -37,9 +37,10 @@ it("index.css draws an inset item's ring inside, after the outside rule", () =>
// The items that sit flush, by their source: a new one of the kind should be added here. // The items that sit flush, by their source: a new one of the kind should be added here.
const src = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8"); const src = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8");
it.each([["../components/LogLine.tsx", /navItemAttrs\(navIndex, [^)]*\{ inset: true \}\)/]])( // The history rows (runs, admin actions) and the log lines share one frame, HistoryRow.
"%s asks for the inside ring", it.each([
(file, re) => { ["../components/HistoryRow.tsx", /navItemAttrs\(navIndex, [^)]*\{ inset: true \}\)/],
["../components/LogLine.tsx", /<HistoryRow\s/],
])("%s asks for the inside ring", (file, re) => {
expect(src(file)).toMatch(re); expect(src(file)).toMatch(re);
}, });
);
+6 -2
View File
@@ -87,8 +87,12 @@ export const qk = {
adminInvites: () => ["admin-invites"] as const, adminInvites: () => ["admin-invites"] as const,
adminPendingAccess: () => ["admin-pending-access"] as const, adminPendingAccess: () => ["admin-pending-access"] as const,
adminConfig: () => ["admin-config"] as const, adminConfig: () => ["admin-config"] as const,
adminAudit: (filters: object) => ["admin-audit", filters] as const, adminAudit: (filters?: object) =>
adminAuditFacets: (filters: object) => ["admin-audit-facets", filters] as const, filters !== undefined ? (["admin-audit", filters] as const) : (["admin-audit"] as const),
adminAuditFacets: (filters?: object) =>
filters !== undefined
? (["admin-audit-facets", filters] as const)
: (["admin-audit-facets"] as const),
adminRuns: (filters: object) => ["admin-runs", filters] as const, adminRuns: (filters: object) => ["admin-runs", filters] as const,
adminRunFacets: (filters: object) => ["admin-run-facets", filters] as const, adminRunFacets: (filters: object) => ["admin-run-facets", filters] as const,
// The console's filters are part of the key; `since` is not (it moves with the clock) — the // The console's filters are part of the key; `since` is not (it moves with the clock) — the
+30 -9
View File
@@ -142,15 +142,32 @@ function isPage(p: string | null | undefined): p is Page {
} }
// A page this build no longer has, the page that took its place, and the params that open its part // A page this build no longer has, the page that took its place, and the params that open its part
// of that page. A bookmark, the remembered last page and an old history entry can still name one; all // of that page — from the old link's own params. A bookmark, the remembered last page and an old
// three resolve through `resolvePage`, so a retired page lands where its content went instead of on // history entry can still name one; all three resolve through `resolvePage`, so a retired page lands
// the feed, and the navigation provider writes `params` into the address bar as it rewrites the page. // where its content went instead of on the feed, and the navigation provider writes `params` into the
const RETIRED: Readonly<Record<string, { page: Page; params?: Record<string, string> }>> = { // address bar as it rewrites the page (a null clears a key the old page owned).
type Updates = Record<string, string | null>;
const RETIRED: Readonly<
Record<string, { page: Page; params?: (old: URLSearchParams) => Updates }>
> = {
// The download queue became the tray; what it downloaded lives in Files. // The download queue became the tray; what it downloaded lives in Files.
downloads: { page: "files" }, downloads: { page: "files" },
// The audit log and the server log became tabs of the activity page (its `at` key). // The audit log and the server log became tabs of the activity page (its `at` key).
audit: { page: "activity", params: { at: "admin" } }, audit: { page: "activity", params: () => ({ at: "admin" }) },
logs: { page: "activity", params: { at: "logs" } }, // The server log's own account, text and range became the page's shared filters; a link without
// a range meant the log's default then, 24 hours.
logs: {
page: "activity",
params: (old) => ({
at: "logs",
au: old.get("lu"),
aq: old.get("lq"),
aw: old.get("lwin") ?? "24h",
lu: null,
lq: null,
lwin: null,
}),
},
}; };
const retired = (p: string | null | undefined) => const retired = (p: string | null | undefined) =>
@@ -162,9 +179,13 @@ export function resolvePage(p: string | null | undefined): Page | null {
return retired(p)?.page ?? null; return retired(p)?.page ?? null;
} }
/** The params that open a retired page's part of its successor (none for a live page). */ /** The params that open a retired page's part of its successor, read from the old link's `params`
export function retiredParams(p: string | null | undefined): Record<string, string> { * (none for a live page). */
return isPage(p) ? {} : (retired(p)?.params ?? {}); export function retiredParams(
p: string | null | undefined,
params = new URLSearchParams(window.location.search),
): Updates {
return isPage(p) ? {} : (retired(p)?.params?.(params) ?? {});
} }
export function readPage(): Page { export function readPage(): Page {