release: 0.108.0 (feature/e40-job-history)

This commit is contained in:
2026-10-18 21:48:23 +02:00
116 changed files with 6124 additions and 2143 deletions
+5 -3
View File
@@ -68,9 +68,11 @@ SMTP_FROM=
# --- Scheduler ---
# 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
# more than one instance against the same database, keep SCHEDULER_ENABLED=true on exactly one
# of them and false on the rest, to avoid double quota use and write races.
# DATABASE_URL is set for you by docker-compose to the bundled `db` service. Run ONE app instance
# per database: a starting instance closes every run still open as interrupted (it assumes the
# 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
# --- Files library & downloads ---
+13
View File
@@ -7,6 +7,19 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.108.0 — 2026-10-18
### New
- Activity: one admin page for background runs, admin actions, the server log and quota, with one time range, account and search. A run opens to its outcome and its log lines, an admin action to its request's lines; old audit and log links open their tab.
- Scheduler: each job shows its last three runs (a run cut short by a restart is marked) and a button opens all of them; turning on a channel's full history is now a run of its own, listed under Ad-hoc runs.
- Configuration: how long the run history is kept (Activity › Run history, 180 days by default). Stats keeps your own numbers; the system quota view moved to Activity › Quota.
### Fixed
- Configuration: saving a setting with the value already in force now keeps it, instead of dropping the change.
- Configuration: the unused SponsorBlock default setting is gone.
## 0.107.3 — 2026-10-17
### New
+3 -3
View File
@@ -63,7 +63,7 @@ how it gets built. Concretely:
## Architecture shape (don't fight it)
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`,
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`, `jobruns`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
@@ -92,8 +92,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` and `jobruns` own their session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
+1 -1
View File
@@ -1 +1 @@
0.107.3
0.108.0
-34
View File
@@ -630,41 +630,7 @@
}
}
],
"./app/routes/admin_logs.py": [
{
"code": "reportReturnType",
"range": {
"startColumn": 15,
"endColumn": 47,
"lineCount": 1
}
}
],
"./app/routes/audit.py": [
{
"code": "reportCallIssue",
"range": {
"startColumn": 17,
"endColumn": 98,
"lineCount": 1
}
},
{
"code": "reportAssignmentType",
"range": {
"startColumn": 17,
"endColumn": 98,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 22,
"endColumn": 97,
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
+160
View File
@@ -0,0 +1,160 @@
"""Background runs get a durable history
Revision ID: 0109_job_runs
Revises: 0108_folder_links
Create Date: 2026-10-17
What a scheduler job last did lived in the API process's memory, gone at every restart; the audit
log kept a row for a run only when it was manual, failed, or changed something. Now every run is a
`job_runs` row, written when it starts and when it ends. A run's quota spend points at it
(`quota_events.job_run_id`), and so do the lines it logged (`log_events.run_id`, no foreign key: the
log writer runs behind the event).
Runs are not admin actions, so the audit log stops carrying them: the existing `job_run` audit rows
MOVE into `job_runs` (start = end = when the row was written, the only time it kept), and leave the
audit log. The downgrade moves every run back into the audit log as such a row (one still running as
interrupted, what the audit row has no column for in its `after`), then drops the table.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0109_job_runs"
down_revision: str | None = "0108_folder_links"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"job_runs",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("job", sa.String(40), nullable=False),
sa.Column("trigger", sa.String(16), nullable=False),
sa.Column("actor_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("target", sa.String(128)),
sa.Column("status", sa.String(16), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("summary", sa.Text()),
sa.Column("result", postgresql.JSONB()),
sa.Column("error", sa.Text()),
sa.Column("failure", postgresql.JSONB()),
sa.Column("quota_units", sa.Integer(), nullable=False, server_default="0"),
sa.CheckConstraint(
"status IN ('running', 'ok', 'error', 'skipped', 'interrupted')",
name="ck_job_runs_status",
),
sa.CheckConstraint(
"trigger IN ('scheduled', 'manual', 'ad_hoc')", name="ck_job_runs_trigger"
),
sa.CheckConstraint(
"(status = 'running') = (finished_at IS NULL)", name="ck_job_runs_finished"
),
)
op.create_index("ix_job_runs_job_started", "job_runs", ["job", "started_at"])
op.create_index("ix_job_runs_actor_id", "job_runs", ["actor_id"])
op.create_index("ix_job_runs_started_at", "job_runs", ["started_at"])
op.add_column(
"quota_events",
sa.Column("job_run_id", sa.BigInteger(), sa.ForeignKey("job_runs.id", ondelete="SET NULL")),
)
op.create_index("ix_quota_events_job_run_id", "quota_events", ["job_run_id"])
op.add_column("log_events", sa.Column("run_id", sa.BigInteger()))
op.create_index(
"ix_log_events_run",
"log_events",
["run_id"],
postgresql_where=sa.text("run_id IS NOT NULL"),
)
# The audit trail's runs move over: the three statuses the scheduler wrote, and `interrupted`,
# which a downgrade writes back (as it does a run's trigger, target, spend and start, which the
# audit row has no column for: its actor alone cannot say "manual" once that admin is deleted),
# so a rollback and a redeploy keep every run as it was. A status the run table does
# not know would fail the CHECK.
op.execute(
"""
INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at, finished_at,
summary, result, error, failure, quota_units)
SELECT target_id,
coalesce(after::jsonb ->> 'trigger',
CASE WHEN actor_id IS NULL THEN 'scheduled' ELSE 'manual' END),
actor_id,
after::jsonb ->> 'target',
after::jsonb ->> 'status',
coalesce((after::jsonb ->> 'started_at')::timestamptz, created_at),
created_at,
CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb ->> 'summary' END,
CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb -> 'result' END,
CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb ->> 'summary' END,
CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb -> 'result' END,
coalesce((after::jsonb ->> 'quota_units')::int, 0)
FROM audit_log
WHERE action = 'job_run'
AND target_id IS NOT NULL
AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped', 'interrupted')
ORDER BY id
"""
)
op.execute(
"""
DELETE FROM audit_log
WHERE action = 'job_run'
AND target_id IS NOT NULL
AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped', 'interrupted')
"""
)
def downgrade() -> None:
# A run still `running` is one the rollback stopped: it goes back as interrupted. What the audit
# row's own columns cannot say rides in `after` (its trigger, target, spend and start), so the
# upgrade restores them.
op.execute(
"""
INSERT INTO audit_log (actor_id, action, target_type, target_id, summary, after, created_at)
SELECT actor_id,
'job_run',
'job',
job,
left(job || ': ' || st
|| coalesce(' — ' || CASE WHEN st = 'error' THEN error ELSE summary END, ''),
255),
(jsonb_build_object(
'status', st,
'summary', CASE WHEN st = 'error' THEN error ELSE summary END)
|| CASE
WHEN st = 'error' AND failure IS NOT NULL
THEN jsonb_build_object('result', failure)
WHEN st <> 'error' AND result IS NOT NULL
THEN jsonb_build_object('result', result)
ELSE '{}'::jsonb
END
|| jsonb_build_object('trigger', trigger, 'quota_units', units,
'started_at', started_at)
|| CASE WHEN target IS NOT NULL
THEN jsonb_build_object('target', target) ELSE '{}'::jsonb END)::json,
coalesce(finished_at, started_at)
FROM (SELECT *, CASE WHEN status = 'running' THEN 'interrupted' ELSE status END AS st,
-- A run stopped mid-way has not summed its spend yet: sum it as the startup
-- sweep would, before the link to it is dropped.
CASE WHEN status = 'running'
THEN (SELECT coalesce(sum(q.units), 0) FROM quota_events q
WHERE q.job_run_id = job_runs.id)
ELSE quota_units END AS units
FROM job_runs) AS r
ORDER BY id
"""
)
op.drop_index("ix_log_events_run", table_name="log_events")
op.drop_column("log_events", "run_id")
op.drop_index("ix_quota_events_job_run_id", table_name="quota_events")
op.drop_column("quota_events", "job_run_id")
op.drop_index("ix_job_runs_started_at", table_name="job_runs")
op.drop_index("ix_job_runs_actor_id", table_name="job_runs")
op.drop_index("ix_job_runs_job_started", table_name="job_runs")
op.drop_table("job_runs")
@@ -0,0 +1,79 @@
"""Job intervals move into sysconfig; the unread SponsorBlock default goes
Revision ID: 0110_intervals_in_sysconfig
Revises: 0109_job_runs
Create Date: 2026-10-18
An admin's interval override lived in its own table, `scheduler_settings`, beside the one store every
other admin knob uses. Each override becomes the `job_interval_<job>` sysconfig key and the table
goes. Only the jobs that still exist are copied: a row for any other id never did anything.
The `sponsorblock_default` setting was offered on the Configuration page but nothing ever read it, so
its key is gone and its stored value with it — the downgrade cannot bring that value back.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0110_intervals_in_sysconfig"
down_revision: str | None = "0109_job_runs"
branch_labels: str | None = None
depends_on: str | None = None
# The interval jobs as of this revision (a migration does not read the live code's list).
_JOBS = (
"rss_poll",
"enrich",
"backfill",
"autotag",
"shorts",
"subscriptions",
"playlist_sync",
"maintenance",
"explore_cleanup",
"download_gc",
"plex_sync",
"plex_watch_sync",
"plex_watch_reconcile",
"tmdb_enrich",
"people_enrich",
"audit_gc",
"log_gc",
)
_PREFIX = "job_interval_"
def upgrade() -> None:
op.execute(
sa.text(
"INSERT INTO system_config (key, value, encrypted)"
" SELECT :prefix || job_id, interval_minutes::text, false FROM scheduler_settings"
" WHERE interval_minutes IS NOT NULL AND job_id IN :jobs"
" ON CONFLICT (key) DO NOTHING"
).bindparams(sa.bindparam("jobs", expanding=True), prefix=_PREFIX, jobs=list(_JOBS))
)
op.drop_table("scheduler_settings")
op.execute("DELETE FROM system_config WHERE key = 'sponsorblock_default'")
def downgrade() -> None:
op.create_table(
"scheduler_settings",
sa.Column("job_id", sa.String(40), primary_key=True),
sa.Column("interval_minutes", sa.Integer(), nullable=False),
)
op.execute(
sa.text(
"INSERT INTO scheduler_settings (job_id, interval_minutes)"
" SELECT substr(key, :start), value::int FROM system_config WHERE key IN :keys"
).bindparams(
sa.bindparam("keys", expanding=True),
start=len(_PREFIX) + 1,
keys=[_PREFIX + j for j in _JOBS],
)
)
op.execute(
sa.text("DELETE FROM system_config WHERE key IN :keys").bindparams(
sa.bindparam("keys", expanding=True), keys=[_PREFIX + j for j in _JOBS]
)
)
@@ -0,0 +1,28 @@
"""An audit row keeps the request that made it
Revision ID: 0111_audit_request_id
Revises: 0110_intervals_in_sysconfig
Create Date: 2026-10-18
The activity page opens an admin action's log lines from its row: `audit_log.request_id` is the
request the change was made in, the same id `log_events.request_id` carries. Older rows have none —
the request that made them was never recorded.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0111_audit_request_id"
down_revision: str | None = "0110_intervals_in_sysconfig"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("audit_log", sa.Column("request_id", sa.String(64)))
op.create_index("ix_audit_log_request_id", "audit_log", ["request_id"])
def downgrade() -> None:
op.drop_index("ix_audit_log_request_id", table_name="audit_log")
op.drop_column("audit_log", "request_id")
+7 -5
View File
@@ -21,7 +21,8 @@ Three layers, by *who* runs the code — not by feature:
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth` (with `apitokens`, a machine's
folder-scoped way into the Files library), `sysconfig`, `quota`, `audit`,
folder-scoped way into the Files library), `sysconfig`, `quota`, `audit`, `jobruns` (every
background run's durable row, its words in `runstatus`),
`notifications`, `userscope`, `ratelimit`, `progress`, `logs` (the one logging
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
@@ -36,7 +37,7 @@ pre-abstract for a single caller; don't leave shared logic duplicated once there
## Transaction ownership
This follows CLAUDE.md's canonical rule verbatim — **"the handler/job-wrapper commits; helpers flush
(only `quota` owns its session)"** — so read it that way, not as an exception to it. The commit belongs
(only `quota` and `jobruns` own their session)"** — so read it that way, not as an exception to it. The commit belongs
to the **operation owner**; anything called *inside* another transaction only stages its change
(`db.add` / `flush`) and lets the owner commit, so the whole operation is one transaction.
`audit.record()` is the reference: it adds the audit row and the caller commits it together with the
@@ -45,7 +46,7 @@ mutation it records, so an audit entry can never exist without its change (or vi
- The **operation owner** is usually the route handler. A `service.*` operation the handler delegates a
whole transition to (e.g. `service.cancel`) *is* that owner and commits its one transition — it isn't
a mid-operation helper. A helper that owns its OWN short-lived session (the worker's `jobdb` writes,
the same carve-out CLAUDE.md grants `quota`) commits that session. Everything else flushes.
`jobruns`' run rows — the same carve-out CLAUDE.md grants `quota`) commits that session. Everything else flushes.
- `SessionLocal` is `expire_on_commit=False`: after a raw `UPDATE` + commit, an ORM object you already
loaded is **stale**. Read the fresh value from the row (RETURNING, or a re-`SELECT`), not the ORM
attribute — see `service.decr_ref_count`.
@@ -67,8 +68,9 @@ mutation it records, so an audit entry can never exist without its change (or vi
- **`app.config.settings`** — boot/env config (DB URL, download root, worker concurrency): read once at
startup, not user-tunable at runtime.
- **`sysconfig.SPECS`** — the runtime admin knobs (DB-override-or-default). A new admin-tunable value
goes in `SPECS`; don't invent a fourth store. (`state.py`'s `maintenance_batch` predates this and is
slated to fold into `sysconfig` — do not add more like it.)
goes in `SPECS`; don't invent a fourth store. Each spec states when a change takes effect
(`applies`, required): `live` keys are read where they are used, never at import (test-held). The
scheduler's job intervals are `SPECS` keys too, edited on the Scheduler page, not the Configuration page.
## The gate
+7 -7
View File
@@ -1,9 +1,9 @@
"""Admin/scheduler audit trail: an append-only record of who changed what and when.
"""Admin audit trail: an append-only record of who changed what and when.
Design (locked): log at ACTION / job-run granularity, NEVER per-row — a scheduler run that
touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are
only logged when they actually changed something (or errored); all admin actions and all manual
runs are always logged. Secrets are never stored (config changes log the key name only).
Design (locked): log at ACTION granularity, NEVER per-row — an action that touches thousands of
rows is ONE audit row summarising the outcome. Background runs are not actions: they have their
own history (app/jobruns.py). Secrets are never stored (config changes log the key name only).
Each row keeps the request that made it, so the request's log lines are one click away.
`record()` only db.add()s the row — the caller commits it in the SAME transaction as the
mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops
@@ -15,6 +15,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app import logs
from app.models import AuditLog
@@ -48,8 +49,6 @@ class AuditAction:
MAINTENANCE_TUNE = "maintenance_tune"
SYNC_PAUSE = "sync_pause"
SYNC_RESUME = "sync_resume"
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
JOB_RUN = "job_run"
# Per-user download cookie/auth credential set or removed (the value is NEVER recorded)
DOWNLOAD_CREDENTIAL_SET = "download_credential_set"
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
@@ -104,6 +103,7 @@ def record(
summary=(summary or None) and summary[:255],
before=before,
after=after,
request_id=logs.current_request_id(),
)
)
+4 -2
View File
@@ -268,10 +268,12 @@ class Settings(BaseSettings):
# YouTube quota history (quota_events): days kept (0 = forever). A year, so the admin usage view's
# longest window (365 days) stays whole; the same daily cleanup job prunes it.
quota_retention_days: int = 365
# Background-run history (job_runs): days kept (0 = forever). The audit log's 180, which is how
# long a run lived there before it moved here: ~1.8k rows a day at the default intervals
# (measured 2026-10-17), ~320k at the limit. The same daily cleanup job prunes it.
job_run_retention_days: int = 180
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
download_layout: DownloadLayout = "plex"
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
sponsorblock_default: bool = False
# --- Diagnostics / logging ---
# BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at
+131
View File
@@ -0,0 +1,131 @@
"""The durable history of background runs — the `job_runs` table.
A run is written twice: a `running` row when it starts, its outcome when it ends. Each write is its
OWN short session, committed at once, for the reason `quota.record_usage` gives: the run's own work
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.
Only the API process runs jobs (the worker never starts the scheduler), and one API process serves a
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 sqlalchemy import delete, func, select, union_all, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import JobRun, QuotaEvent
from app.runstatus import RunStatus, RunTrigger
def start(job: str, trigger: RunTrigger, actor_id: int | None, target: str | None = None) -> int:
"""Open a run's row; returns its id."""
with SessionLocal() as s:
row = JobRun(
job=job,
trigger=trigger,
actor_id=actor_id,
target=target,
status=RunStatus.RUNNING,
started_at=datetime.now(timezone.utc),
)
s.add(row)
s.commit()
return row.id
def finish(
run_id: int,
status: RunStatus,
*,
summary: str | None = None,
result: dict | None = None,
error: str | None = None,
failure: dict | None = None,
) -> None:
"""Close a run with its outcome and what it spent. Conditional on the row still running: a row
the startup sweep already called interrupted is not rewritten by a thread that outlived it."""
with SessionLocal() as s:
units = s.scalar(
select(func.coalesce(func.sum(QuotaEvent.units), 0)).where(
QuotaEvent.job_run_id == run_id
)
)
s.execute(
update(JobRun)
.where(JobRun.id == run_id, JobRun.status == RunStatus.RUNNING)
.values(
status=status,
finished_at=datetime.now(timezone.utc),
summary=summary,
result=result,
error=error,
failure=failure,
quota_units=units or 0,
)
)
s.commit()
def interrupt_leftovers() -> int:
"""Every row a previous process left `running` → `interrupted`, with what it spent until then
(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:
res = s.execute(
update(JobRun)
.where(JobRun.status == RunStatus.RUNNING)
.values(
status=RunStatus.INTERRUPTED,
finished_at=datetime.now(timezone.utc),
quota_units=spent,
)
)
s.commit()
return res.rowcount if isinstance(res, CursorResult) else 0
def recent_runs(db: Session, jobs: list[str], per_job: int) -> dict[str, list[JobRun]]:
"""Each of `jobs`' last `per_job` runs, newest first, by job id — one query, each job's part a
short walk of `ix_job_runs_job_started`, so a poll costs the same at any history length (a window
over the whole table grew with every run kept)."""
if not jobs:
return {}
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()
rows = db.execute(
select(JobRun)
.where(JobRun.id.in_(select(newest.c.id)))
.order_by(JobRun.job, JobRun.started_at.desc(), JobRun.id.desc())
).scalars()
out: dict[str, list[JobRun]] = {}
for r in rows:
out.setdefault(r.job, []).append(r)
return out
def gc(db: Session, retention_days: int) -> int:
"""Delete finished runs older than `retention_days` (0 = keep forever). A run still going is
never reaped. The caller commits."""
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
res = db.execute(
delete(JobRun).where(JobRun.started_at < cutoff, JobRun.status != RunStatus.RUNNING)
)
return res.rowcount if isinstance(res, CursorResult) else 0
+15 -2
View File
@@ -10,8 +10,8 @@ the logs were both unjoinable and leaky. `configure_logging()` replaces all four
Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line:
ts · level · sev · source · logger · event · msg · attrs
plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`,
`job_id`) and the app version. That shape is what the admin log console filters on and what a Gitea
issue attachment will carry, so it is deliberately flat and stable.
`job_id`, `run_id`) and the app version. That shape is what the admin log console filters on and
what a Gitea issue attachment will carry, so it is deliberately flat and stable.
Call sites stay ordinary stdlib logging:
@@ -81,15 +81,28 @@ _debug_session: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"log_debug_session", default=None
)
_job_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_job_id", default=None)
# The background run (app/jobruns.py) this context is doing; quota's spend is tagged with it too.
_run_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_run_id", default=None)
_CONTEXT_VARS = {
"request_id": _request_id,
"user_id": _user_id,
"debug_session_id": _debug_session,
"job_id": _job_id,
"run_id": _run_id,
}
def current_request_id() -> str | None:
"""The HTTP request this context is serving, if any — the request middleware sets it."""
return _request_id.get()
def current_run_id() -> int | None:
"""The background run this context is doing, if any — `bind(run_id=…)` sets it."""
return _run_id.get()
class _RequestScope:
"""What the auth dependency learns about a request, for every line logged after it.
+1
View File
@@ -362,6 +362,7 @@ def serialize(row: LogEvent) -> dict:
"request_id": row.request_id,
"user_id": row.user_id,
"job_id": row.job_id,
"run_id": row.run_id,
"debug_session_id": row.debug_session_id,
"version": row.version,
}
+2
View File
@@ -52,6 +52,7 @@ from app.routes import ( # noqa: E402 — logging is configured first
plex as plex_routes,
public as public_routes,
quota,
runs as runs_routes,
saved_views,
scheduler as scheduler_routes,
search as search_routes,
@@ -345,6 +346,7 @@ app.include_router(config_routes.router)
app.include_router(scheduler_routes.router)
app.include_router(audit_routes.router)
app.include_router(admin_logs_routes.router)
app.include_router(runs_routes.router)
app.include_router(quota.router)
app.include_router(version.router)
app.include_router(setup_routes.router)
+55 -12
View File
@@ -24,6 +24,7 @@ from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base
from app.runstatus import RunStatus, RunTrigger, sql_in
from app.utils import canonical_email
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
@@ -563,14 +564,20 @@ class QuotaEvent(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
# The background run that spent it (app/jobruns.py); NULL = a request's own spend. SET NULL: a
# reaped run leaves the spend in the budget's history.
job_run_id: Mapped[int | None] = mapped_column(
ForeignKey("job_runs.id", ondelete="SET NULL"), index=True
)
class AuditLog(Base):
"""Append-only admin/scheduler audit trail: who did what, when, and (where it applies)
the before->after values. `actor_id` is the acting admin; NULL means the scheduler /
background did it (SET NULL on user delete keeps the trail). Logged at ACTION / job-run
granularity — never per-row — so a scheduler run that touches thousands of videos is ONE
row summarising the outcome. `before`/`after` are small JSON snapshots (secrets excluded)."""
"""Append-only admin audit trail: who did what, when, and (where it applies) the before->after
values. `actor_id` is the acting admin; NULL means the background did it (SET NULL on user
delete keeps the trail). Logged per ACTION, never per row; a background run is not an action
and has its own history (`JobRun`). `before`/`after` are small JSON snapshots (secrets
excluded). `request_id` is the request that made the change, so its log lines are one click
away (NULL for rows written outside a request, and for rows older than the column)."""
__tablename__ = "audit_log"
@@ -587,6 +594,7 @@ class AuditLog(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
class LogEvent(Base):
@@ -609,6 +617,8 @@ class LogEvent(Base):
"debug_session_id",
postgresql_where="debug_session_id IS NOT NULL",
),
# A run's lines are `WHERE run_id = …`; request lines carry none.
Index("ix_log_events_run", "run_id", postgresql_where="run_id IS NOT NULL"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -624,7 +634,11 @@ class LogEvent(Base):
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int | None] = mapped_column(Integer)
debug_session_id: Mapped[str | None] = mapped_column(String(64))
# A DOWNLOAD job's id (download_jobs) — not a background run, which is `run_id`.
job_id: Mapped[int | None] = mapped_column(Integer)
# The background run (job_runs) that logged it. No foreign key, as `user_id`: the writer runs
# behind the event, and a run reaped meanwhile must not fail the batch its lines travel in.
run_id: Mapped[int | None] = mapped_column(BigInteger)
version: Mapped[str | None] = mapped_column(String(64))
@@ -689,15 +703,44 @@ class AppState(Base):
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
class SchedulerSetting(Base):
"""Admin override for a scheduler job's run interval (minutes). One row per job id;
absent = use the env/config default. DB-backed so it's editable from the admin UI at
runtime without a redeploy (env stays a fallback)."""
class JobRun(Base):
"""One run of a background job — scheduled, an admin's "run now", or a user's ad-hoc work on one
target — written when it starts and again when it ends (app/jobruns.py), each in its own
committed session, so the history survives the job's own rollback and a restart. A row left
`running` by a process that died is set `interrupted` at the next startup.
__tablename__ = "scheduler_settings"
`quota_units` is what the run spent of the YouTube budget: the `quota_events` rows carrying its
id, summed when it ends. `summary` is the English one-line fallback; `result` / `failure` are the
same outcome as data, which the client names in the reader's language."""
job_id: Mapped[str] = mapped_column(String(40), primary_key=True)
interval_minutes: Mapped[int] = mapped_column(Integer)
__tablename__ = "job_runs"
__table_args__ = (
Index("ix_job_runs_job_started", "job", "started_at"),
CheckConstraint(f"status IN ({sql_in(RunStatus)})", name="ck_job_runs_status"),
CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"),
# Only a run still going has no end.
CheckConstraint(
f"(status = '{RunStatus.RUNNING}') = (finished_at IS NULL)", name="ck_job_runs_finished"
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
job: Mapped[str] = mapped_column(String(40))
trigger: Mapped[str] = mapped_column(String(16))
# Who started it: the admin of a manual run, the user of an ad-hoc one; NULL = the schedule.
actor_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
# What an ad-hoc run worked on (a channel id); NULL for the recurring jobs.
target: Mapped[str | None] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
summary: Mapped[str | None] = mapped_column(Text)
result: Mapped[dict | None] = mapped_column(JSONB)
error: Mapped[str | None] = mapped_column(Text)
failure: Mapped[dict | None] = mapped_column(JSONB)
quota_units: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
class SystemConfig(Base):
+11 -3
View File
@@ -16,7 +16,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session
from app import sysconfig
from app import logs, sysconfig
from app.db import SessionLocal
from app.models import ApiQuotaUsage, QuotaEvent
@@ -204,6 +204,14 @@ def record_usage(units: int) -> None:
)
with SessionLocal() as qs:
qs.execute(stmt)
# Audit detail: who/what spent it (per-user attribution; NULL actor = system).
qs.add(QuotaEvent(user_id=_actor_id.get(), action=_action.get(), units=units))
# Audit detail: who/what spent it (per-user attribution; NULL actor = system), and the
# background run it belongs to, so the run's row can say what it cost.
qs.add(
QuotaEvent(
user_id=_actor_id.get(),
action=_action.get(),
units=units,
job_run_id=logs.current_run_id(),
)
)
qs.commit()
+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)
+66
View File
@@ -0,0 +1,66 @@
"""Keyset paging over `(timestamp desc, id desc)`, shared by the admin history lists (server log,
audit log, background runs).
A timestamp alone is not unique (a browser log row's `ts` is clamped into its consent window; a run
and its siblings can start in the same microsecond), and an offset would skip or repeat rows while
writers add new ones at the top — so a page continues from the last row's `(timestamp, id)`.
"""
import base64
import binascii
import json
from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session
from app.models import User
PAGE_MAX = 200
def utc(value: datetime | None) -> datetime | None:
"""A bound without a zone is UTC — the columns are `timestamptz`, and a naive value would be read
in the database session's zone instead."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def encode_cursor(ts: datetime, row_id: int) -> str:
return base64.urlsafe_b64encode(json.dumps([ts.isoformat(), row_id]).encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
at = datetime.fromisoformat(ts)
return (at if at.tzinfo is not None else at.replace(tzinfo=timezone.utc)), int(row_id)
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
def older_than(ts_col, id_col, cursor: str):
"""The rows after `cursor` in `(ts desc, id desc)` order. The `ts <=` conjunct is implied by the
OR; it is there so the planner can bound the timestamp index scan instead of filtering every
newer row first."""
ts, row_id = decode_cursor(cursor)
return and_(ts_col <= ts, or_(ts_col < ts, and_(ts_col == ts, id_col < row_id)))
def page(rows: list, limit: int, ts_of) -> tuple[list, str | None]:
"""`rows` was read with `limit + 1` (to know whether there is a next page without a count query):
the page itself, and the cursor that continues after it (None on the last page)."""
more = len(rows) > limit
rows = rows[:limit]
return rows, encode_cursor(ts_of(rows[-1]), rows[-1].id) if more else None
def accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
"""The accounts a page names, by id → address. An id with no account left (erased after the row
was written) is simply absent, and the client shows the bare id."""
if not user_ids:
return {}
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
return {str(uid): email for uid, email in rows}
+32 -71
View File
@@ -8,33 +8,29 @@ What a row holds was redacted when it was written (`logs.record_fields`, and the
browser's rows), so this serves the columns as they are; a value the redactor took out reads as
`[redacted]` / `[email]`, which is the point — the console shows that something was there.
Paging is a keyset over `(ts desc, id desc)`: a browser row's `ts` is clamped into its consent
window, so `ts` alone is not unique, and an offset would skip or repeat rows while the writer adds
new ones at the top. The live tail re-reads the first page rather than asking for "ids after N":
Paging is a keyset over `(ts desc, id desc)` (`_keyset`). The live tail re-reads the first page rather than asking for "ids after N":
several processes write this table, and a batch's ids are taken when it inserts, not when it
commits, so a slower batch can land below an id the tail already passed — and would never show.
"""
import base64
import binascii
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Text, and_, cast, func, or_, select
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Text, cast, func, or_, select
from sqlalchemy.orm import Session
from app import logsink
from app.auth import admin_user
from app.db import get_db
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.utils import like_escape
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
PAGE_MAX = 200
# A facet list is a chip row, not a report: past this many distinct events the rest are reachable
# through the free-text search, and the response stays small whatever a self-host logs.
FACET_EVENTS_MAX = 100
@@ -42,38 +38,9 @@ FACET_EVENTS_MAX = 100
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# The filters a facet can leave out of its own count (`LogFilters.conditions`).
Facet = Literal["levels", "sources", "user", "event"]
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
# request) — a real value to filter on, not the absence of a filter.
NONE = "none"
def _utc(value: datetime | None) -> datetime | None:
"""A bound without a zone is UTC — the column is `timestamptz`, and a naive value would be read
in the database session's zone instead."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _encode_cursor(row: LogEvent) -> str:
return base64.urlsafe_b64encode(json.dumps([row.ts.isoformat(), row.id]).encode()).decode()
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
return _utc(datetime.fromisoformat(ts)), int(row_id)
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
"""The accounts a page names, by id → address. An id with no account left (erased after the
line was written) is simply absent, and the client shows the bare id."""
if not user_ids:
return {}
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
return {str(uid): email for uid, email in rows}
# `none` selects the rows that have no event code, as the account filter's `none` (app/routes/_account)
# selects the rows of no account — a real value to filter on, not the absence of a filter.
NONE = account.NONE
@dataclass(frozen=True)
@@ -86,6 +53,7 @@ class LogFilters:
event: str | None
request_id: str | None
job_id: int | None
run_id: int | None
q: str | None
since: datetime | None
until: datetime | None
@@ -100,11 +68,7 @@ class LogFilters:
if self.sources and skip != "sources":
out.append(LogEvent.source.in_(self.sources))
if self.user is not None and skip != "user":
out.append(
LogEvent.user_id.is_(None)
if self.user == NONE
else LogEvent.user_id == int(self.user)
)
out.append(account.condition(LogEvent.user_id, self.user))
if self.event is not None and skip != "event":
out.append(
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
@@ -113,10 +77,12 @@ class LogFilters:
out.append(LogEvent.request_id == self.request_id)
if self.job_id is not None:
out.append(LogEvent.job_id == self.job_id)
if self.run_id is not None:
out.append(LogEvent.run_id == self.run_id)
if self.since is not None:
out.append(LogEvent.ts >= _utc(self.since))
out.append(LogEvent.ts >= utc(self.since))
if self.until is not None:
out.append(LogEvent.ts < _utc(self.until))
out.append(LogEvent.ts < utc(self.until))
if self.q and self.q.strip():
like = f"%{like_escape(self.q.strip())}%"
out.append(
@@ -134,17 +100,20 @@ class LogFilters:
def log_filters(
levels: list[Level] = Query(default=[]),
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(
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
),
request_id: str | None = Query(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$"),
job_id: int | None = Query(default=None, ge=1),
run_id: int | None = Query(default=None, ge=1),
q: str | None = Query(default=None, max_length=200),
since: datetime | None = None,
until: datetime | None = None,
) -> LogFilters:
return LogFilters(list(levels), list(sources), user, event, request_id, job_id, q, since, until)
return LogFilters(
list(levels), list(sources), user, event, request_id, job_id, run_id, q, since, until
)
@router.get("")
@@ -159,28 +128,20 @@ def list_logs(
continues from a previous page's `next_cursor`."""
query = select(LogEvent).where(*filters.conditions())
if before is not None:
ts, row_id = _decode_cursor(before)
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
# `ts` index scan instead of filtering every newer row first.
query = query.where(
and_(
LogEvent.ts <= ts,
or_(LogEvent.ts < ts, and_(LogEvent.ts == ts, LogEvent.id < row_id)),
)
)
# One more than asked, to know whether there is a next page without a second count query.
rows = (
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
.scalars()
.all()
query = query.where(older_than(LogEvent.ts, LogEvent.id, before))
rows, cursor = page(
list(
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
.scalars()
.all()
),
limit,
lambda r: r.ts,
)
more = len(rows) > limit
rows = rows[:limit]
return {
"items": [logsink.serialize(r) for r in rows],
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
"next_cursor": _encode_cursor(rows[-1]) if more else None,
"accounts": accounts(db, {r.user_id for r in rows if r.user_id is not None}),
"next_cursor": cursor,
}
@@ -211,6 +172,6 @@ def log_facets(
"sources": counts(LogEvent.source, "sources"),
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
"users": [{**u, "value": str(u["value"])} for u in users],
"accounts": _accounts(db, {u["value"] for u in users if u["value"] != NONE}),
"accounts": accounts(db, {u["value"] for u in users if u["value"] != NONE}),
"total": db.scalar(total) or 0,
}
+106 -29
View File
@@ -1,67 +1,144 @@
"""Admin audit log API: read the append-only who/what/when trail, and clear it.
Admin-only. The client renders these rows in a DataTable (client-side sort/filter/paginate), so
this returns the most recent window of rows (capped) plus the true total, rather than paging
server-side — the log stays small in practice (state-changing runs + admin actions only)."""
Admin-only. The activity page reads it a page at a time, newest first, filtered on the server like
the server log (`_keyset` paging), so the whole trail is reachable however long it grows."""
from fastapi import APIRouter, Depends
from sqlalchemy import delete, func, select
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Text, cast, delete, func, or_, select
from sqlalchemy.orm import Session
from app import audit
from app.audit import AuditAction
from app.auth import admin_user
from app.db import get_db
from app.models import AuditLog, User
from app.utils import iso
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.utils import iso, like_escape
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
# Cap the client-side window. The audit log is small by design; if it ever outgrows this we'd
# switch the DataTable to server-side paging.
MAX_ROWS = 2000
def _serialize(row: AuditLog, actors: dict[int, str]) -> dict:
def _serialize(row: AuditLog, lines: dict[str, int]) -> dict:
return {
"id": row.id,
"created_at": iso(row.created_at),
# actor_id NULL = the scheduler/background did it (client shows "System"); a set id that
# can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present.
"actor": actors.get(row.actor_id) if row.actor_id is not None else None,
# NULL = the background did it (the client shows "System"); the address is in `accounts`.
"actor_id": row.actor_id,
"action": row.action,
"target_type": row.target_type,
"target_id": row.target_id,
"summary": row.summary,
"before": row.before,
"after": row.after,
"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,
}
@dataclass(frozen=True)
class AuditFilters:
action: str | None
actor: str | None
q: str | None
since: datetime | None
until: datetime | None
def conditions(self, skip: Literal["action"] | None = None) -> list:
"""The WHERE conditions, ANDed. `skip="action"` leaves the action filter out, so the action
menu counts every action under the other filters (the way back from a pick)."""
out = []
if self.action is not None and skip != "action":
out.append(AuditLog.action == self.action)
if self.actor is not None:
out.append(account.condition(AuditLog.actor_id, self.actor))
if self.since is not None:
out.append(AuditLog.created_at >= utc(self.since))
if self.until is not None:
out.append(AuditLog.created_at < utc(self.until))
if self.q and self.q.strip():
like = f"%{like_escape(self.q.strip())}%"
out.append(
or_(
AuditLog.summary.ilike(like),
AuditLog.target_id.ilike(like),
AuditLog.action.ilike(like),
AuditLog.request_id.ilike(like),
cast(AuditLog.before, Text).ilike(like),
cast(AuditLog.after, Text).ilike(like),
)
)
return out
def audit_filters(
action: str | None = Query(default=None, max_length=48, pattern=r"^[a-z][a-z0-9_]*$"),
user: str | None = Query(default=None, pattern=account.PATTERN),
q: str | None = Query(default=None, max_length=200),
since: datetime | None = None,
until: datetime | None = None,
) -> AuditFilters:
return AuditFilters(action, user, q, since, until)
@router.get("")
def list_audit(
limit: int = 500,
filters: AuditFilters = Depends(audit_filters),
before: str | None = Query(default=None, max_length=200),
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
limit = max(1, min(limit, MAX_ROWS))
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
rows = (
db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit))
.scalars()
.all()
"""One page of rows, newest first, matching every filter given; `before` continues from a
previous page's `next_cursor`."""
query = select(AuditLog).where(*filters.conditions())
if before is not None:
query = query.where(older_than(AuditLog.created_at, AuditLog.id, before))
order = (AuditLog.created_at.desc(), AuditLog.id.desc())
rows, cursor = page(
list(db.execute(query.order_by(*order).limit(limit + 1)).scalars().all()),
limit,
lambda r: r.created_at,
)
actor_ids = {r.actor_id for r in rows if r.actor_id is not None}
actors: dict[int, str] = {}
if actor_ids:
actors = dict(db.execute(select(User.id, User.email).where(User.id.in_(actor_ids))).all())
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 {
"items": [_serialize(r, actors) for r in rows],
"total": total,
"returned": len(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}),
"next_cursor": cursor,
}
@router.get("/facets")
def audit_facets(
filters: AuditFilters = Depends(audit_filters),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""The actions present under the other filters, with counts — what the action menu offers."""
n = func.count().label("n")
stmt = (
select(AuditLog.action, n)
.where(*filters.conditions("action"))
.group_by(AuditLog.action)
.order_by(n.desc(), AuditLog.action)
)
return {"actions": [{"value": a, "count": c} for a, c in db.execute(stmt).all()]}
@router.delete("")
def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence)."""
+16 -9
View File
@@ -3,15 +3,16 @@ hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side w
(subscribe / unsubscribe) that require the optional write scope."""
from datetime import datetime, timezone
from functools import partial
from fastapi import APIRouter, BackgroundTasks, Depends
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import Select, func, select, update
from sqlalchemy.orm import Session
from app.collation import collated, sorted_words
from app.mailtext import lang_for_user
from app import logs, quota
from app import logs, quota, scheduler
from app.auth import admin_user, current_user, require_write_scope
from app.db import get_db
from app.models import (
@@ -481,7 +482,6 @@ class ChannelUpdateIn(BaseModel):
def update_channel(
channel_id: str,
body: ChannelUpdateIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -508,12 +508,19 @@ def update_channel(
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1)
# …and kick the FULL deep backfill for this one channel immediately in the background, 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 scheduler
# still finishes a very large channel's tail on its normal runs (deep_requested stays set).
if deep_turned_on:
background.add_task(backfill_channel_now, channel_id, user.id)
# …and kick the FULL deep backfill for this one channel immediately, as a recorded run of its own,
# 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
# scheduler still finishes a very large channel's tail on its normal runs (deep_requested stays set).
# 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.CHANNEL_BACKFILL,
partial(backfill_channel_now, channel_id, user.id),
actor_id=user.id,
target=channel_id,
)
return {
"ok": True,
+5 -3
View File
@@ -61,7 +61,7 @@ def set_config(
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
s = sysconfig.spec(key)
s = sysconfig.config_page_spec(key)
if s is None:
raise not_found("config_key")
if "value" not in body.model_fields_set:
@@ -89,7 +89,9 @@ def set_config(
before=None if s.secret else {"value": _redact(old)},
after=None if s.secret else {"value": _redact(new)},
)
db.commit()
# The override is committed even when the trail skips it: re-saving the value in force still
# pins it as an override, and an uncommitted write would be dropped with the session.
db.commit()
_apply_runtime(db, key)
return sysconfig.describe(db)
@@ -100,7 +102,7 @@ def reset_config(
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
s = sysconfig.spec(key)
s = sysconfig.config_page_spec(key)
if s is None:
raise not_found("config_key")
old = None if s.secret else sysconfig.get(db, key)
+20 -9
View File
@@ -6,7 +6,7 @@ The aggregate daily budget lives in ApiQuotaUsage; here we read the per-spend au
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -14,6 +14,8 @@ from app import quota
from app.auth import admin_user, current_user
from app.db import get_db
from app.models import QuotaEvent, User
from app.routes import _account as account
from app.routes._keyset import utc
router = APIRouter(prefix="/api/quota", tags=["quota"])
@@ -45,23 +47,32 @@ def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db))
@router.get("/admin")
def admin_usage(
days: int = 30,
since: datetime | None = None,
until: datetime | None = None,
user: str | None = Query(default=None, pattern=account.PATTERN),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
days = max(1, min(days, 365))
since = datetime.now(timezone.utc) - timedelta(days=days)
"""Spend per account (and per action) and per day, between `since` (default: 30 days back) and
`until` (default: now), optionally for one account (`none` = the background)."""
now = datetime.now(timezone.utc)
lo = utc(since) or now - timedelta(days=30)
window = [QuotaEvent.created_at >= lo]
if until is not None:
window.append(QuotaEvent.created_at < utc(until))
if user is not None:
window.append(account.condition(QuotaEvent.user_id, user))
totals = db.execute(
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.where(*window)
.group_by(QuotaEvent.user_id)
).all()
per_action: dict[int | None, dict[str, int]] = {}
for uid, action, units in db.execute(
select(QuotaEvent.user_id, QuotaEvent.action, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.where(*window)
.group_by(QuotaEvent.user_id, QuotaEvent.action)
).all():
per_action.setdefault(uid, {})[action] = int(units)
@@ -84,16 +95,16 @@ def admin_usage(
]
rows.sort(key=lambda r: r["total"], reverse=True)
# Instance-wide daily series in Pacific days (matches the budget reset).
# The daily series in Pacific days (matches the budget reset), of the one account when filtered.
day_expr = func.date(func.timezone("America/Los_Angeles", QuotaEvent.created_at))
daily = [
{"day": str(day), "total": int(units)}
for day, units in db.execute(
select(day_expr, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.where(*window)
.group_by(day_expr)
.order_by(day_expr)
).all()
]
return {"range_days": days, "rows": rows, "daily": daily}
return {"rows": rows, "daily": daily}
+156
View File
@@ -0,0 +1,156 @@
"""Admin background-run history: read `job_runs`, a page at a time.
Admin-only and read-only — rows leave the table through the retention job
(`job_run_retention_days`). Filtered on the server and paged by `_keyset`, like the server log and
the audit log beside it on the activity page. A row names what a reader needs without a second
request: the channel a user's ad-hoc run worked on, and how many log lines the run left (only
WARNING and up are stored, so most runs have none — the count says so before the click).
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app import scheduler
from app.auth import admin_user
from app.db import get_db
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.runstatus import RunStatus
from app.utils import iso, like_escape
router = APIRouter(prefix="/api/admin/runs", tags=["admin"])
Facet = Literal["job", "status"]
def _serialize(run: JobRun, titles: dict[str, str], lines: dict[int, int]) -> dict:
return {
"id": run.id,
"job": run.job,
"trigger": run.trigger,
"actor_id": run.actor_id,
"target": run.target,
"target_title": titles.get(run.target) if run.target is not None else None,
"status": run.status,
"started_at": iso(run.started_at),
"finished_at": iso(run.finished_at),
"summary": run.summary,
"result": run.result,
"error": run.error,
"failure": run.failure,
"quota_units": run.quota_units,
"log_lines": lines.get(run.id, 0),
}
@dataclass(frozen=True)
class RunFilters:
job: str | None
status: RunStatus | None
actor: str | None
q: str | None
since: datetime | None
until: datetime | None
def conditions(self, skip: Facet | None = None) -> list:
"""The WHERE conditions, ANDed. `skip` leaves one menu's own filter out, so that menu counts
its values under every other filter (the way back from a pick)."""
out = []
if self.job is not None and skip != "job":
out.append(JobRun.job == self.job)
if self.status is not None and skip != "status":
out.append(JobRun.status == self.status)
if self.actor is not None:
out.append(account.condition(JobRun.actor_id, self.actor))
if self.since is not None:
out.append(JobRun.started_at >= utc(self.since))
if self.until is not None:
out.append(JobRun.started_at < utc(self.until))
if self.q and self.q.strip():
like = f"%{like_escape(self.q.strip())}%"
out.append(
or_(
JobRun.job.ilike(like),
JobRun.target.ilike(like),
JobRun.summary.ilike(like),
JobRun.error.ilike(like),
)
)
return out
def run_filters(
job: str | None = Query(default=None, max_length=40, pattern=r"^[a-z][a-z0-9_]*$"),
status: RunStatus | None = None,
user: str | None = Query(default=None, pattern=account.PATTERN),
q: str | None = Query(default=None, max_length=200),
since: datetime | None = None,
until: datetime | None = None,
) -> RunFilters:
return RunFilters(job, status, user, q, since, until)
@router.get("")
def list_runs(
filters: RunFilters = Depends(run_filters),
before: str | None = Query(default=None, max_length=200),
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""One page of runs, newest first, matching every filter given; `before` continues from a
previous page's `next_cursor`."""
query = select(JobRun).where(*filters.conditions())
if before is not None:
query = query.where(older_than(JobRun.started_at, JobRun.id, before))
order = (JobRun.started_at.desc(), JobRun.id.desc())
runs, cursor = page(
list(db.execute(query.order_by(*order).limit(limit + 1)).scalars().all()),
limit,
lambda r: r.started_at,
)
channels = {r.target for r in runs if r.job in scheduler.AD_HOC_JOBS and r.target}
titles: dict[str, str] = {}
if channels:
rows = db.execute(select(Channel.id, Channel.title).where(Channel.id.in_(channels)))
titles = {cid: title for cid, title in rows.tuples() if title is not None}
lines: dict[int, int] = {}
if runs:
counted = db.execute(
select(LogEvent.run_id, func.count())
.where(LogEvent.run_id.in_([r.id for r in runs]))
.group_by(LogEvent.run_id)
)
lines = {rid: n for rid, n in counted.tuples() if rid is not None}
return {
"items": [_serialize(r, titles, lines) for r in runs],
"accounts": accounts(db, {r.actor_id for r in runs if r.actor_id is not None}),
"next_cursor": cursor,
}
@router.get("/facets")
def run_facets(
filters: RunFilters = Depends(run_filters),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""What the job menu and the status chips offer, each counted under every filter but its own."""
def counts(col, skip: Facet) -> list[dict]:
n = func.count().label("n")
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
return [{"value": v, "count": c} for v, c in db.execute(stmt).all()]
total = select(func.count()).select_from(JobRun).where(*filters.conditions("status"))
return {
"jobs": counts(JobRun.job, "job"),
"statuses": counts(JobRun.status, "status"),
"total": db.scalar(total) or 0,
}
+7 -5
View File
@@ -17,8 +17,9 @@ from app.scheduler import (
JOB_INTERVALS,
MAX_INTERVAL,
MIN_INTERVAL,
apply_interval,
reschedule,
scheduler_snapshot,
stage_interval,
trigger_all,
trigger_job,
)
@@ -42,14 +43,14 @@ def set_job_interval(
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
to the live scheduler immediately."""
"""Change how often a scheduler job runs (minutes). Stored as an override together with its
audit row, then applied to the live scheduler."""
if job_id not in JOB_INTERVALS:
raise not_found("job")
minutes = body.interval_minutes
if not (MIN_INTERVAL <= minutes <= 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(
db,
admin.id,
@@ -60,6 +61,7 @@ def set_job_interval(
after={"interval_minutes": applied},
)
db.commit()
reschedule(job_id, applied)
return {"ok": True, "id": job_id, "interval_minutes": applied}
@@ -117,7 +119,7 @@ def set_maintenance_settings(
@router.get("")
def get_scheduler(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
snap = scheduler_snapshot()
snap = scheduler_snapshot(db)
# Work still queued (shared catalog, so these are instance-wide, not per-user).
deep_requested = (
+3 -3
View File
@@ -91,12 +91,12 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
@router.post("/config")
def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_setup)) -> dict:
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the
sysconfig registry; empty values are skipped (left at the default). Genuinely dynamic
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be the
Configuration page's; empty values are skipped (left at the default). Genuinely dynamic
key→value map, so it stays a typed dict rather than a fixed model."""
saved: list[str] = []
for key, value in settings_map.items():
if sysconfig.spec(key) is None:
if sysconfig.config_page_spec(key) is None:
raise refused(400, "unknown_setting", key=key)
if value is None or value == "":
continue
+37
View File
@@ -0,0 +1,37 @@
"""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
model's CHECK constraints are built from these sets, and the dashboard reads them back (the frontend's
copies are held to them by `backendIds.contract.test.ts`). The migrated database's CHECKs are frozen
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
class RunStatus(StrEnum):
RUNNING = "running"
OK = "ok"
ERROR = "error"
# Sync was paused: the run was due and deliberately did nothing.
SKIPPED = "skipped"
# The process stopped while the run was going (a deploy, a crash): set at the next startup, so a
# row never claims to be running in a process that no longer exists.
INTERRUPTED = "interrupted"
class RunTrigger(StrEnum):
# The run's interval came round.
SCHEDULED = "scheduled"
# An admin pressed "run now" (or "run all").
MANUAL = "manual"
# A user's own action started work on one target (a channel's full history), outside the
# recurring jobs.
AD_HOC = "ad_hoc"
def sql_in(values: type[StrEnum]) -> str:
"""`'a', 'b'` — the IN-list a CHECK constraint names these values with."""
return ", ".join(f"'{v.value}'" for v in values)
+346 -190
View File
@@ -3,28 +3,31 @@ recent-first / deep backfill. Runs inside the API process (single worker)."""
import contextvars
import threading
from datetime import datetime, timezone
from datetime import timezone
from typing import Callable
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.jobstores.base import ConflictingIdError
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from app import audit, debugsession, logs, logsink, progress, quota
from app.audit import AuditAction
from app import audit, debugsession, jobruns, logs, logsink, progress, quota, sysconfig
from app.config import settings
from app.db import SessionLocal
from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting
from app.models import Channel, JobRun
from app.plex.sync import sync as run_plex_sync
from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.tmdb import run_people_enrich
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
from app.notifications import create_notification
from app.runstatus import RunStatus, RunTrigger
from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all
from app.sync.explore import purge_ephemeral
from app.sync.maintenance import run_maintenance
from app.sync.playlists import sync_all_playlists
from app.utils import iso
from app.sync.runner import (
run_deep_backfill,
run_enrich,
@@ -37,12 +40,31 @@ from app.sync.runner import (
log = logs.get_logger(__name__)
_scheduler: BackgroundScheduler | None = None
# Per-job run activity, kept in-memory for the admin Scheduler dashboard to read (the
# scheduler lives in the same single-worker process as the API, so a request can read it
# directly). Ephemeral by design — it reflects "what the scheduler is doing right now and
# how the last runs went" since this process started; durable history isn't the goal here.
# What is running RIGHT NOW, per job: the single-run claim and the live progress. In memory on
# purpose — the scheduler lives in the API's one process, and progress changes every second. What a
# run did is durable instead: its `job_runs` row (app/jobruns.py), which outlives a restart.
_activity: dict[str, dict] = {}
_activity_lock = threading.Lock()
# The runs this process has open (run id -> job), under the same lock. A row can stay `running` in
# the table after its outcome failed to write (_close), so the table cannot say what is going; this
# can, as the one process per database (app/jobruns.py) is the one running them.
_open_runs: dict[int, str] = {}
def _opened(run_id: int, job: str) -> None:
with _activity_lock:
_open_runs[run_id] = job
def _closed(run_id: int) -> None:
with _activity_lock:
_open_runs.pop(run_id, None)
def _open_run_ids() -> dict[int, str]:
with _activity_lock:
return dict(_open_runs)
# Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that
# run — and only that run, not the recurring scheduled ones — posts a completion notification
@@ -51,59 +73,36 @@ _manual_actor: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"manual_actor", default=None
)
# Each interval job's env/config default period (minutes). The admin can override any of
# these at runtime (stored in scheduler_settings, applied live); see load_intervals.
# Each interval job's env/config default period (minutes). The admin can override any of these at
# runtime from the Scheduler page — a sysconfig key per job (`sysconfig.interval_key`), applied
# live; see load_intervals.
JOB_INTERVALS: dict[str, int] = {
"rss_poll": settings.rss_poll_minutes,
"enrich": settings.enrich_interval_minutes,
"backfill": settings.backfill_interval_minutes,
"autotag": settings.autotag_interval_minutes,
"shorts": settings.shorts_probe_interval_minutes,
"subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes,
"explore_cleanup": settings.explore_cleanup_minutes,
"download_gc": settings.download_gc_minutes,
"plex_sync": settings.plex_sync_interval_min,
"plex_watch_sync": settings.plex_watch_sync_interval_min,
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
"tmdb_enrich": settings.tmdb_enrich_interval_min,
"people_enrich": settings.people_enrich_interval_min,
"audit_gc": settings.audit_gc_minutes,
"log_gc": settings.log_gc_minutes,
job: getattr(settings, attr) for job, attr in sysconfig.JOB_INTERVAL_DEFAULTS.items()
}
# Sane bounds for an admin-set interval (minutes).
MIN_INTERVAL = 1
MAX_INTERVAL = 1440 # one day
MIN_INTERVAL = sysconfig.MIN_INTERVAL
MAX_INTERVAL = sysconfig.MAX_INTERVAL
def load_intervals(db) -> dict[str, int]:
"""Effective per-job intervals: the env/config defaults overlaid with any admin overrides
saved in scheduler_settings."""
iv = dict(JOB_INTERVALS)
for row in db.execute(select(SchedulerSetting)).scalars():
if row.job_id in iv and row.interval_minutes:
iv[row.job_id] = row.interval_minutes
return iv
"""Effective per-job intervals: each job's sysconfig override, else its env/config default."""
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:
"""Persist a job's interval override and reschedule the live job immediately (if the
scheduler runs in this process). Returns the applied value."""
def stage_interval(db, job_id: str, minutes: int) -> int:
"""Stage a job's interval override (clamped) in `db`; returns the value. The caller commits it
with its audit row, then calls `reschedule` — the live job follows only what was stored."""
if job_id not in JOB_INTERVALS:
raise KeyError(job_id)
minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes)))
row = db.get(SchedulerSetting, job_id)
if row is None:
db.add(SchedulerSetting(job_id=job_id, interval_minutes=minutes))
else:
row.interval_minutes = minutes
db.commit()
sysconfig.set_value(db, sysconfig.interval_key(job_id), minutes)
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:
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
log.info("rescheduled job %s to every %s min", job_id, minutes)
return minutes
def _record(name: str, **fields) -> None:
@@ -112,7 +111,12 @@ def _record(name: str, **fields) -> None:
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:
"""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."""
@@ -130,49 +134,6 @@ def _notify_done(
log.exception("failed to write completion notification for job %s", job_id)
def _run_changed(result) -> bool:
"""Did an automatic job run actually do something worth an audit row? (No-op polls don't.)
A "change" = a truthy NUMERIC count; status-string dicts like {"skipped": "disabled"} (Plex
off — the default) are no-ops, not changes, so they don't flood the trail every interval."""
if result is None:
return False
if isinstance(result, dict):
# A phased job (backfill's recent/deep, people_enrich's bios/portraits) nests a result per
# phase: a count inside a phase is a change too.
return any(
_run_changed(v) if isinstance(v, dict) else isinstance(v, (int, float)) and bool(v)
for v in result.values()
)
if isinstance(result, (int, float)):
return result != 0
return bool(result)
def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
"""Write ONE run-summary audit row per job run (never per-item). Manual runs (an admin
clicked "run now" → actor_id set) and errors are always logged; automatic runs only when
they changed something, so routine no-op polls don't spam the trail. Best-effort."""
if actor_id is None and status == "ok" and not _run_changed(result):
return
# The result as data too: the audit log names it in the reader's language, as the inbox
# does; `summary` stays the English line of the row's own summary column.
data = _result_data(result)
try:
audit.record(
db,
actor_id,
AuditAction.JOB_RUN,
target_type="job",
target_id=job_id,
summary=f"{job_id}: {status}" + (f" — {summary}" if summary else ""),
after={"status": status, "summary": summary, **({"result": data} if data else {})},
)
db.commit()
except Exception:
db.rollback()
log.exception("failed to write audit row for job %s", job_id)
def _job(name: str, fn) -> None:
# The one place both run paths converge (manual trigger AND scheduled fire), so claim the run
# HERE, atomically: whichever reaches this first wins, the other skips. This is the real
@@ -180,71 +141,88 @@ def _job(name: str, fn) -> None:
if not _claim_running(name):
log.info("job %s already running — skipping this trigger", name)
return
db = SessionLocal()
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
_record(
name, last_started=datetime.now(timezone.utc).isoformat(), last_error=None, progress=None
) # running already claimed
trigger = RunTrigger.SCHEDULED if actor_id is None else RunTrigger.MANUAL
_record(name, progress=None) # running already claimed
db = SessionLocal()
run_id = None
try:
try:
run_id = jobruns.start(name, trigger, actor_id)
except Exception:
# No row to tell it in: the database is what failed. The log is the record.
log.exception("job %s not started: its run could not be recorded", name)
return
_opened(run_id, name)
with logs.bind(run_id=run_id):
_run(db, name, fn, run_id, actor_id)
finally:
# ALWAYS release the claim, even if the body raised before its own status update — so a
# failure can never wedge the job as permanently "running".
if run_id is not None:
_closed(run_id)
_record(name, running=False, progress=None)
db.close()
def _run(db, name: str, fn, run_id: int, actor_id: int | None) -> None:
"""The run itself, inside its `job_runs` row: the pause-skip, the work, and its outcome — on
the row, and in the triggering admin's inbox for a manual run."""
def sink(current, total, 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:
if is_sync_paused(db):
log.info("job %s skipped (sync paused)", name)
_record(
name,
status="skipped",
last_finished=datetime.now(timezone.utc).isoformat(),
last_result="paused",
result=_PAUSED,
progress=None,
)
if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED)
_audit_run(db, actor_id, name, "skipped", "sync paused", _PAUSED)
return
with progress.bind(sink):
result = fn(db)
summary = _summarize(result)
data = _result_data(result)
log.info("job %s -> %s", name, result)
_record(
name,
status="ok",
last_finished=datetime.now(timezone.utc).isoformat(),
last_result=summary,
result=data,
progress=None,
)
if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary, data)
_audit_run(db, actor_id, name, "ok", summary, result)
paused = is_sync_paused(db)
if not paused:
with progress.bind(sink):
result = fn(db)
except Exception as exc:
db.rollback()
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
err = str(exc) or exc.__class__.__name__
# A failure the job named (progress.JobFailed) rides along as data, as a result does, so the
# client says it in the reader's language; `err` stays the English line.
failure = (
{"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
)
_record(
name,
status="error",
last_finished=datetime.now(timezone.utc).isoformat(),
last_error=err,
result=failure,
progress=None,
)
err, failure = _failure(exc)
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
if actor_id is not None:
_notify_done(db, actor_id, name, "error", err, failure)
_audit_run(db, actor_id, name, "error", err, failure)
finally:
# ALWAYS release the claim, even if the body raised before its own status update — so a
# failure can never wedge the job as permanently "running".
_record(name, running=False)
db.close()
_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]:
"""Close a run that returned: its result on the row, as data and as the English one-liner."""
summary = _summarize(result)
data = _result_data(result)
log.info("job %s -> %s", name, result)
_close(run_id, RunStatus.OK, summary=summary, result=data)
return summary, data
def _failure(exc: Exception) -> tuple[str, dict | None]:
"""A raised run's English error line, and the failure the job named (progress.JobFailed) as
data, as a result is, so the client says it in the reader's language."""
err = str(exc) or exc.__class__.__name__
failure = {"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
return err, failure
# A skipped run's result, in the same shape as a job's own `{"skipped": …}`.
@@ -271,11 +249,13 @@ def _summarize(result) -> str | None:
return str(result)
def scheduler_snapshot() -> dict:
"""What the scheduler is doing, for the admin dashboard. `running_here` is False in
environments where the scheduler is disabled (e.g. local dev shares the prod-ish DB but
runs no scheduler), so the UI can say so while still showing DB-derived queue/quota."""
running_here = _scheduler is not None
def scheduler_snapshot(db) -> dict:
"""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
did it go"; and the same for the ad-hoc runs users start on one target. `running_here` is False where the scheduler runs no interval jobs
(SCHEDULER_ENABLED=false), so the UI can say so while still showing the DB-derived history,
queue and quota."""
running_here = _scheduler is not None and settings.scheduler_enabled
next_runs: dict[str, str | None] = {}
live_intervals: dict[str, int] = {}
if _scheduler is not None:
@@ -287,6 +267,12 @@ def scheduler_snapshot() -> dict:
live_intervals[job.id] = round(iv.total_seconds() / 60)
with _activity_lock:
acts = {k: dict(v) for k, v in _activity.items()}
# Open before the read or after it: a run that started or ended while the rows were read is
# still counted as the open run it was, never as a stray one.
open_runs = _open_run_ids()
recent = jobruns.recent_runs(db, [*JOB_INTERVALS, *AD_HOC_JOBS], RECENT_RUNS)
open_runs |= _open_run_ids()
going_id = max((r for r, j in open_runs.items() if j == CHANNEL_BACKFILL), default=None)
jobs = []
for job_id, default_interval in JOB_INTERVALS.items():
a = acts.get(job_id, {})
@@ -296,16 +282,94 @@ def scheduler_snapshot() -> dict:
"interval_minutes": live_intervals.get(job_id, default_interval),
"next_run": next_runs.get(job_id),
"running": a.get("running", False),
"status": a.get("status"),
"last_started": a.get("last_started"),
"last_finished": a.get("last_finished"),
"last_result": a.get("last_result"),
"result": a.get("result"),
"last_error": a.get("last_error"),
"progress": a.get("progress"),
**_last_runs(recent.get(job_id, []), open_runs),
}
)
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
# Work a user starts on one target: no interval, no "run now" (it needs its target), but its
# runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the
# target's own (sync/runner), so "running" is the runs this process has open: two channels can
# backfill at once, so a run still going counts even when a newer one already ended. That run then
# leads the row, so its channel, its "no outcome yet" and its dot are one run's, never the
# newer run's outcome under the older one's channel.
backfills = recent.get(CHANNEL_BACKFILL, [])
going = db.get(JobRun, going_id) if going_id is not None else None
if going is not None and going.status != RunStatus.RUNNING:
going = None # its outcome is written; only the bookkeeping has not caught up
if going is not None:
backfills = [going, *(r for r in backfills if r.id != going.id)][:RECENT_RUNS]
shown = backfills[0] if backfills else None
ad_hoc = [
{
"id": CHANNEL_BACKFILL,
"running": going is not None,
"target": shown.target if shown else None,
"target_title": (
db.scalar(select(Channel.title).where(Channel.id == shown.target))
if shown is not None and shown.target
else None
),
**_last_runs(backfills, open_runs),
}
]
return {
"running_here": running_here,
"enabled": settings.scheduler_enabled,
"jobs": jobs,
"ad_hoc": ad_hoc,
}
# How many past runs a Scheduler row shows as dots; the full history is the run list.
RECENT_RUNS = 3
def _seen(run, open_runs: dict[int, str]) -> RunStatus:
"""A run's status as the page tells it. A row `running` that no run here has open is one whose
outcome failed to write: the next start's sweep closes it as interrupted, so it reads so now —
not as a run still going beside a row that says nothing runs."""
if run.status == RunStatus.RUNNING and run.id not in open_runs:
return RunStatus.INTERRUPTED
return RunStatus(run.status)
def _last_runs(runs: list, open_runs: dict[int, str]) -> dict:
"""A row's run fields from its newest runs (newest first): how the last one went, and the few
before it for the history dots."""
run = runs[0] if runs else None
status = _seen(run, open_runs) if run is not None else None
# A run still going has no outcome yet: only an ended run says how it went.
ended = run if status is not None and status != RunStatus.RUNNING else None
return {
"status": status if ended else None,
"last_started": iso(run.started_at) if run else None,
# A stray row has no end of its own: it ended, as far as anyone can tell, where it began.
"last_finished": iso(ended.finished_at or ended.started_at) 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.
"result": _outcome_data(ended),
"last_error": ended.error if ended else None,
"last_trigger": run.trigger if run else None,
"last_run_id": run.id if run else None,
"quota_units": ended.quota_units if ended else None,
"recent": [_run_brief(r, open_runs) for r in runs],
}
def _run_brief(run, open_runs: dict[int, str]) -> dict:
return {
"id": run.id,
"status": _seen(run, open_runs),
"trigger": run.trigger,
"started_at": iso(run.started_at),
"finished_at": iso(run.finished_at),
}
def _outcome_data(run) -> dict | None:
if run is None:
return None
return run.failure if run.status == RunStatus.ERROR else run.result
def _rss_job() -> None:
@@ -408,15 +472,16 @@ def _people_enrich_job() -> None:
def _audit_gc_job() -> None:
# The history cleanup: audit-log rows past audit_retention_days and YouTube quota history past
# quota_retention_days (0 = keep forever, each). Pure DB, no quota.
# The history cleanup: audit-log rows past audit_retention_days, YouTube quota history past
# quota_retention_days and finished runs past job_run_retention_days (0 = keep forever, each).
# Pure DB, no quota.
def work(db):
from app import sysconfig
reaped = audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))
quota_reaped = quota.gc(db, sysconfig.get_int(db, "quota_retention_days"))
runs_reaped = jobruns.gc(db, sysconfig.get_int(db, "job_run_retention_days"))
db.commit()
return {"reaped": reaped, "quota_reaped": quota_reaped}
return {"reaped": reaped, "quota_reaped": quota_reaped, "runs_reaped": runs_reaped}
_job("audit_gc", work)
@@ -425,7 +490,6 @@ def _log_gc_job() -> None:
# Prune log_events rows older than log_retention_days — the limit the privacy policy states, so
# this runs hourly rather than daily. Pure DB, no quota.
def work(db):
from app import sysconfig
days = sysconfig.get_int(db, "log_retention_days")
reaped = logsink.gc(db, days)
@@ -485,15 +549,29 @@ def running_job_ids() -> set[str]:
return {jid for jid, a in _activity.items() if a.get("running")}
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job immediately in a background thread, independent of its interval schedule.
The wrapper handles its own DB session, activity tracking and pause-skip, so the live
dashboard reflects the run. Returns "started", "already_running", or raises KeyError for
an unknown job. Refusing a concurrent run keeps a manual trigger from overlapping a
scheduled run (APScheduler enforces max_instances=1 for the scheduled side).
def _as_actor(fn: Callable[[], None], actor_id: int | None) -> Callable[[], None]:
"""`fn` run as `actor_id`'s manual run. The actor is RESET afterwards: the executor's pool
threads are reused and keep their context from task to task, so an actor left set would make
the next scheduled run that thread picks up look like this admin's manual run."""
`actor_id` (the admin who clicked "run now") is stashed in a contextvar inside the new
thread so the run posts a completion notification to that admin's inbox."""
def run() -> None:
token = _manual_actor.set(actor_id)
try:
fn()
finally:
_manual_actor.reset(token)
return run
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job now, independent of its interval schedule: a one-shot job on the same scheduler,
so a manual run shares the scheduled runs' executor and shutdown — no thread of its own. The
wrapper handles its own DB session, run row and pause-skip. Returns "started",
"already_running", or raises KeyError for an unknown job.
`actor_id` (the admin who clicked "run now") is set in a contextvar inside the run's thread, so
the run is recorded as theirs and posts a completion notification to their inbox."""
fn = JOB_FUNCS.get(job_id)
if fn is None:
raise KeyError(job_id)
@@ -502,13 +580,18 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
# _job claims, sees it's taken, and skips — no double run.
if _is_running(job_id):
return "already_running"
sched = _scheduler
if sched is None:
# Only between shutdown and exit: start_scheduler runs at every API startup.
raise RuntimeError("the scheduler is not running")
def run() -> None:
if actor_id is not None:
_manual_actor.set(actor_id)
fn()
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
try:
# One id per job: a second click before the first one-shot has even started is the same
# run, not a queued second one. misfire_grace_time=None: a busy pool delays it, never drops it.
sched.add_job(_as_actor(fn, actor_id), "date", id=f"{job_id}@now", misfire_grace_time=None)
# silent-ok: not a failure — the answer is the 409 the admin sees ("already running").
except ConflictingIdError:
return "already_running"
return "started"
@@ -521,22 +604,95 @@ def trigger_all(actor_id: int | None = None) -> list[str]:
return started
def start_scheduler() -> None:
global _scheduler
if not settings.scheduler_enabled or _scheduler is not None:
return
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
db = SessionLocal()
# Work a user starts on one target outside the job list — today the "get full history" backfill of
# one channel. It runs on the same scheduler as a recorded run (trigger `ad_hoc`), so what it did and
# spent is in `job_runs` like any job's, not a background task only the log ever heard of.
CHANNEL_BACKFILL = "channel_backfill"
# Every ad-hoc job id: the page names each one (a frontend contract test reads this tuple).
AD_HOC_JOBS = (CHANNEL_BACKFILL,)
def run_ad_hoc(job: str, fn: Callable[[], dict], *, actor_id: int, target: str) -> None:
"""Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its
own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so
a second request before the first has even started is that same run."""
if job not in AD_HOC_JOBS:
raise KeyError(job) # an unnamed job would show on the page as its bare id
sched = _scheduler
if sched is None:
# Only between shutdown and exit: start_scheduler runs at every API startup.
raise RuntimeError("the scheduler is not running")
try:
iv = load_intervals(db)
sched.add_job(
_ad_hoc,
"date",
args=(job, fn, actor_id, target),
id=f"{job}@{target}",
misfire_grace_time=None,
)
# silent-ok: not a failure — the run for this target is already queued, and it does this work.
except ConflictingIdError:
log.info("ad-hoc %s for %s already queued", job, target)
def _ad_hoc(job: str, fn: Callable[[], dict], actor_id: int, target: str) -> None:
try:
run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target)
except Exception:
# No row to tell it in: the database is what failed. The log is the record.
log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target)
return
_opened(run_id, job)
try:
_ad_hoc_work(job, fn, run_id)
finally:
db.close()
scheduler = BackgroundScheduler(timezone="UTC")
for job_id, fn in JOB_FUNCS.items():
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
_closed(run_id)
def _ad_hoc_work(job: str, fn: Callable[[], dict], run_id: int) -> None:
with logs.bind(run_id=run_id):
# As in _run: only the work is inside the try.
try:
result = fn()
except Exception as exc:
log.exception("job %s failed", job, extra=logs.event("scheduler.job.failed", job=job))
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
# 10 would queue a scheduled fire behind long manual runs.
_POOL_SIZE = len(JOB_FUNCS) + 8
def start_scheduler() -> None:
"""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
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
if _scheduler is not None:
return
left = jobruns.interrupt_leftovers()
if left:
log.warning("%s run(s) left running by the previous process marked interrupted", left)
scheduler = BackgroundScheduler(
timezone="UTC", executors={"default": ThreadPoolExecutor(_POOL_SIZE)}
)
if settings.scheduler_enabled:
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
db = SessionLocal()
try:
iv = load_intervals(db)
finally:
db.close()
for job_id, fn in JOB_FUNCS.items():
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
scheduler.start()
_scheduler = scheduler
log.info("scheduler started")
log.info("scheduler started (interval jobs %s)", "on" if settings.scheduler_enabled else "off")
def shutdown_scheduler() -> None:
+22 -21
View File
@@ -437,7 +437,7 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
# Channels being deep-backfilled RIGHT NOW by EITHER path — the interactive backfill_channel_now (a
# FastAPI BackgroundTasks threadpool thread) OR the scheduler's run_deep_backfill (its own thread).
# one-shot run on the scheduler's pool, see scheduler.run_ad_hoc) OR the scheduler's run_deep_backfill.
# BOTH register the channel they're paging (under the lock) so the other skips it: no two runs page
# the same channel's `backfill_cursor` concurrently, whichever started first. `_immediate_running`
# counts only the interactive path, capped so one user's burst can't drain the shared budget (the
@@ -448,9 +448,9 @@ _backfill_lock = threading.Lock()
_MAX_CONCURRENT_IMMEDIATE = 2
def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 400) -> None:
"""User-initiated "get full history": deep-backfill ONE channel to completion right now, in a
background task with its OWN session, so the full catalogue lands in seconds instead of over the
def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 400) -> dict:
"""User-initiated "get full history": deep-backfill ONE channel to completion right now, as its
own recorded run with its OWN session, so the full catalogue lands in seconds instead of over the
paced scheduler's 10-minute ticks (max_pages=5/channel/tick). max_pages is high enough to finish
any realistic channel in a single run (400 pages = 20 000 videos; e.g. BBC Earth's ~3400 = 69
pages — the old 60-page cap stopped one page short of large channels, so their tail dribbled in
@@ -459,34 +459,35 @@ def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 400) ->
Quota-guarded, capped to _MAX_CONCURRENT_IMMEDIATE interactive runs so one user can't drain the
shared backfill budget by opting many channels in at once (the overflow, and any channel too large
to finish under the reserve, is finished by the scheduler since deep_requested stays set), and
mutually excluded per-channel with the scheduler via _backfill_in_progress. Best-effort — logs and
swallows failures."""
mutually excluded per-channel with the scheduler via _backfill_in_progress.
Returns what the run did, as a job result: the counts, or `{"skipped": <why>}` when it left the
channel to the scheduler. A failure raises — the run that wraps this records it."""
global _immediate_running
with _backfill_lock:
if channel_id in _backfill_in_progress or _immediate_running >= _MAX_CONCURRENT_IMMEDIATE:
return # already backfilling this channel, or too many interactive runs — leave it to the scheduler
if channel_id in _backfill_in_progress:
return {"skipped": "channel busy"}
if _immediate_running >= _MAX_CONCURRENT_IMMEDIATE:
return {"skipped": "at capacity"}
_backfill_in_progress.add(channel_id)
_immediate_running += 1
try:
with SessionLocal() as db:
user = get_service_user(db)
if user is None:
return
return {"skipped": "no service user"}
channel = db.get(Channel, channel_id)
if channel is None or channel.backfill_done or not channel.uploads_playlist_id:
return
return {"skipped": "nothing to do"}
if quota.backfill_reserve_reached():
return
try:
# Attribute the spend to the requesting user under the full-backfill action, so it
# shows up correctly in the usage breakdown (not the default OTHER/system bucket).
with quota.attribute(user_id, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
with YouTubeClient(db, user) as yt:
backfill_channel_deep(db, yt, channel, max_pages)
reconcile_full_history(db)
except Exception:
db.rollback()
log.exception("Immediate backfill failed for channel %s", channel_id)
return {"skipped": "low quota"}
# Attribute the spend to the requesting user under the full-backfill action, so it
# shows up correctly in the usage breakdown (not the default OTHER/system bucket).
with quota.attribute(user_id, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
with YouTubeClient(db, user) as yt:
added = backfill_channel_deep(db, yt, channel, max_pages)
reconciled = reconcile_full_history(db)
return {"videos_added": added, "reconciled_full": reconciled}
finally:
with _backfill_lock:
_backfill_in_progress.discard(channel_id)
+251 -61
View File
@@ -8,7 +8,8 @@ stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secr
stored in the DB and stay env-only).
"""
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Literal
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -27,6 +28,16 @@ _decrypt_warned: set[str] = set()
_coerce_warned: set[tuple[str, str]] = set()
# 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 (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
# "restart" key is refused until the page can (test-held).
Applies = Literal["live", "restart"]
LIVE: Applies = "live"
@dataclass(frozen=True)
class ConfigSpec:
key: str
@@ -37,6 +48,8 @@ class ConfigSpec:
min: int | None = None
max: int | None = None
allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset"
# Required, with no default: each key states it, so a new one cannot be live by omission.
applies: Applies = field(kw_only=True)
@property
def choices(self) -> tuple[str, ...]:
@@ -45,30 +58,78 @@ class ConfigSpec:
return choices_of(Settings.model_fields[self.default_attr].annotation)
# Each interval job of the scheduler and the `settings` attribute holding its default period
# (minutes). Its override is the `job_interval_<job>` key, set from the Scheduler page.
JOB_INTERVAL_DEFAULTS: dict[str, str] = {
"rss_poll": "rss_poll_minutes",
"enrich": "enrich_interval_minutes",
"backfill": "backfill_interval_minutes",
"autotag": "autotag_interval_minutes",
"shorts": "shorts_probe_interval_minutes",
"subscriptions": "subscriptions_resync_minutes",
"playlist_sync": "playlist_sync_minutes",
"maintenance": "maintenance_interval_minutes",
"explore_cleanup": "explore_cleanup_minutes",
"download_gc": "download_gc_minutes",
"plex_sync": "plex_sync_interval_min",
"plex_watch_sync": "plex_watch_sync_interval_min",
"plex_watch_reconcile": "plex_watch_reconcile_interval_min",
"tmdb_enrich": "tmdb_enrich_interval_min",
"people_enrich": "people_enrich_interval_min",
"audit_gc": "audit_gc_minutes",
"log_gc": "log_gc_minutes",
}
# Sane bounds for an admin-set interval (minutes).
MIN_INTERVAL = 1
MAX_INTERVAL = 1440 # one day
def interval_key(job: str) -> str:
return f"job_interval_{job}"
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
# off env into the admin UI; the admin page renders it automatically from this list. A group's keys
# stay together, in the order its section lists them; a NEW group also needs its place in SECTIONS.
SPECS: tuple[ConfigSpec, ...] = (
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
ConfigSpec("allow_registration", "bool", "access", "allow_registration", applies=LIVE),
# --- Email / SMTP (one logical group; the password is the only secret) ---
ConfigSpec("smtp_host", "str", "email", "smtp_host"),
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True),
ConfigSpec("smtp_from", "str", "email", "smtp_from"),
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
ConfigSpec("smtp_host", "str", "email", "smtp_host", applies=LIVE),
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535, applies=LIVE),
ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True, applies=LIVE),
ConfigSpec("smtp_from", "str", "email", "smtp_from", applies=LIVE),
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True, applies=LIVE),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True, applies=LIVE),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
ConfigSpec(
"youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True, applies=LIVE
),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
# --- Quota (shared YouTube Data API daily budget) ---
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True, applies=LIVE),
ConfigSpec(
"backfill_quota_reserve", "int", "quota", "backfill_quota_reserve", min=0, max=1_000_000
"google_client_secret", "str", "google", "google_client_secret", secret=True, applies=LIVE
),
# --- Quota (shared YouTube Data API daily budget) ---
ConfigSpec(
"quota_daily_budget",
"int",
"quota",
"quota_daily_budget",
min=1,
max=1_000_000,
applies=LIVE,
),
ConfigSpec(
"backfill_quota_reserve",
"int",
"quota",
"backfill_quota_reserve",
min=0,
max=1_000_000,
applies=LIVE,
),
ConfigSpec(
"search_daily_limit_per_user",
@@ -77,10 +138,19 @@ SPECS: tuple[ConfigSpec, ...] = (
"search_daily_limit_per_user",
min=0,
max=100_000,
applies=LIVE,
),
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
ConfigSpec("search_source", "choice", "quota", "search_source"),
ConfigSpec("quota_retention_days", "int", "quota", "quota_retention_days", min=0, max=3650),
ConfigSpec("search_source", "choice", "quota", "search_source", applies=LIVE),
ConfigSpec(
"quota_retention_days",
"int",
"quota",
"quota_retention_days",
min=0,
max=3650,
applies=LIVE,
),
# --- Backfill (recent-first first pass per channel) ---
ConfigSpec(
"backfill_recent_max_videos",
@@ -89,19 +159,46 @@ SPECS: tuple[ConfigSpec, ...] = (
"backfill_recent_max_videos",
min=1,
max=100_000,
applies=LIVE,
),
ConfigSpec(
"backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500
"backfill_recent_max_days",
"int",
"backfill",
"backfill_recent_max_days",
min=1,
max=36_500,
applies=LIVE,
),
ConfigSpec(
"rss_fallback_hours", "int", "backfill", "rss_fallback_hours", min=0, max=168, applies=LIVE
),
ConfigSpec("rss_fallback_hours", "int", "backfill", "rss_fallback_hours", min=0, max=168),
# --- Shorts probe ---
ConfigSpec(
"shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600
"shorts_probe_max_seconds",
"int",
"shorts",
"shorts_probe_max_seconds",
min=1,
max=3_600,
applies=LIVE,
),
ConfigSpec(
"shorts_probe_batch", "int", "shorts", "shorts_probe_batch", min=1, max=10_000, applies=LIVE
),
ConfigSpec("shorts_probe_batch", "int", "shorts", "shorts_probe_batch", min=1, max=10_000),
# --- Batch sizes ---
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
ConfigSpec(
"enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50, applies=LIVE
),
ConfigSpec(
"autotag_title_sample",
"int",
"batch",
"autotag_title_sample",
min=1,
max=1_000,
applies=LIVE,
),
# --- Maintenance (rolling re-validation of already-known videos) ---
# Videos re-checked per maintenance run. Folded here from app_state (B-core-11) so every admin
# knob lives in one store; read by sync/maintenance.py, tuned from the Scheduler dashboard.
@@ -112,16 +209,33 @@ SPECS: tuple[ConfigSpec, ...] = (
"maintenance_revalidate_batch",
min=100,
max=100_000,
applies=LIVE,
),
# --- Discovery cleanup (search + explore ephemeral content) ---
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
# Days an un-kept live-search result video is kept before the cleanup job removes it.
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0),
ConfigSpec(
"download_default_max_bytes", "int", "downloads", "download_default_max_bytes", min=0
"explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650, applies=LIVE
),
# Days an un-kept live-search result video is kept before the cleanup job removes it.
ConfigSpec(
"search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650, applies=LIVE
),
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
ConfigSpec(
"download_total_max_bytes",
"int",
"downloads",
"download_total_max_bytes",
min=0,
applies=LIVE,
),
ConfigSpec(
"download_default_max_bytes",
"int",
"downloads",
"download_default_max_bytes",
min=0,
applies=LIVE,
),
ConfigSpec(
"download_default_max_concurrent",
@@ -130,6 +244,7 @@ SPECS: tuple[ConfigSpec, ...] = (
"download_default_max_concurrent",
min=1,
max=20,
applies=LIVE,
),
ConfigSpec(
"download_default_max_jobs",
@@ -138,6 +253,7 @@ SPECS: tuple[ConfigSpec, ...] = (
"download_default_max_jobs",
min=1,
max=100_000,
applies=LIVE,
),
# min=1, NOT 0: a stored 0 used to read as "no per-file cap" (the cap was enforced as
# `if cap and size > cap`, so a falsy value disabled it). Now the route passes this number
@@ -161,14 +277,27 @@ SPECS: tuple[ConfigSpec, ...] = (
"download_upload_max_bytes",
min=1,
max=settings.download_upload_max_bytes,
applies=LIVE,
),
ConfigSpec(
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
"download_retention_days",
"int",
"downloads",
"download_retention_days",
min=0,
max=3_650,
applies=LIVE,
),
ConfigSpec(
"download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650
"download_gc_grace_days",
"int",
"downloads",
"download_gc_grace_days",
min=0,
max=3_650,
applies=LIVE,
),
ConfigSpec("download_layout", "choice", "downloads", "download_layout"),
ConfigSpec("download_layout", "choice", "downloads", "download_layout", applies=LIVE),
ConfigSpec(
"download_worker_concurrency",
"int",
@@ -176,60 +305,112 @@ SPECS: tuple[ConfigSpec, ...] = (
"download_worker_concurrency",
min=1,
max=16,
applies=LIVE,
),
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
# --- Audit log ---
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
ConfigSpec(
"audit_retention_days",
"int",
"audit",
"audit_retention_days",
min=0,
max=3650,
applies=LIVE,
),
# Days the job_runs table keeps a finished background run before the audit_gc job prunes it.
ConfigSpec(
"job_run_retention_days",
"int",
"audit",
"job_run_retention_days",
min=0,
max=3650,
applies=LIVE,
),
# --- Diagnostics ---
# Runtime log level for the API process. Applied without a restart — see routes/config.py and
# main.py's lifespan.
ConfigSpec("log_level", "choice", "diagnostics", "log_level"),
ConfigSpec("log_level", "choice", "diagnostics", "log_level", applies=LIVE),
# Days the log_events table keeps a row (the log_gc job prunes). No "0 = forever": stored logs
# carry account-linked request history, and the privacy policy names a limit.
ConfigSpec("log_retention_days", "int", "diagnostics", "log_retention_days", min=1, max=90),
ConfigSpec(
"log_retention_days",
"int",
"diagnostics",
"log_retention_days",
min=1,
max=90,
applies=LIVE,
),
# --- Plex integration (optional; local-file playback + Plex metadata) ---
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True),
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map"),
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64),
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled", applies=LIVE),
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url", applies=LIVE),
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True, applies=LIVE),
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map", applies=LIVE),
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries", applies=LIVE),
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map", applies=LIVE),
ConfigSpec(
"plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16, applies=LIVE
),
ConfigSpec(
"plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64, applies=LIVE
),
# Transcode re-encode tuning. The libx264 knobs apply only to the software fallback.
# crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"),
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw", applies=LIVE),
ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset", applies=LIVE),
ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
"plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51, applies=LIVE
),
ConfigSpec(
"plex_transcode_max_height",
"int",
"plex",
"plex_transcode_max_height",
min=0,
max=4320,
applies=LIVE,
),
# Audio loudness normalisation on the HLS path (remux + transcode alike, not direct play).
ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm"),
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm", applies=LIVE),
# --- TMDB enrichment (optional) — the API key is the only secret ---
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True),
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"),
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True, applies=LIVE),
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language", applies=LIVE),
# --- Bug reports: the Gitea issue tracker a debug session's issue goes to ---
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"),
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"),
ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True),
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url", applies=LIVE),
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo", applies=LIVE),
ConfigSpec(
"issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True, applies=LIVE
),
# --- Scheduler: how often each interval job runs (minutes), one key per job ---
*(
ConfigSpec(
interval_key(job),
"int",
"scheduler",
attr,
min=MIN_INTERVAL,
max=MAX_INTERVAL,
applies=LIVE,
)
for job, attr in JOB_INTERVAL_DEFAULTS.items()
),
)
# The admin page's sections (the owner's pick): sixteen groups as tabs wrapped to three rows at 133%
# zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in
# exactly one section (test-held), so a new group cannot fall off the page.
# exactly one section, or is edited on its own page (test-held), so a new group cannot fall off.
SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
("access", ("access", "google", "email")),
("youtube", ("youtube", "quota", "backfill", "shorts", "batch", "explore")),
("media", ("downloads", "plex", "tmdb")),
("system", ("maintenance", "audit", "diagnostics", "issues")),
)
# Groups another admin page edits, so the Configuration page neither lists nor writes them: an
# interval is changed on the Scheduler page, which also reschedules the running job — a write that
# skipped it would leave the job on its old period while the page showed the new one.
OWN_PAGE_GROUPS: tuple[str, ...] = ("scheduler",)
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
@@ -238,6 +419,12 @@ def spec(key: str) -> ConfigSpec | None:
return _BY_KEY.get(key)
def config_page_spec(key: str) -> ConfigSpec | None:
"""`spec`, for the Configuration page: None for a key edited on its own page as well."""
s = _BY_KEY.get(key)
return None if s is None or s.group in OWN_PAGE_GROUPS else s
def secrets_manageable() -> bool:
"""Secret values can only be stored when Fernet is configured (TOKEN_ENCRYPTION_KEY)."""
return bool(settings.token_encryption_key)
@@ -426,13 +613,16 @@ def reset(db: Session, key: str) -> None:
def describe(db: Session) -> dict:
"""Admin-UI metadata + current values, grouped. Secret values are never echoed —
only whether an override is set and whether an env default exists."""
"""Admin-UI metadata + current values, grouped — the keys of the Configuration page, not those
edited on their own page. Secret values are never echoed — only whether an override is set and
whether an env default exists."""
groups: dict[str, list[dict]] = {}
# Every override in one query: a db.get per key was one round trip each (two for a key with no
# row, which the session cannot cache), ~40 ms of the page's ~55 on prod.
rows = {r.key: r for r in db.scalars(select(SystemConfig))}
for s in SPECS:
if s.group in OWN_PAGE_GROUPS:
continue
row = rows.get(s.key)
is_set = row is not None
item: dict = {
+205
View File
@@ -0,0 +1,205 @@
"""The activity page's reads: the audit log and the background runs, filtered on the server and
paged by a keyset, and the quota breakdown over a time range and one account.
Each list is asked for the rows a filter must keep AND must drop, and a page boundary is crossed
with rows that share a timestamp — the case an offset or a timestamp-only cursor gets wrong.
"""
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 tests.test_body_limit import _route_objects
T0 = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
def _audit(db, action, actor=None, at=T0, summary=None, request_id=None) -> int:
row = AuditLog(
actor_id=actor, action=action, summary=summary, created_at=at, request_id=request_id
)
db.add(row)
db.flush()
return row.id
def _run(db, job, status="ok", actor=None, at=T0, target=None, trigger="scheduled") -> int:
row = JobRun(
job=job,
trigger=trigger,
actor_id=actor,
target=target,
status=status,
started_at=at,
finished_at=None if status == "running" else at + timedelta(seconds=4),
quota_units=0,
)
db.add(row)
db.flush()
return row.id
def _ids(body) -> list[int]:
return [r["id"] for r in body["items"]]
def test_the_audit_list_filters_on_the_server_and_pages_across_a_shared_timestamp(
db, make_user, as_user
):
admin = make_user(role="admin")
a = _audit(db, "config_set", admin.id, T0, "quota_daily_budget = 9000")
b = _audit(db, "config_set", admin.id, T0, "log_level = DEBUG")
c = _audit(db, "config_set", admin.id, T0, "smtp_host = mail")
bg = _audit(db, "user_purged", None, T0 - timedelta(days=3))
db.commit()
client = as_user(admin)
first = client.get("/api/admin/audit", params={"limit": 2, "action": "config_set"}).json()
assert _ids(first) == [c, b]
second = client.get(
"/api/admin/audit",
params={"limit": 2, "action": "config_set", "before": first["next_cursor"]},
).json()
assert (_ids(second), second["next_cursor"]) == ([a], None)
# A page that ends exactly at the last row says there is nothing older.
whole = client.get("/api/admin/audit", params={"limit": 3, "action": "config_set"}).json()
assert (_ids(whole), whole["next_cursor"]) == ([c, b, a], None)
assert first["accounts"] == {str(admin.id): admin.email}
def ids(**params) -> list[int]:
return _ids(client.get("/api/admin/audit", params=params).json())
assert ids(user="none") == [bg]
assert ids(user=str(admin.id)) == [c, b, a]
assert ids(q="DEBUG") == [b]
assert ids(since=(T0 - timedelta(days=1)).isoformat()) == [c, b, a]
assert ids(until=(T0 - timedelta(days=1)).isoformat()) == [bg]
# The action menu counts every action under the other filters, its own pick included.
facets = client.get("/api/admin/audit/facets", params={"action": "config_set"}).json()
assert facets["actions"] == [
{"value": "config_set", "count": 3},
{"value": "user_purged", "count": 1},
]
def test_an_admin_action_keeps_the_request_it_was_made_in(db, make_user, as_user):
client = as_user(make_user(role="admin"))
r = client.patch("/api/admin/config/quota_daily_budget", json={"value": 9500})
assert r.status_code == 200, r.text
(row,) = client.get("/api/admin/audit", params={"action": "config_set"}).json()["items"]
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):
admin = make_user(role="admin")
db.add(Channel(id="UCchan", title="Aileen Wang"))
ok1 = _run(db, "rss_poll", at=T0)
ok2 = _run(db, "rss_poll", at=T0)
bad = _run(db, "backfill", "error", admin.id, T0, trigger="manual")
adhoc = _run(
db, "channel_backfill", "ok", admin.id, T0 - timedelta(hours=1), "UCchan", "ad_hoc"
)
for _ in range(2):
db.add(
LogEvent(ts=T0, level="WARNING", sev=30, source="api", logger="t", msg="m", run_id=bad)
)
db.commit()
client = as_user(admin)
first = client.get("/api/admin/runs", params={"limit": 2}).json()
rest = client.get("/api/admin/runs", params={"limit": 2, "before": first["next_cursor"]}).json()
assert _ids(first) + _ids(rest) == [bad, ok2, ok1, adhoc]
assert rest["next_cursor"] is None
rows = {r["id"]: r for r in first["items"] + rest["items"]}
assert (rows[bad]["log_lines"], rows[ok2]["log_lines"]) == (2, 0)
assert (rows[adhoc]["target_title"], rows[ok1]["target_title"]) == ("Aileen Wang", None)
def ids(**params) -> list[int]:
return _ids(client.get("/api/admin/runs", params=params).json())
assert ids(status="error") == [bad]
assert ids(job="rss_poll") == [ok2, ok1]
assert ids(user="none") == [ok2, ok1]
assert ids(user=str(admin.id)) == [bad, adhoc]
assert ids(q="UCchan") == [adhoc]
assert ids(since=(T0 - timedelta(minutes=30)).isoformat()) == [bad, ok2, ok1]
assert client.get("/api/admin/runs", params={"status": "finished"}).status_code == 422
facets = client.get("/api/admin/runs/facets", params={"status": "error"}).json()
assert facets["statuses"] == [{"value": "ok", "count": 3}, {"value": "error", "count": 1}]
assert facets["jobs"] == [{"value": "backfill", "count": 1}]
assert facets["total"] == 4
def test_the_quota_breakdown_covers_its_range_and_one_account(db, make_user, as_user):
admin = make_user(role="admin")
now = datetime.now(timezone.utc)
db.add_all(
[
QuotaEvent(user_id=admin.id, action="search", units=100, created_at=now),
QuotaEvent(user_id=None, action="backfill", units=7, created_at=now),
QuotaEvent(
user_id=admin.id, action="search", units=50, created_at=now - timedelta(days=10)
),
]
)
db.commit()
client = as_user(admin)
def totals(**params) -> dict:
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"]}
assert totals() == {admin.id: 150, None: 7} # the last 30 days by default
assert totals(since=(now - timedelta(days=1)).isoformat()) == {admin.id: 100, None: 7}
assert totals(until=(now - timedelta(days=1)).isoformat()) == {admin.id: 50}
assert totals(user="none") == {None: 7}
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_ == []
+10 -1
View File
@@ -24,6 +24,7 @@ def _row(**kw) -> dict:
"request_id": None,
"user_id": None,
"job_id": None,
"run_id": None,
"version": "0.90.0",
}
row.update(kw)
@@ -80,6 +81,7 @@ def test_newest_first_and_every_row_once_across_pages_even_on_equal_timestamps(d
({"event": "none"}, {"warn"}),
({"request_id": "req-abcdef12"}, {"mine"}),
({"job_id": 42}, {"err"}),
({"run_id": 7}, {"warn"}), # a background run's lines, not a download job's
({"q": "FORBIDDEN"}, {"err"}), # in attrs, any case
({"q": "KeyError"}, {"warn"}), # in the stack
({"q": "100%"}, {"plain"}), # a literal, not a wildcard
@@ -102,7 +104,14 @@ def test_each_filter_selects_exactly_its_rows(db, admin, make_user, params, expe
job_id=42,
attrs={"code": "forbidden"},
),
_row(msg="warn", level="WARNING", sev=13, event=None, exc="Traceback …\nKeyError: 'x'"),
_row(
msg="warn",
level="WARNING",
sev=13,
event=None,
exc="Traceback …\nKeyError: 'x'",
run_id=7,
),
_row(msg="plain", ts=T0 - timedelta(hours=2), attrs={"note": "100% done"}),
# Matched by no filter above but `until`: another account, and `100%` read as a pattern.
_row(msg="decoy", ts=T0 - timedelta(hours=3), attrs={"note": "1000 done"}, user_id=99),
+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
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).
"""
+64 -7
View File
@@ -107,25 +107,48 @@ def _setup_channel_and_sub(db, *, email):
def _patch_deep_requested(db, monkeypatch, override_dep, user):
submitted: list[tuple] = []
calls: list[tuple] = []
# background.add_task runs after the response in TestClient; record what would be scheduled.
# Record the run the route submits, and what its work would call, instead of running it.
monkeypatch.setattr(
channels_route.scheduler,
"run_ad_hoc",
lambda job, fn, **kw: submitted.append((job, kw)) or fn(),
)
monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
return resp, calls
return resp, submitted, calls
def test_deep_request_schedules_immediate_backfill(db, monkeypatch, override_dep):
def test_deep_request_starts_the_backfill_as_the_users_run_of_that_channel(
db, monkeypatch, override_dep
):
"""It was a BackgroundTask: what it did, spent or failed with reached only the log."""
ch, me = _setup_channel_and_sub(db, email="wire@example.com")
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
resp, submitted, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
assert resp.status_code == 200, resp.text
assert resp.json()["deep_requested"] is True
# scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
assert submitted == [("channel_backfill", {"actor_id": me.id, "target": ch.id})]
# its work runs exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
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:
"""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
@@ -148,15 +171,49 @@ class TestImmediateBackfillGuard:
def test_a_second_call_for_an_in_flight_channel_is_a_noop(self, monkeypatch):
runner._backfill_in_progress.add("UC_busy")
monkeypatch.setattr(runner, "SessionLocal", self._boom_session)
runner.backfill_channel_now("UC_busy", 1) # returns immediately; _boom_session never runs
# returns at once — _boom_session never runs — and the run says why it did nothing.
assert runner.backfill_channel_now("UC_busy", 1) == {"skipped": "channel busy"}
assert runner._backfill_in_progress == {"UC_busy"} # the in-flight entry is untouched
def test_over_the_concurrency_cap_falls_back_to_the_scheduler(self, monkeypatch):
runner._immediate_running = runner._MAX_CONCURRENT_IMMEDIATE
monkeypatch.setattr(runner, "SessionLocal", self._boom_session)
runner.backfill_channel_now("UC_new", 1) # over the cap → no-op, scheduler will handle it
# over the cap → no-op, the scheduler will handle it
assert runner.backfill_channel_now("UC_new", 1) == {"skipped": "at capacity"}
assert "UC_new" not in runner._backfill_in_progress
@staticmethod
def _one_channel(db, monkeypatch, cid):
ch = Channel(id=cid, title=cid, uploads_playlist_id="UU" + cid, backfill_done=False)
me = User(email=f"{cid}@example.com")
db.add_all([ch, me])
db.commit()
monkeypatch.setattr(runner, "get_service_user", lambda _db: me)
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: False)
monkeypatch.setattr(runner, "YouTubeClient", lambda *a, **k: _NullYt())
return ch, me
def test_a_failure_reaches_its_run_and_still_frees_the_channel(self, db, monkeypatch):
"""The failure was logged and swallowed, so the run that now wraps it would read "ok"."""
ch, me = self._one_channel(db, monkeypatch, "UC_fail")
def boom(*_a):
raise RuntimeError("playlistItems 500")
monkeypatch.setattr(runner, "backfill_channel_deep", boom)
with pytest.raises(RuntimeError, match="playlistItems 500"):
runner.backfill_channel_now(ch.id, me.id)
assert runner._backfill_in_progress == set() and runner._immediate_running == 0
def test_a_finished_backfill_says_what_it_added(self, db, monkeypatch):
ch, me = self._one_channel(db, monkeypatch, "UC_ok")
monkeypatch.setattr(runner, "backfill_channel_deep", lambda *_a: 7)
monkeypatch.setattr(runner, "reconcile_full_history", lambda _db: 1)
assert runner.backfill_channel_now(ch.id, me.id) == {
"videos_added": 7,
"reconciled_full": 1,
}
def test_scheduler_deep_pass_skips_an_in_flight_channel(self, db, monkeypatch):
# A channel the scheduler's query WOULD pick (deep-requested, recent-synced, not done)…
ch = Channel(
+165
View File
@@ -0,0 +1,165 @@
"""Migration 0109's data step, DB-lane: the audit log's run rows move into `job_runs` with their
outcome intact, and leave the audit log; an admin action stays where it was. The downgrade moves
them back."""
import json
from datetime import timedelta
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def _audit(conn, actor, action, target, after):
conn.execute(
text(
"INSERT INTO audit_log (actor_id, action, target_type, target_id, summary, after,"
" created_at) VALUES (:a, :act, 'job', :t, 's', CAST(:after AS json),"
" CAST('2026-10-01T03:00:00+00:00' AS timestamptz))"
),
{"a": actor, "act": action, "t": target, "after": json.dumps(after)},
)
def test_the_audit_logs_runs_move_into_the_run_table_and_back(db, make_user):
uid = make_user().id
cfg = Config("alembic.ini")
command.downgrade(cfg, "0108_folder_links")
try:
with engine.begin() as conn:
_audit(
conn,
None,
"job_run",
"rss_poll",
{"status": "ok", "summary": "new=3", "result": {"new": 3}},
)
_audit(
conn,
uid,
"job_run",
"rss_poll",
{"status": "error", "summary": "boom", "result": {"failure": "rss_all_failed"}},
)
_audit(
conn, uid, "job_run", "backfill", {"status": "skipped", "summary": "sync paused"}
)
_audit(conn, uid, "config_set", None, {"key": "x"})
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
runs = conn.execute(
text(
"SELECT job, trigger, actor_id, status, summary, result, error, failure,"
" to_char(started_at AT TIME ZONE 'UTC', 'MM-DD HH24'), finished_at = started_at"
" FROM job_runs ORDER BY id"
)
).all()
left = conn.execute(text("SELECT action FROM audit_log")).scalars().all()
assert [tuple(r) for r in runs] == [
("rss_poll", "scheduled", None, "ok", "new=3", {"new": 3}, None, None, "10-01 03", True),
(
"rss_poll",
"manual",
uid,
"error",
None,
None,
"boom",
{"failure": "rss_all_failed"},
"10-01 03",
True,
),
("backfill", "manual", uid, "skipped", "sync paused", None, None, None, "10-01 03", True),
]
assert left == ["config_set"]
command.downgrade(cfg, "0108_folder_links")
try:
with engine.connect() as conn:
back = conn.execute(
text(
"SELECT actor_id, target_id, after::jsonb FROM audit_log"
" WHERE action = 'job_run' ORDER BY id"
)
).all()
finally:
command.upgrade(cfg, "head")
# What the audit row has no column for rides along, so the next upgrade restores it.
def run(trigger: str) -> dict:
return {"trigger": trigger, "quota_units": 0, "started_at": "2026-10-01T03:00:00+00:00"}
assert [tuple(r) for r in back] == [
(
None,
"rss_poll",
{"status": "ok", "summary": "new=3", "result": {"new": 3}, **run("scheduled")},
),
(
uid,
"rss_poll",
{
"status": "error",
"summary": "boom",
"result": {"failure": "rss_all_failed"},
**run("manual"),
},
),
(uid, "backfill", {"status": "skipped", "summary": "sync paused", **run("manual")}),
]
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, quota_units) VALUES"
" ('backfill', 'scheduled', NULL, NULL, 'interrupted', now(), now(), 0),"
" ('maintenance', 'manual', :u, NULL, 'running', now(), NULL, 0),"
" ('channel_backfill', 'ad_hoc', :u, 'UC_x', 'ok', now(), now(), 0),"
# Its admin is gone (SET NULL): only the row itself still says it was manual.
" ('enrich', 'manual', NULL, NULL, 'ok',"
" '2026-10-01 10:00+00', '2026-10-01 10:30+00', 42)"
),
{"u": uid},
)
# What the run caught mid-way had spent: only its quota rows know it yet.
conn.execute(
text(
"INSERT INTO quota_events (action, units, job_run_id)"
" SELECT 'videos.list', 7, id FROM job_runs WHERE job = 'maintenance'"
)
)
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()
kept = conn.execute(
text("SELECT quota_units, finished_at - started_at FROM job_runs WHERE job = 'enrich'")
).one()
stopped = conn.execute(
text("SELECT quota_units FROM job_runs WHERE job = 'maintenance'")
).scalar()
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"),
("enrich", "manual", None, None, "ok"),
]
assert tuple(kept) == (42, timedelta(minutes=30))
assert stopped == 7
assert stranded == 0
+56
View File
@@ -0,0 +1,56 @@
"""Migration 0110's data step, DB-lane: an admin's interval override moves from `scheduler_settings`
into sysconfig and back, a row for a job that no longer exists is not carried over, and the unread
SponsorBlock default leaves the store.
The schema half is checked by test_migrations.py; a value lost on the way is invisible there.
"""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app import scheduler
from app.db import engine
def _config(conn) -> dict[str, str]:
return dict(conn.execute(text("SELECT key, value FROM system_config")).tuples().all())
def test_an_interval_override_moves_into_sysconfig_and_back(db):
cfg = Config("alembic.ini")
command.downgrade(cfg, "0109_job_runs")
try:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO scheduler_settings (job_id, interval_minutes)"
" VALUES ('rss_poll', 25), ('log_gc', 90), ('retired_job', 5)"
)
)
conn.execute(
text(
"INSERT INTO system_config (key, value, encrypted) VALUES"
" ('sponsorblock_default', 'true', false), ('quota_daily_budget', '5000', false)"
)
)
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
assert _config(conn) == {
"job_interval_rss_poll": "25",
"job_interval_log_gc": "90",
"quota_daily_budget": "5000",
}
assert conn.execute(text("SELECT to_regclass('scheduler_settings')")).scalar() is None
assert scheduler.load_intervals(db)["rss_poll"] == 25
command.downgrade(cfg, "0109_job_runs")
try:
with engine.connect() as conn:
back = conn.execute(text("SELECT job_id, interval_minutes FROM scheduler_settings"))
assert dict(back.tuples().all()) == {"rss_poll": 25, "log_gc": 90}
assert _config(conn) == {"quota_daily_budget": "5000"}
finally:
command.upgrade(cfg, "head")
-7
View File
@@ -9,7 +9,6 @@ from datetime import datetime, timezone
import httpx
import pytest
from app import scheduler
from app.models import PlexItem, PlexLibrary, PlexPerson, User
from app.plex import imgcache
from app.plex import people as plex_people
@@ -340,12 +339,6 @@ def test_the_people_job_runs_its_halves_without_a_tmdb_key(db, cache_dir, plex_o
}
def test_a_phase_count_is_a_change_worth_an_audit_row():
assert scheduler._run_changed({"bios": {"enriched": 3}, "portraits": {"cached": 0}})
assert scheduler._run_changed({"recent": {"videos_added": 2}, "deep": {}})
assert not scheduler._run_changed({"bios": {"skipped": "x"}, "portraits": {"cached": 0}})
def test_the_person_page_says_a_bio_is_on_its_way(db, monkeypatch, override_dep):
from fastapi.testclient import TestClient
+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}
+534 -47
View File
@@ -1,11 +1,21 @@
"""_claim_running is the single-run guard that both the scheduled fire and the manual trigger go
"""The scheduler's run path, DB-lane: the single-run guard, and every run as a durable `job_runs` row
— its outcome as data, what it spent, the lines it logged — that outlives the process.
_claim_running is the single-run guard that both the scheduled fire and the manual trigger go
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
import logging
import time
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app import scheduler
from app.models import Notification
from app import jobruns, logs, logsink, quota, scheduler, state, sysconfig
from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent
from app.routes import scheduler as scheduler_routes
from app.runstatus import RunStatus, RunTrigger
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
@@ -31,26 +41,38 @@ def test_claim_is_exclusive_until_released():
# ---- the result as data --------------------------------------------------------------------------
def _snapshot_job(job_id):
return next(j for j in scheduler.scheduler_snapshot()["jobs"] if j["id"] == job_id)
def _snapshot_job(db, job_id):
return next(j for j in scheduler.scheduler_snapshot(db)["jobs"] if j["id"] == job_id)
def _runs(db, job=None) -> list[JobRun]:
db.expire_all()
q = select(JobRun).order_by(JobRun.id)
if job is not None:
q = q.where(JobRun.job == job)
return list(db.execute(q).scalars())
def _manual(actor_id, name, fn):
token = scheduler._manual_actor.set(actor_id)
try:
scheduler._job(name, fn)
finally:
scheduler._manual_actor.reset(token)
def test_a_run_keeps_its_result_as_data_for_the_readers_language(db, make_user):
"""The dashboard and the manual-run notice showed `_summarize`'s English one-liner ("new=3,
failed=0", a Python repr for a nested phase). The result rides along as data, so the client names
it; the one-liner stays as the fallback and the audit text."""
it; the one-liner stays as the fallback."""
admin = make_user("sched@example.com", role="admin")
token = scheduler._manual_actor.set(admin.id)
try:
scheduler._job("rss_poll", lambda _db: {"new": 3, "failed": 0, "fallback": 1})
scheduler._job("enrich", lambda _db: 5) # enrich returns a bare count
finally:
scheduler._manual_actor.reset(token)
_manual(admin.id, "rss_poll", lambda _db: {"new": 3, "failed": 0, "fallback": 1})
_manual(admin.id, "enrich", lambda _db: 5) # enrich returns a bare count
rss = _snapshot_job("rss_poll")
rss = _snapshot_job(db, "rss_poll")
assert rss["result"] == {"new": 3, "failed": 0, "fallback": 1}
assert rss["last_result"] == "new=3, failed=0, fallback=1"
assert _snapshot_job("enrich")["result"] == {"enriched": 5}
assert _snapshot_job(db, "enrich")["result"] == {"enriched": 5}
notices = db.execute(
select(Notification).where(Notification.user_id == admin.id).order_by(Notification.id)
@@ -64,53 +86,518 @@ def test_a_run_keeps_its_result_as_data_for_the_readers_language(db, make_user):
def test_a_failed_run_drops_the_previous_result(db):
scheduler._job("rss_poll", lambda _db: {"new": 1})
scheduler._job("rss_poll", lambda _db: (_ for _ in ()).throw(RuntimeError("boom")))
rss = _snapshot_job("rss_poll")
rss = _snapshot_job(db, "rss_poll")
assert rss["status"] == "error"
assert rss["last_error"] == "boom"
assert rss["result"] is None
def test_a_job_runs_audit_row_carries_its_result_as_data(db):
"""The audit log names a run's result in the reader's language, as the inbox does —
from data, not from the English one-liner (a nested phase there was a Python dict repr)."""
from app.models import AuditLog, User
admin = User(email="auditrun@example.com")
db.add(admin)
db.commit()
scheduler._audit_run(db, None, "people_enrich", "ok", "x", {"bios": {"enriched": 2}})
scheduler._audit_run(db, admin.id, "rss_poll", "skipped", "sync paused", scheduler._PAUSED)
scheduler._audit_run(db, admin.id, "rss_poll", "error", "boom", None)
rows = db.execute(select(AuditLog).order_by(AuditLog.id)).scalars().all()
assert [r.after for r in rows] == [
{"status": "ok", "summary": "x", "result": {"bios": {"enriched": 2}}},
{"status": "skipped", "summary": "sync paused", "result": {"skipped": "sync paused"}},
{"status": "error", "summary": "boom"},
]
def test_a_failure_the_job_names_rides_along_as_data(db, make_user):
"""A failed RSS poll reached the Hungarian audit log as the server's English sentence. A
failure the job names (`progress.JobFailed`) is stored as data beside that sentence — on the
dashboard, the manual-run notice and the audit row — so the client says it in the reader's
language. Any other failure keeps only its own text (the test above)."""
"""A failed RSS poll reached the Hungarian UI as the server's English sentence. A failure the job
names (`progress.JobFailed`) is stored as data beside that sentence — on the run's row, the
dashboard and the manual-run notice — so the client says it in the reader's language."""
from app import progress
from app.models import AuditLog
def outage(_db):
raise progress.JobFailed("rss_all_failed", "RSS poll failed", total=2, cause="transport")
admin = make_user("failure@example.com", role="admin")
token = scheduler._manual_actor.set(admin.id)
try:
scheduler._job("rss_poll", outage)
finally:
scheduler._manual_actor.reset(token)
_manual(admin.id, "rss_poll", outage)
named = {"failure": "rss_all_failed", "total": 2, "cause": "transport"}
rss = _snapshot_job("rss_poll")
rss = _snapshot_job(db, "rss_poll")
assert rss["last_error"] == "RSS poll failed"
assert rss["result"] == named
notice = db.execute(select(Notification).where(Notification.user_id == admin.id)).scalar_one()
assert notice.data["result"] == named
row = db.execute(select(AuditLog).order_by(AuditLog.id.desc())).scalars().first()
assert row is not None and row.after["result"] == named
(run,) = _runs(db, "rss_poll")
assert (run.status, run.error, run.failure) == ("error", "RSS poll failed", named)
# ---- the durable row -----------------------------------------------------------------------------
def test_every_run_is_a_row_with_who_started_it_and_none_in_the_audit_log(db, make_user):
"""A no-op scheduled poll was not written anywhere (the audit rule kept only changes), so "did
it run at 03:00?" had no answer. Every run is a row now; the audit log keeps admin actions only."""
admin = make_user("runs@example.com", role="admin")
scheduler._job("rss_poll", lambda _db: {"new": 0})
_manual(admin.id, "rss_poll", lambda _db: {"new": 2})
scheduled, manual = _runs(db, "rss_poll")
assert (scheduled.trigger, scheduled.actor_id, scheduled.status) == ("scheduled", None, "ok")
assert scheduled.result == {"new": 0}
assert (manual.trigger, manual.actor_id, manual.summary) == ("manual", admin.id, "new=2")
assert manual.finished_at is not None and manual.finished_at >= manual.started_at
assert db.execute(select(AuditLog)).scalars().all() == []
def test_the_last_run_survives_a_restart(db):
"""The dashboard's "last run" lived in the process's memory: a deploy blanked every job."""
scheduler._job("plex_sync", lambda _db: {"items": 4})
with scheduler._activity_lock:
scheduler._activity.clear() # what a new process starts with
plex = _snapshot_job(db, "plex_sync")
assert (plex["status"], plex["result"], plex["last_trigger"]) == (
"ok",
{"items": 4},
"scheduled",
)
assert plex["last_finished"] is not None
def test_a_row_shows_its_last_three_runs_newest_first_an_interrupted_one_included(db):
"""The row said only how the LAST run went; an interrupted run read as "no run recorded"."""
oldest = jobruns.start("shorts", RunTrigger.SCHEDULED, None)
jobruns.interrupt_leftovers()
scheduler._job("shorts", lambda _db: {"probed": 1})
scheduler._job("shorts", lambda _db: {"probed": 2})
scheduler._job("shorts", lambda _db: 1 / 0)
scheduler._job("rss_poll", lambda _db: {"new": 0}) # another job's run stays on its own row
recent = _snapshot_job(db, "shorts")["recent"]
assert [r["status"] for r in recent] == ["error", "ok", "ok"]
assert oldest not in [r["id"] for r in recent] # the fourth-newest is not shown
assert recent[0]["trigger"] == "scheduled" and recent[0]["finished_at"] is not None
assert [r["status"] for r in _snapshot_job(db, "rss_poll")["recent"]] == ["ok"]
jobruns.start("shorts", RunTrigger.SCHEDULED, None)
jobruns.interrupt_leftovers()
shorts = _snapshot_job(db, "shorts")
assert shorts["status"] == "interrupted"
assert [r["status"] for r in shorts["recent"]] == ["interrupted", "error", "ok"]
def test_a_paused_sync_is_a_skipped_run(db):
state.set_sync_paused(db, True)
db.commit()
scheduler._job("rss_poll", lambda _db: pytest.fail("a paused run must not do its work"))
(run,) = _runs(db, "rss_poll")
assert (run.status, run.result) == ("skipped", {"skipped": "sync paused"})
def test_a_run_left_running_by_a_stopped_process_is_interrupted_and_stays_so(db):
"""A deploy mid-run left nothing behind before; now the row would say `running` forever. The
next startup closes it, and the stale thread's own finish cannot rewrite it."""
run_id = jobruns.start("backfill", RunTrigger.SCHEDULED, None)
assert jobruns.interrupt_leftovers() == 1
(run,) = _runs(db, "backfill")
assert run.status == "interrupted" and run.finished_at is not None
jobruns.finish(run_id, RunStatus.OK, summary="late")
(run,) = _runs(db, "backfill")
assert (run.status, run.summary) == ("interrupted", None)
def test_a_run_knows_what_it_spent_and_a_request_spend_is_nobodys_run(db):
def spends(_db):
quota.record_usage(5)
quota.record_usage(3)
return {"enriched": 2}
scheduler._job("enrich", spends)
quota.record_usage(11) # a request's own spend, outside any run
(run,) = _runs(db, "enrich")
assert run.quota_units == 8
events = db.execute(select(QuotaEvent.units, QuotaEvent.job_run_id)).all()
assert sorted(events) == [(3, run.id), (5, run.id), (11, None)]
assert _snapshot_job(db, "enrich")["quota_units"] == 8
class _Collect(logging.Handler):
def __init__(self):
super().__init__()
self.addFilter(logs.ContextFilter())
self.records: list[logging.LogRecord] = []
def emit(self, record):
self.records.append(record)
def test_a_runs_log_lines_carry_its_id(db):
"""So a run's row can open exactly the lines it logged (the log table's `run_id`)."""
probe = logging.getLogger("siftlode.test.run")
handler = _Collect()
probe.addHandler(handler)
probe.setLevel(logging.INFO)
try:
scheduler._job("autotag", lambda _db: probe.info("tagging") or {"tagged": 1})
probe.info("after the run")
finally:
probe.removeHandler(handler)
(run,) = _runs(db, "autotag")
inside, after = (logs.record_fields(r).get("run_id") for r in handler.records)
assert (inside, after) == (run.id, None)
# …and it reaches the stored row, whose columns are the model's.
assert logsink.to_row(logs.record_fields(handler.records[0]))["run_id"] == run.id
def test_retention_reaps_finished_runs_only(db):
old = datetime.now(timezone.utc) - timedelta(days=40)
db.add_all(
[
JobRun(
job="rss_poll",
trigger="scheduled",
status="ok",
started_at=old,
finished_at=old,
),
JobRun(job="backfill", trigger="scheduled", status="running", started_at=old),
JobRun(
job="rss_poll",
trigger="scheduled",
status="ok",
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
),
]
)
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, 30) == 1
db.commit()
assert sorted((r.job, r.status) for r in _runs(db)) == [
("backfill", "running"),
("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 --------------------------------------------------------------
@pytest.fixture
def live_scheduler(db, monkeypatch):
"""This process's scheduler, started as the API starts it (on the migrated schema `db` brings),
with interval jobs off."""
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", False)
scheduler.start_scheduler()
try:
yield scheduler._scheduler
finally:
scheduler.shutdown_scheduler()
def _wait_finished(db, job, timeout=10.0) -> JobRun:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
runs = [r for r in _runs(db, job) if r.status != "running"]
if runs:
return runs[-1]
time.sleep(0.05)
raise AssertionError(f"{job} did not finish")
def test_run_now_goes_through_the_scheduler_even_with_interval_jobs_off(
db, make_user, live_scheduler, monkeypatch
):
admin = make_user("now@example.com", role="admin")
monkeypatch.setitem(
scheduler.JOB_FUNCS, "shorts", lambda: scheduler._job("shorts", lambda _db: {"probed": 3})
)
assert live_scheduler.get_jobs() == [] # SCHEDULER_ENABLED=false: no interval job
live_scheduler.pause() # hold the one-shot so the second click finds it pending
assert scheduler.trigger_job("shorts", admin.id) == "started"
assert scheduler.trigger_job("shorts", admin.id) == "already_running"
assert [j.id for j in live_scheduler.get_jobs()] == ["shorts@now"]
live_scheduler.resume()
run = _wait_finished(db, "shorts")
assert (run.trigger, run.actor_id, run.result) == ("manual", admin.id, {"probed": 3})
def test_an_ad_hoc_run_is_the_users_run_of_its_target_with_its_spend_and_failure(
db, make_user, live_scheduler
):
"""The one-channel deep backfill ran as a BackgroundTask nothing recorded: its spend counted as
nobody's run and a failure lived only in the log."""
user = make_user("adhoc@example.com")
def work():
quota.record_usage(3)
return {"videos_added": 50}
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, work, actor_id=user.id, target="UC_a")
run = _wait_finished(db, "channel_backfill")
assert (run.trigger, run.actor_id, run.target) == ("ad_hoc", user.id, "UC_a")
assert (run.status, run.result, run.quota_units) == ("ok", {"videos_added": 50}, 3)
def fail():
raise RuntimeError("quota exceeded")
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, fail, actor_id=user.id, target="UC_b")
deadline = time.monotonic() + 10
while time.monotonic() < deadline and len(_finished(db, "channel_backfill")) < 2:
time.sleep(0.05)
failed = next(r for r in _runs(db, "channel_backfill") if r.target == "UC_b")
assert (failed.status, failed.error) == ("error", "quota exceeded")
def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, make_user):
user = make_user("shown@example.com")
db.add(Channel(id="UC_shown", title="BBC Earth"))
db.commit()
scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_none")
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 69}, user.id, "UC_shown")
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"]
assert (row["id"], row["running"], row["target"], row["target_title"]) == (
"channel_backfill",
False,
"UC_shown",
"BBC Earth",
)
assert (row["status"], row["result"], row["last_trigger"]) == (
"ok",
{"videos_added": 69},
"ad_hoc",
)
assert len(row["recent"]) == 2
assert all(j["id"] != "channel_backfill" for j in scheduler.scheduler_snapshot(db)["jobs"])
seen = []
scheduler._ad_hoc(
"channel_backfill",
lambda: seen.append(scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"]) or {},
user.id,
"UC_shown",
)
assert seen == [True]
assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is False
def _finished(db, job) -> list[JobRun]:
return [r for r in _runs(db, job) if r.status != "running"]
def test_an_ad_hoc_job_outside_the_named_list_is_refused(live_scheduler):
"""The page names every ad-hoc job from AD_HOC_JOBS; one outside it would show as a bare id."""
with pytest.raises(KeyError):
scheduler.run_ad_hoc("unnamed_job", dict, actor_id=1, target="x")
assert live_scheduler.get_jobs() == []
def test_a_second_ad_hoc_request_before_the_first_starts_is_that_same_run(
db, make_user, live_scheduler
):
user = make_user("twice@example.com")
live_scheduler.pause() # hold the one-shots so the second request finds the first pending
for target in ("UC_x", "UC_x", "UC_y"):
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, dict, actor_id=user.id, target=target)
assert sorted(j.id for j in live_scheduler.get_jobs()) == [
"channel_backfill@UC_x",
"channel_backfill@UC_y",
]
live_scheduler.remove_all_jobs() # never run: a run finishing after the test would land in the next
def test_a_manual_run_leaves_no_actor_behind_on_its_thread(db, make_user):
"""The scheduler's pool threads are reused: an actor a manual run left set made the next
scheduled run on that thread an admin's "manual" run, with a notice in their inbox."""
admin = make_user("pool@example.com", role="admin")
seen = []
scheduler._as_actor(lambda: seen.append(scheduler._manual_actor.get()), admin.id)()
assert seen == [admin.id]
assert scheduler._manual_actor.get() is None # this same thread's next task runs as nobody
def test_run_now_refuses_a_job_already_running(live_scheduler):
assert scheduler._claim_running("maintenance") is True
try:
assert scheduler.trigger_job("maintenance") == "already_running"
finally:
scheduler._record("maintenance", running=False)
def test_startup_closes_what_the_previous_process_left_running(db, monkeypatch):
jobruns.start("plex_sync", RunTrigger.MANUAL, None)
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", False)
scheduler.start_scheduler()
scheduler.shutdown_scheduler()
(run,) = _runs(db, "plex_sync")
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
# Nothing here runs it: the page reads it as the sweep will close it, not as a run going on.
row = _row(db, "autotag")
assert (row["running"], row["status"], row["recent"][0]["status"]) == (
False,
"interrupted",
"interrupted",
)
assert row["last_finished"] is not None
user = make_user("blip@example.com")
calls.clear()
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_blip")
assert calls == ["ok"]
# Its row is still `running`, but nothing here is: the page does not call it going.
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"]
assert (row["running"], row["status"], row["recent"][0]["status"]) == (
False,
"interrupted",
"interrupted",
)
def test_a_run_going_on_reads_as_running_on_its_row(db):
seen = []
scheduler._job("autotag", lambda _db: seen.append(_row(db, "autotag")) or {"tagged": 1})
(row,) = seen
assert (row["running"], row["status"], row["recent"][0]["status"]) == (True, None, "running")
assert _row(db, "autotag")["recent"][0]["status"] == "ok"
def _row(db, job_id: str) -> dict:
return next(j for j in scheduler.scheduler_snapshot(db)["jobs"] if j["id"] == job_id)
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()
rows = []
def long_one():
scheduler._ad_hoc(
"channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_short"
)
rows.extend(scheduler.scheduler_snapshot(db)["ad_hoc"])
return {}
scheduler._ad_hoc("channel_backfill", long_one, user.id, "UC_long")
(row,) = rows
assert (row["running"], row["target"], row["target_title"]) == (True, "UC_long", "Long one")
# The outcome beside it is the long one's (none yet), never the short one's skip.
assert (row["status"], row["result"], row["last_error"]) == (None, None, None)
assert [r["status"] for r in row["recent"]] == ["running", "ok"]
def test_an_interval_change_lands_with_its_audit_row_or_not_at_all(
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():
assert set(scheduler.JOB_FUNCS) == set(scheduler.JOB_INTERVALS)
def test_a_stored_interval_is_what_the_job_starts_with(db, monkeypatch):
sysconfig.set_value(db, "job_interval_rss_poll", 25)
db.commit()
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", True)
scheduler.start_scheduler()
try:
sched = scheduler._scheduler
assert sched is not None
assert sched.get_job("rss_poll").trigger.interval == timedelta(minutes=25)
default = scheduler.JOB_INTERVALS["enrich"]
assert sched.get_job("enrich").trigger.interval == timedelta(minutes=default)
finally:
scheduler.shutdown_scheduler()
def test_an_interval_set_on_the_scheduler_page_is_stored_and_reschedules_the_job(
db, make_user, as_user, live_scheduler
):
live_scheduler.add_job(lambda: None, "interval", minutes=10, id="backfill")
admin = as_user(make_user(role="admin"))
r = admin.patch("/api/admin/scheduler/jobs/backfill", json={"interval_minutes": 45})
assert r.status_code == 200, r.text
assert live_scheduler.get_job("backfill").trigger.interval == timedelta(minutes=45)
db.expire_all()
assert sysconfig.get_int(db, "job_interval_backfill") == 45
assert scheduler.load_intervals(db)["backfill"] == 45
# The Configuration page neither lists an interval nor writes one past the reschedule.
assert (
admin.patch("/api/admin/config/job_interval_backfill", json={"value": 5}).status_code == 404
)
assert admin.delete("/api/admin/config/job_interval_backfill").status_code == 404
assert "scheduler" not in admin.get("/api/admin/config").json()["groups"]
db.expire_all()
assert sysconfig.get_int(db, "job_interval_backfill") == 45
def test_the_setup_wizard_does_not_write_an_interval(db):
from fastapi import HTTPException
from app.routes.setup import setup_config
with pytest.raises(HTTPException) as refused:
setup_config({"job_interval_rss_poll": 5}, db)
assert refused.value.status_code == 400
assert setup_config({"quota_daily_budget": 5000}, db)["saved"] == ["quota_daily_budget"]
+81 -1
View File
@@ -15,10 +15,14 @@ boot, sysconfig holds a stored override to it, and the admin page renders a pick
The DB lane (`db` fixture) covers the store; the rest is pure logic.
"""
import ast
from pathlib import Path
import pytest
from pydantic import ValidationError
from app import sysconfig
from app.models import SystemConfig
from app.config import Settings, choices_of
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
@@ -37,7 +41,8 @@ def test_each_group_is_one_run_of_keys():
def test_every_group_sits_in_exactly_one_section():
listed = [g for _, members in sysconfig.SECTIONS for g in members]
assert len(listed) == len(set(listed)), listed
assert set(listed) == {s.group for s in sysconfig.SPECS}
assert set(listed) | set(sysconfig.OWN_PAGE_GROUPS) == {s.group for s in sysconfig.SPECS}
assert not set(listed) & set(sysconfig.OWN_PAGE_GROUPS)
assert [sid for sid, _ in sysconfig.SECTIONS] == ["access", "youtube", "media", "system"]
@@ -112,3 +117,78 @@ def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
with pytest.raises(ValueError, match="scrape, api"):
sysconfig.set_value(db, "search_source", "bing")
db.rollback()
# --- when a changed value takes effect --------------------------------------------------------------
_APP = Path(__file__).resolve().parent.parent / "app"
_READERS = {"get", "get_str", "get_int", "get_bool"}
def _key_reads() -> list[tuple[str, str, bool]]:
"""Every `sysconfig.get*(db, "<key>")` in the app outside the registry: (key, where, inside a
function)."""
found: list[tuple[str, str, bool]] = []
def walk(node: ast.AST, path: Path, in_fn: bool) -> None:
for child in ast.iter_child_nodes(node):
fn = in_fn or isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda))
if (
isinstance(child, ast.Call)
and isinstance(child.func, ast.Attribute)
and isinstance(child.func.value, ast.Name)
and child.func.value.id == "sysconfig"
and child.func.attr in _READERS
and len(child.args) >= 2
and isinstance(child.args[1], ast.Constant)
and isinstance(child.args[1].value, str)
):
found.append((child.args[1].value, f"{path.name}:{child.lineno}", in_fn))
walk(child, path, fn)
for path in _APP.rglob("*.py"):
if path.name != "sysconfig.py":
walk(ast.parse(path.read_text(encoding="utf-8")), path, False)
return found
def test_every_key_states_when_it_applies_and_none_needs_a_restart_yet():
with pytest.raises(TypeError):
sysconfig.ConfigSpec("k", "int", "quota", "quota_daily_budget") # pyright: ignore[reportCallIssue]
# The Configuration page cannot tell the admin that a setting waits for a restart; a key that
# does needs that first (a badge on it and a banner once it changed).
assert {s.applies for s in sysconfig.SPECS} == {"live"}
def test_a_live_key_is_read_where_it_is_used_never_at_import():
reads = _key_reads()
assert len(reads) > 50, len(
reads
) # the scan sees the reads (it would pass vacuously otherwise)
assert [(k, at) for k, at, in_fn in reads if not in_fn] == []
def test_every_key_on_the_configuration_page_has_a_reader():
read = {k for k, _, _ in _key_reads()}
page = {s.key for s in sysconfig.SPECS if s.group not in sysconfig.OWN_PAGE_GROUPS}
assert sorted(page - read) == []
def test_the_intervals_are_keys_of_the_scheduler_page_only():
keys = {s.key for s in sysconfig.SPECS if s.group == "scheduler"}
assert keys == {sysconfig.interval_key(j) for j in sysconfig.JOB_INTERVAL_DEFAULTS}
assert all(len(k) <= 64 for k in keys) # system_config.key
assert sysconfig.config_page_spec("job_interval_rss_poll") is None
assert sysconfig.config_page_spec("quota_daily_budget") is not None
assert "scheduler" not in sysconfig.describe(_FakeDb())["groups"]
def test_saving_the_value_in_force_still_stores_the_override(db, make_user, as_user):
client = as_user(make_user(role="admin"))
default = sysconfig.get_int(db, "quota_daily_budget")
r = client.patch("/api/admin/config/quota_daily_budget", json={"value": default})
assert r.status_code == 200, r.text
(item,) = [i for i in r.json()["groups"]["quota"] if i["key"] == "quota_daily_budget"]
assert item["is_set"] is True
db.expire_all()
assert db.get(SystemConfig, "quota_daily_budget") is not None
+130 -129
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
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]
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below`
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller. The band
holds controls only: an intro paragraph scrolls with the content (in the band it 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
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
@@ -649,7 +648,7 @@ of a full-width panel and nothing on the right.
`app/collation.py` on the server, so both sort alike.
- **Hungarian words, not English ones** (E30 D3). Kept as Hungarian users say them: Feed, Shorts, Admin,
Token, Port, and technical tokens (log levels, ffmpeg presets, remux). Everything else is Hungarian:
közvetítés (stream), Prioritás, Feladat (job), Eseménynapló (audit), Demó, Automatikus, Infó,
közvetítés (stream), Prioritás, Feladat (job), Napló (activity), Admin műveletek (audit), Demó, Automatikus, Infó,
Felület / Szerver (frontend / backend), videók behúzása (backfill), átkódolás (transcode), e-mail.
A longer Hungarian word gets its room — a column is sized to it, a pill truncates — never clipped.
- A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
@@ -668,127 +667,129 @@ of a full-width panel and nothing on the right.
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
+4 -5
View File
@@ -13,11 +13,10 @@ import { gotoPage, openApp, openFirstVideo } from "./helpers";
// Chromium only (playwright.config.ts leaves it out of the other projects).
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
/** Wait until nothing is loading: no QueryState spinner and no skeleton bone left. */
/** Wait until nothing is loading: no QueryState spinner and no skeleton bone left. A bone says it is
* one (`data-skeleton`): a pulse alone is not loading — a running job's dot pulses as long as it runs. */
async function settled(page: Page): Promise<void> {
await expect(
page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'),
).toHaveCount(0);
await expect(page.locator('[role="status"] .animate-spin, [data-skeleton]')).toHaveCount(0);
}
async function violations(page: Page): Promise<string[]> {
@@ -31,7 +30,7 @@ async function violations(page: Page): Promise<string[]> {
test.describe("accessibility (axe)", () => {
const RAIL = ["feed", "channels", "playlists", "files", "stats", "settings"];
const ADMIN = ["scheduler", "config", "users", "audit"];
const ADMIN = ["scheduler", "config", "users", "activity"];
for (const id of RAIL) {
test(`page: ${id}`, async ({ page }) => {
+1 -1
View File
@@ -11,7 +11,7 @@ test.describe("page switching", () => {
// and it failed on every run for a while without anyone seeing it, because e2e was in no
// required gate (it is now part of `release`).
const RAIL = ["channels", "playlists", "files", "stats", "settings", "feed"];
const ADMIN = ["scheduler", "config", "users", "audit"];
const ADMIN = ["scheduler", "config", "users", "activity"];
test("each rail item activates its page", async ({ page }) => {
await openApp(page);
@@ -0,0 +1,446 @@
// @vitest-environment jsdom
//
// The activity page around its run and audit lists (the server-log tab has its own file): the view
// is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens
// to its log lines and an admin action to its request's, each a new history entry that Back undoes;
// the account filter reaches the quota tab; a
// failed read is an error with a retry, never an empty list.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPage<RunRow>>>();
const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>();
const logsMock = vi.fn<(q: LogQuery, before: 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) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before),
adminRunFacets: () => runFacetsMock(),
adminAudit: (q: AuditQuery, before: string | null) => auditMock(q, before),
adminAuditFacets: () => Promise.resolve({ actions: [] }),
adminLogs: (q: LogQuery, before: string | null) => logsMock(q, before),
adminLogFacets: () =>
Promise.resolve({ levels: [], sources: [], events: [], users: [], accounts: {}, total: 0 }),
adminUsers: () => usersMock(),
adminQuota: (since: string, user: string | null) => quotaMock(since, user),
clearAudit: () => Promise.resolve({ ok: true, cleared: 1 }),
},
};
});
import { qk } from "../lib/queryKeys";
import Activity from "./Activity";
import { ChoiceContext } from "./confirmContext";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
const slot = document.body.appendChild(document.createElement("div"));
const RUN: RunRow = {
id: 41,
job: "backfill",
trigger: "manual",
actor_id: 1,
target: null,
target_title: null,
status: "error",
started_at: "2026-10-18T02:31:04Z",
finished_at: "2026-10-18T02:31:45Z",
summary: null,
result: null,
error: "quotaExceeded",
failure: null,
quota_units: 212,
log_lines: 9,
};
const ACTION: AuditRow = {
id: 5,
created_at: "2026-10-18T02:27:00Z",
actor_id: 1,
action: "scheduler_interval",
target_type: "job",
target_id: "rss_poll",
summary: "rss_poll → 25 min/run",
before: null,
after: { interval_minutes: 25 },
request_id: "0efdd960c9ad1977",
log_lines: 4,
};
const page = <T,>(items: T[]): HistoryPage<T> => ({
items,
accounts: { "1": "admin@example.com" },
next_cursor: null,
});
function renderPage(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
return render(
<QueryClientProvider client={qc}>
{/* Every confirmation is answered yes. */}
<ChoiceContext.Provider value={() => Promise.resolve("confirm")}>
<PageToolbarSlot.Provider value={slot}>
<Activity />
</PageToolbarSlot.Provider>
</ChoiceContext.Provider>
</QueryClientProvider>,
);
}
const url = () => new URLSearchParams(window.location.search);
const rowButton = (text: RegExp) =>
screen
.getAllByRole("button")
.find((b) => b.hasAttribute("aria-expanded") && text.test(b.textContent ?? ""))!;
beforeEach(() => {
runsMock.mockResolvedValue(page([RUN]));
auditMock.mockResolvedValue(page([ACTION]));
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
quotaMock.mockResolvedValue({ rows: [], daily: [] });
runFacetsMock.mockResolvedValue({ jobs: [], statuses: [], total: 0 });
usersMock.mockResolvedValue([{ id: 1, email: "admin@example.com" }]);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("Activity", () => {
it("a link narrows the runs to one job, and a status chip narrows them further", async () => {
window.history.replaceState(null, "", "/?page=activity&rjob=backfill");
renderPage();
await waitFor(() =>
expect(runsMock).toHaveBeenCalledWith(
expect.objectContaining({ job: "backfill", status: undefined, since: expect.any(String) }),
null,
),
);
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
await waitFor(() =>
expect(runsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ job: "backfill", status: "error" }),
null,
),
);
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 () => {
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota");
renderPage();
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
fireEvent.click(rowButton(/quotaExceeded/));
fireEvent.click(screen.getByRole("button", { name: "activity.runs.logLines" }));
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ run_id: 41, q: undefined }),
null,
),
);
// Every other filter is off, and the range is the log's whole retention.
expect([url().get("at"), url().get("lrun"), url().get("aw"), url().get("aq")]).toEqual([
"logs",
"41",
"90d",
null,
]);
await act(async () => {
const landed = new Promise((resolve) =>
window.addEventListener("popstate", resolve, { once: true }),
);
window.history.back();
await landed;
});
await waitFor(() => expect(url().get("at")).toBeNull());
expect([url().get("aw"), url().get("aq"), url().get("lrun")]).toEqual(["24h", "quota", null]);
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("accounts that could not be refreshed, still listed, are not called unloaded", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } });
qc.setQueryData(qk.adminUsers(), [{ id: 1, email: "admin@example.com" }]);
usersMock.mockRejectedValue(new Error("502"));
renderPage(qc);
await waitFor(() => expect(usersMock).toHaveBeenCalled());
await waitFor(() => expect(qc.getQueryState(qk.adminUsers())?.status).toBe("error"));
expect(screen.queryByText("activity.accountsFailed")).toBeNull();
});
it("clearing the admin actions reads the list again", async () => {
window.history.replaceState(null, "", "/?page=activity&at=admin");
renderPage();
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("Back/Forward on the server log reads only the filters of the entry it lands on", async () => {
// A browser runs the microtasks between two popstate listeners, so each listener's update
// renders on its own; a test dispatch does not. Each listener runs in its own act here.
const listeners: EventListener[] = [];
const addOther = window.addEventListener.bind(window);
const removeOther = window.removeEventListener.bind(window);
const add = vi.spyOn(window, "addEventListener").mockImplementation((type, l, o) => {
if (type === "popstate") listeners.push(l as EventListener);
else addOther(type, l, o);
});
const remove = vi.spyOn(window, "removeEventListener").mockImplementation((type, l, o) => {
if (type === "popstate") listeners.splice(listeners.indexOf(l as EventListener), 1);
else removeOther(type, l, o);
});
window.history.replaceState(null, "", "/?page=activity&at=logs&aw=24h");
renderPage();
await waitFor(() => expect(logsMock).toHaveBeenCalled());
logsMock.mockClear();
// Forward onto a pivot's entry: one run's lines over the log's whole retention.
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
for (const l of [...listeners]) {
await act(async () => l(new PopStateEvent("popstate")));
}
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
);
const days = (since?: string) => (Date.now() - Date.parse(since ?? "")) / 86_400_000;
// Never one run's lines over a day, nor the whole log over 90 days.
expect(logsMock.mock.calls.map(([q]) => [q.run_id ?? null, days(q.since) > 30])).toEqual(
logsMock.mock.calls.map(() => [41, true]),
);
add.mockRestore();
remove.mockRestore();
});
it("an action whose request's lines are gone says so instead of linking to an empty list", async () => {
auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }]));
window.history.replaceState(null, "", "/?page=activity&at=admin");
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 () => {
window.history.replaceState(null, "", "/?page=activity&at=admin");
renderPage();
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
expect(runsMock).not.toHaveBeenCalled();
fireEvent.click(rowButton(/auditActions\.scheduler_interval/));
fireEvent.click(screen.getByRole("button", { name: "activity.audit.requestLines" }));
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ request_id: "0efdd960c9ad1977" }),
null,
),
);
expect(url().get("lreq")).toBe("0efdd960c9ad1977");
});
it("the account filter reaches the quota tab, over the page's range", async () => {
window.history.replaceState(null, "", "/?page=activity&at=quota&au=none&aw=30d");
renderPage();
await waitFor(() => expect(quotaMock).toHaveBeenCalled());
const [since, user] = quotaMock.mock.calls[0]!;
expect(user).toBe("none");
const days = (Date.now() - new Date(since).getTime()) / 86_400_000;
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
// unknown, and a "1 sec" would be invented.
it("a run whose start and end are one instant has no length", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockResolvedValue(
page([
{ ...RUN, id: 1, status: "ok", error: null, finished_at: RUN.started_at, summary: "moved" },
{ ...RUN, id: 2, status: "ok", error: null, summary: "timed" },
]),
);
renderPage();
await waitFor(() => expect(screen.getByText("moved")).toBeTruthy());
const cells = (text: string) =>
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
expect(cells("moved")).toContain("—");
expect(cells("timed")).not.toContain("—");
});
// A bare "212" in a row reads as nothing in particular: the spend carries its unit, and a run that
// spent none says nothing rather than "0".
it("a run's quota spend reads with its unit, and none spent leaves the cell blank", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockResolvedValue(
page([
{ ...RUN, id: 1, status: "ok", error: null, summary: "spent" },
{ ...RUN, id: 2, status: "ok", error: null, summary: "free", quota_units: 0 },
]),
);
renderPage();
await waitFor(() => expect(screen.getByText("spent")).toBeTruthy());
const cells = (text: string) =>
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
expect(cells("spent")).toContain("activity.runs.units");
expect(cells("spent")).not.toContain("212");
expect(cells("free")).not.toContain("0");
expect(cells("free")).not.toContain("activity.runs.units");
});
// Most runs store no line at all: a link there would only lead to an empty list.
it("a run that logged nothing says so instead of linking to an empty list", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockResolvedValue(page([{ ...RUN, log_lines: 0 }]));
renderPage();
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
fireEvent.click(rowButton(/quotaExceeded/));
expect(screen.getByText("activity.runs.noLogLines")).toBeTruthy();
expect(screen.queryByRole("button", { name: "activity.runs.logLines" })).toBeNull();
});
it("a failed read is an error with a retry, not an empty list", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockRejectedValue(new Error("502"));
renderPage();
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.queryByText("activity.runs.empty")).toBeNull();
});
});
+214
View File
@@ -0,0 +1,214 @@
import clsx from "clsx";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { api } from "../lib/api";
import {
ACTIVITY_TABS,
ACTIVITY_WINDOWS,
activityParamsToState,
activityParamUpdates,
type ActivityTab,
type ActivityUrlState,
type ActivityWindow,
} from "../lib/activityUrlState";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import {
DEFAULT_LOGS_STATE,
logsParamsToState,
logsParamUpdates,
type LogsUrlState,
} from "../lib/logsUrlState";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { urlWithParams } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import ActivityAudit from "./ActivityAudit";
import ActivityQuota from "./ActivityQuota";
import ActivityRuns from "./ActivityRuns";
import AdminLogs, { type LogPivot } from "./AdminLogs";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
import { PageToolbar } from "./ui/PageShell";
import { StateMessage } from "./ui/QueryState";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
// Activity ("Napló"): what happened, in one place — the background runs, the admins' actions, the
// server's log and the quota spend, each its own table on the server, read here as four tabs under
// one filter bar (time range, account, text). The view lives in the URL, so it is a link; a pivot to
// one run's or one request's log lines is a new history entry, and Back returns to where it was made.
// A pivot shows everything the run or request logged: every other filter would only hide part of it,
// and the longest range is the log's own retention.
const PIVOT_SHARED: Pick<ActivityUrlState, "window" | "user" | "q"> = {
window: "90d",
user: null,
q: "",
};
export default function Activity() {
const { t } = useTranslation();
const [state, setState] = useState<ActivityUrlState>(() =>
activityParamsToState(new URLSearchParams(window.location.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.
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 — a popstate is also a dialog closing,
// onto the same address, and a fresh log would drop its Live and its focus.
const [pivots, setPivots] = useState(0);
// The log's half of the entry Back/Forward landed on, read here with this page's half: one listener,
// so both change in one render. Two listeners rendered in between (a browser runs the microtasks
// after each), and that render read the log with one entry's filters and the other's range.
const [landed, setLanded] = useState<{ logs: LogsUrlState } | null>(null);
const q = useDebounced(state.q, 300, jumps);
const panel = useTabPanel();
// `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id.
useUrlMirror({ page: "activity", ...activityParamUpdates(state) });
useEffect(() => {
const onPop = () => {
const params = new URLSearchParams(window.location.search);
setJumps((n) => n + 1);
setState(activityParamsToState(params));
setLanded({ logs: logsParamsToState(params) });
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const update = (patch: Partial<ActivityUrlState>) => setState((s) => ({ ...s, ...patch }));
const pivot = (target: LogPivot) => {
const next: ActivityUrlState = { ...state, ...PIVOT_SHARED, tab: "logs" };
window.history.pushState(
{ ...rememberNavPosition(), sfScroll: nextNavId() },
"",
urlWithParams({
page: "activity",
...activityParamUpdates(next),
...logsParamUpdates({ ...DEFAULT_LOGS_STATE, ...target }),
}),
);
notifyNav();
setJumps((n) => n + 1);
setPivots((n) => n + 1);
setState(next);
};
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 (
<div {...panel.panelProps(state.tab)} className="px-4 pb-4 pt-3 max-w-6xl w-full mx-auto">
<PageToolbar>
<div className="px-4 pt-2 max-w-6xl w-full mx-auto">
<Tabs
idBase={panel.idBase}
active={state.tab}
onChange={(tab) => update({ tab: tab as ActivityTab })}
tabs={ACTIVITY_TABS.map((id) => ({ id, label: t(`activity.tabs.${id}`) }))}
className="flex flex-wrap gap-1 pb-2"
/>
<SharedBar state={state} onChange={update} />
</div>
</PageToolbar>
{/* Only when the menu really lacks them: a refresh that failed keeps the list it had. */}
{users.isError && !users.data && (
<StateMessage tone="error" compact onRetry={() => void users.refetch()}>
{t("activity.accountsFailed")}
</StateMessage>
)}
{/* The quota tab says what its numbers are itself, beside them. */}
{state.tab !== "quota" && (
<p className="mb-3 text-xs leading-relaxed text-muted">
{t(`activity.intro.${state.tab}`)}
</p>
)}
{state.tab === "runs" ? (
<ActivityRuns
shared={shared}
job={state.job}
status={state.status}
onFilter={update}
onLogs={(runId) => pivot({ requestId: null, jobId: null, runId })}
/>
) : state.tab === "admin" ? (
<ActivityAudit
shared={shared}
action={state.action}
onAction={(action) => update({ action })}
onRequest={(requestId) => pivot({ requestId, jobId: null, runId: null })}
/>
) : state.tab === "logs" ? (
// Keyed by the pivots: a pivot hands the log its filters through the address bar, which it
// reads when it mounts.
<AdminLogs key={pivots} shared={shared} landed={landed} onPivot={pivot} />
) : (
<ActivityQuota shared={shared} />
)}
</div>
);
}
/** The filters every tab shares: the text (not on the quota tab — it has nothing to match), the time
* range, and the account. */
function SharedBar({
state,
onChange,
}: {
state: ActivityUrlState;
onChange: (patch: Partial<ActivityUrlState>) => void;
}) {
const { t } = useTranslation();
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const known = users.data ?? [];
// The account in use stays offered even when the list does not have it (an erased account).
const kept =
state.user && state.user !== "none" && !known.some((u) => String(u.id) === state.user)
? [{ value: state.user, label: `#${state.user}` }]
: [];
return (
<div className="flex flex-wrap items-center gap-2 pb-2">
{state.tab !== "quota" && (
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[12rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<InnerInput
type="search"
value={state.q}
onChange={(e) => onChange({ q: e.target.value.slice(0, 200) })}
placeholder={t("activity.search")}
aria-label={t("activity.search")}
/>
</label>
)}
<FilterChipGroup
ariaLabel={t("activity.windowLabel")}
value={state.window}
onChange={(window) => onChange({ window: window as ActivityWindow })}
options={ACTIVITY_WINDOWS.map((w) => ({ value: w, label: t(`activity.windows.${w}`) }))}
/>
<FilterSelect
label={t("activity.account")}
options={[
{ value: "", label: t("activity.any") },
{ value: "none", label: t("activity.noAccount") },
...known.map((u) => ({ value: String(u.id), label: u.email })),
...kept,
]}
selected={[state.user ?? ""]}
onChange={([user]) => onChange({ user: user || null })}
testId="activity-user"
/>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { api, type AuditQuery, type AuditRow } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { sinceOf } from "../lib/activityUrlState";
import { auditActionLabel, auditText } from "../lib/auditText";
import { formatDateTimeSec } from "../lib/format";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { useHistoryList } from "../lib/useHistoryList";
import type { Shared } from "./ActivityRuns";
import { useConfirm } from "./confirmContext";
import { StateMessage } from "./ui/QueryState";
import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow";
import { RelativeTime } from "./ui/RelativeTime";
import { Button } from "./ui/Button";
import FilterSelect from "./ui/FilterSelect";
// Activity → Admin actions: the append-only who/what/when trail, newest first, filtered on the
// server. A row opens to its before/after and to the lines its request logged. Clear stays here,
// leaving one row that says who cleared it.
const GRID =
"grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(10rem,1fr)]";
export default function ActivityAudit({
shared,
action,
onAction,
onRequest,
}: {
shared: Shared;
action: string | null;
onAction: (action: string | null) => void;
/** Open the server log at this request's lines. */
onRequest: (requestId: string) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const confirm = useConfirm();
const [open, setOpen] = useState<number | null>(null);
const query: AuditQuery = {
user: shared.user ?? undefined,
q: shared.q.trim() || undefined,
action: action ?? undefined,
};
const key = { ...query, window: shared.window };
const state = useHistoryList(qk.adminAudit(key), (before) =>
api.adminAudit({ ...query, since: sinceOf(shared.window) }, before),
);
const facets = useQuery({
queryKey: qk.adminAuditFacets(key),
queryFn: () => api.adminAuditFacets({ ...query, since: sinceOf(shared.window) }),
placeholderData: keepPreviousData,
});
const actions = [
...(facets.data?.actions ?? []),
...(action && !facets.data?.actions.some((f) => f.value === action)
? [{ value: action, count: 0 }]
: []),
];
const clear = useMutation({
mutationFn: () => api.clearAudit(),
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
},
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
});
const onClear = async () => {
if (
await confirm({
title: t("audit.clearTitle"),
message: t("audit.clearConfirm"),
confirmLabel: t("audit.clear"),
danger: true,
})
)
clear.mutate();
};
const actor = (row: AuditRow) =>
row.actor_id === null ? null : (state.accounts[String(row.actor_id)] ?? `#${row.actor_id}`);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label={t("activity.audit.action")}
options={[
{ value: "", label: t("activity.any") },
...actions.map((f) => ({
value: f.value,
label: auditActionLabel(t, f.value),
count: f.count,
})),
]}
selected={[action ?? ""]}
onChange={([value]) => onAction(value || null)}
testId="audit-action"
/>
<Button
variant="outline-danger"
size="xs"
className="ml-auto"
onClick={() => void onClear()}
disabled={clear.isPending}
icon={<Trash2 className="h-3.5 w-3.5" />}
>
{t("audit.clear")}
</Button>
</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
state={state}
empty={t("audit.empty")}
count={t("activity.audit.count", { count: state.rows.length })}
>
{state.rows.map((row, index) => {
const { head, sub } = auditText(t, row, lang);
const who = actor(row);
return (
<HistoryRow
key={row.id}
navIndex={index}
navText={`${auditActionLabel(t, row.action)} ${head ?? ""}`}
grid={GRID}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
title={formatDateTimeSec(row.created_at, lang)}
cells={
<>
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
<RelativeTime
at={row.created_at}
seconds
className="tabular-nums text-muted sm:hidden"
/>
<span
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
>
{who ?? t("audit.system")}
</span>
<RelativeTime
at={row.created_at}
seconds
className="hidden tabular-nums text-muted sm:block"
/>
<span className="col-span-2 truncate text-muted sm:col-span-1">
{[head, sub].filter(Boolean).join(" · ")}
</span>
</>
}
>
<AuditDetail row={row} who={who} onRequest={onRequest} />
</HistoryRow>
);
})}
</HistoryList>
</div>
);
}
function AuditDetail({
row,
who,
onRequest,
}: {
row: AuditRow;
who: string | null;
onRequest: (requestId: string) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { head, sub } = auditText(t, row, lang);
return (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
<dt>{t("audit.cols.actor")}</dt>
<dd>{who ?? t("audit.system")}</dd>
<dt>{t("audit.cols.when")}</dt>
<dd className="tabular-nums">{formatDateTimeSec(row.created_at, lang)}</dd>
{(head || sub) && (
<>
<dt>{t("audit.cols.details")}</dt>
<dd className="wrap-break-word">{[head, sub].filter(Boolean).join(" · ")}</dd>
</>
)}
{row.target_id && (
<>
<dt>{t("activity.audit.target")}</dt>
<dd className="font-mono">{row.target_id}</dd>
</>
)}
</dl>
{(row.before || row.after) && (
<pre className="overflow-x-auto whitespace-pre font-mono">
{JSON.stringify({ before: row.before, after: row.after }, null, 2)}
</pre>
)}
{/* 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>
<Button
variant="link"
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
onClick={() => onRequest(row.request_id!)}
>
{t("activity.audit.requestLines")}
</Button>
</div>
) : (
<p>{t("activity.audit.noRequestLines")}</p>
))}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api, type AdminQuotaRow } from "../lib/api";
import { sinceOf } from "../lib/activityUrlState";
import { formatNumber, quotaActionLabel } from "../lib/format";
import { qk } from "../lib/queryKeys";
import type { Shared } from "./ActivityRuns";
import { Pressable } from "./ui/Pressable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { SURFACE } from "./ui/surfaces";
// Activity → Quota: the shared YouTube API budget's spend over the page's time range — per day
// (Pacific days, as the budget resets), then per account, each opening to its actions. The
// account filter narrows both to one account; the text filter has nothing to match here.
export default function ActivityQuota({ shared }: { shared: Shared }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const q = useQuery({
queryKey: qk.adminQuota({ window: shared.window, user: shared.user }),
queryFn: () => api.adminQuota(sinceOf(shared.window), shared.user),
placeholderData: keepPreviousData,
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
return (
<div className={q.isPlaceholderData ? "opacity-60" : ""} aria-busy={q.isPlaceholderData}>
<p className="text-xs text-muted mb-3 leading-relaxed">
{t("stats.introBefore")}
<b className="text-fg/80">{t("stats.introSystem")}</b>
{t("stats.introAfter")}
</p>
{q.isError && !data ? (
<StateMessage tone="error" onRetry={() => void q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !data ? (
<LoadingState label={t("stats.loading")} />
) : (
<>
{q.isError && (
<StateMessage tone="error" compact onRetry={() => void q.refetch()} className="mb-3">
{t("activity.stale")}
</StateMessage>
)}
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t(shared.window === "all" ? "activity.quota.totalAll" : "activity.quota.total", {
count: grandTotal,
window: t(`activity.windows.${shared.window}`),
units: formatNumber(grandTotal, lang),
})}
</div>
{data.daily.length === 0 ? (
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
) : (
// Each day's figure is in its bar's text too (read out, not shown): the tooltip only
// reaches a pointer.
<div
className="flex items-end gap-0.5 h-24"
role="list"
aria-label={t("activity.quota.daily")}
>
{data.daily.map((d) => (
<div
key={d.day}
role="listitem"
// The day under the pointer is lit by the B3 layer over its whole column, not by
// dimming the bar: an opacity dim reads as "disabled".
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
title={t("stats.dailyTooltip", {
count: d.total,
day: d.day,
units: formatNumber(d.total, lang),
})}
>
<span className="sr-only">
{t("stats.dailyTooltip", {
count: d.total,
day: d.day,
units: formatNumber(d.total, lang),
})}
</span>
<div
className="w-full bg-accent rounded-t"
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
/>
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
{data.rows.length === 0 ? (
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
) : (
data.rows.map((r) => (
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
))
)}
</div>
</>
)}
</div>
);
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
return (
<div className={`${SURFACE.card} p-3`}>
<Pressable
selectable
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="w-full flex items-center gap-3"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? t("stats.system") : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{formatNumber(row.total, lang)}
</span>
</Pressable>
{/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full"
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
/>
</div>
{open && actions.length > 0 && (
<div className="mt-2 space-y-1 text-xs text-muted">
{actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units, lang)}</span>
</div>
))}
</div>
)}
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api, type LogFacet, type RunQuery, type RunRow } from "../lib/api";
import {
RUN_STATUSES,
sinceOf,
type ActivityUrlState,
type RunStatusFilter,
} from "../lib/activityUrlState";
import { formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
import { jobFailureText, jobResultText, parseSummary } from "../lib/jobResult";
import { qk } from "../lib/queryKeys";
import { useHistoryList } from "../lib/useHistoryList";
import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow";
import { StateMessage } from "./ui/QueryState";
import { RelativeTime } from "./ui/RelativeTime";
import { StatusDot } from "./RunStatusDot";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
// Activity → Runs: every background run, newest first — the scheduled ones, an admin's "run now", a
// user's work on one channel. A row opens to who started it, when, what it returned or why it
// failed, and two ways on: the lines the run logged, and the other runs of its job.
const GRID =
"grid-cols-[0.75rem_minmax(0,1fr)_auto] sm:grid-cols-[0.75rem_minmax(8rem,12rem)_minmax(0,8rem)_5.5rem_4rem_5.5rem_minmax(10rem,1fr)]";
/** The shared filters, as the page passes them down. */
export interface Shared {
window: ActivityUrlState["window"];
user: string | null;
q: string;
}
export default function ActivityRuns({
shared,
job,
status,
onFilter,
onLogs,
}: {
shared: Shared;
job: string | null;
status: RunStatusFilter | null;
onFilter: (patch: { job?: string | null; status?: RunStatusFilter | null }) => void;
/** Open the server log at this run's lines. */
onLogs: (runId: number) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [open, setOpen] = useState<number | null>(null);
const query: RunQuery = {
user: shared.user ?? undefined,
q: shared.q.trim() || undefined,
job: job ?? undefined,
status: status ?? undefined,
};
const key = { ...query, window: shared.window };
const state = useHistoryList(qk.adminRuns(key), (before) =>
api.adminRuns({ ...query, since: sinceOf(shared.window) }, before),
);
const facets = useQuery({
queryKey: qk.adminRunFacets(key),
queryFn: () => api.adminRunFacets({ ...query, since: sinceOf(shared.window) }),
placeholderData: keepPreviousData,
});
const count = (list: LogFacet[] | undefined, value: string) =>
list?.find((f) => f.value === value)?.count ?? (facets.data ? 0 : undefined);
const jobName = (id: string) => t(`scheduler.jobs.${id}`, id);
// The job in use stays offered at zero: under the other filters it may count nothing, and a menu
// without it would show no way back.
const jobs = [
...(facets.data?.jobs ?? []),
...(job && !facets.data?.jobs.some((f) => f.value === job) ? [{ value: job, count: 0 }] : []),
];
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label={t("activity.runs.job")}
options={[
{ value: "", label: t("activity.any") },
...jobs.map((f) => ({ value: f.value, label: jobName(f.value), count: f.count })),
]}
selected={[job ?? ""]}
onChange={([value]) => onFilter({ job: value || null })}
testId="runs-job"
/>
<FilterChipGroup
ariaLabel={t("activity.runs.statusLabel")}
value={status ?? "all"}
onChange={(value) =>
onFilter({ status: value === "all" ? null : (value as RunStatusFilter) })
}
options={[
{ value: "all", label: t("activity.any"), count: facets.data?.total },
...RUN_STATUSES.map((s) => ({
value: s,
label: t(`activity.runs.status.${s}`),
count: count(facets.data?.statuses, s),
})),
]}
/>
</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
state={state}
empty={t("activity.runs.empty")}
count={t("activity.runs.count", { count: state.rows.length })}
>
{state.rows.map((run, index) => (
<HistoryRow
key={run.id}
navIndex={index}
navText={`${jobName(run.job)} ${run.target_title ?? ""}`}
grid={GRID}
open={open === run.id}
onToggle={() => setOpen(open === run.id ? null : run.id)}
title={formatDateTimeSec(run.started_at, lang)}
cells={
<RunCells
run={run}
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
/>
}
>
<RunDetail
run={run}
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
onLogs={() => onLogs(run.id)}
onJob={() => onFilter({ job: run.job })}
/>
</HistoryRow>
))}
</HistoryList>
</div>
);
}
/** How long the run took; null while it runs, 0 when its start and end are one instant (the runs
* moved over from the audit log kept only when they were written — the length is unknown). */
function seconds(run: RunRow): number | null {
if (!run.finished_at) return null;
return (new Date(run.finished_at).getTime() - new Date(run.started_at).getTime()) / 1000;
}
/** What the run came to, in the reader's language: its failure, that the process stopped under it,
* or its result. */
function useOutcome(run: RunRow): { text: string; tone: string } {
const { t, i18n } = useTranslation();
const lang = i18n.language;
if (run.status === "error")
return {
text: jobFailureText(t, run.failure, lang) ?? run.error ?? t("scheduler.dot.error"),
tone: "text-danger",
};
if (run.status === "interrupted") return { text: t("scheduler.interrupted"), tone: "text-warn" };
if (run.status === "running") return { text: t("scheduler.runningNow"), tone: "text-accent" };
// 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" };
}
function RunCells({ run, actor }: { run: RunRow; actor: string | null | undefined }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const outcome = useOutcome(run);
const secs = seconds(run);
const name = t(`scheduler.jobs.${run.job}`, run.job);
return (
<>
<StatusDot k={run.status} label={t(`scheduler.dot.${run.status}`)} />
<span className="truncate font-medium">
{name}
{run.target_title && <span className="font-normal text-muted"> · {run.target_title}</span>}
</span>
{/* A phone has room for the name and the time only; the row opens to the rest. */}
<RelativeTime at={run.started_at} seconds className="tabular-nums text-muted sm:hidden" />
<span className="hidden truncate text-muted sm:block">
{t(`scheduler.trigger.${run.trigger}`)}
{actor && ` · ${actor}`}
</span>
<RelativeTime
at={run.started_at}
seconds
className="hidden tabular-nums text-muted sm:block"
/>
<span className="hidden tabular-nums text-muted sm:block">
{secs ? formatRuntime(Math.max(1, secs), lang) : "—"}
</span>
{/* The spend carries its unit (a bare figure in a row has no meaning); none spent is blank. */}
<span className="hidden truncate tabular-nums text-muted sm:block">
{run.quota_units > 0 &&
t("activity.runs.units", {
count: run.quota_units,
units: formatNumber(run.quota_units, lang),
})}
</span>
<span className={`col-span-3 truncate sm:col-span-1 ${outcome.tone}`}>{outcome.text}</span>
</>
);
}
function RunDetail({
run,
actor,
onLogs,
onJob,
}: {
run: RunRow;
actor: string | null | undefined;
onLogs: () => void;
onJob: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const link = "underline decoration-dotted underline-offset-2 hover:decoration-solid";
return (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
<dt>{t("activity.runs.fields.started")}</dt>
<dd>
{run.actor_id === null
? t(`scheduler.trigger.${run.trigger}`)
: `${actor ?? `#${run.actor_id}`} · ${t(`scheduler.trigger.${run.trigger}`)}`}
</dd>
<dt>{t("activity.runs.fields.when")}</dt>
<dd className="tabular-nums">
{formatDateTimeSec(run.started_at, lang)}
{run.finished_at && ` → ${formatDateTimeSec(run.finished_at, lang)}`}
</dd>
{run.target && (
<>
<dt>{t("activity.runs.fields.target")}</dt>
<dd>
{run.target_title ? `${run.target_title} · ` : ""}
<span className="font-mono">{run.target}</span>
</dd>
</>
)}
<dt>{t("activity.runs.fields.quota")}</dt>
<dd className="tabular-nums">{formatNumber(run.quota_units, lang)}</dd>
</dl>
{run.error && (
<pre className="whitespace-pre-wrap break-words text-danger/90">{run.error}</pre>
)}
{run.result && (
<pre className="overflow-x-auto whitespace-pre font-mono">
{JSON.stringify(run.result, null, 2)}
</pre>
)}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{/* Most runs store no line at all; a link there would only open an empty list. */}
{run.log_lines > 0 ? (
<Button variant="link" className={link} onClick={onLogs}>
{t("activity.runs.logLines", { count: run.log_lines })}
</Button>
) : (
<span>{t("activity.runs.noLogLines")}</span>
)}
<Button variant="link" className={link} onClick={onJob}>
{t("activity.runs.jobRuns")}
</Button>
</div>
</div>
);
}
+75 -17
View File
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
//
// The admin log console. A request id narrows the list to that request (and says so, in the
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
// error, not an empty log; Copy takes every loaded line, oldest first.
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
// filter row and the URL), and Back returns to the view the pivot was made from; the account in use
// stays pickable; "Live" watches the newest page only, and stops on a failed read instead of
// re-raising it; a failed read is an error, not an empty log; Copy takes every loaded line, oldest
// first.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -32,6 +34,7 @@ vi.mock("../lib/api", async (importOriginal) => {
...actual.api,
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
adminLogFacets: (query: LogQuery) => facetsMock(query),
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
},
};
});
@@ -44,7 +47,11 @@ vi.mock("../lib/clipboard", () => ({
},
}));
import AdminLogs from "./AdminLogs";
import Activity from "./Activity";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
const slot = document.body.appendChild(document.createElement("div"));
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
return {
@@ -60,6 +67,7 @@ function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
request_id: `req-${String(id).padStart(8, "0")}`,
user_id: 1,
job_id: null,
run_id: null,
debug_session_id: null,
version: "0.90.0",
...extra,
@@ -79,7 +87,9 @@ function renderConsole() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<AdminLogs />
<PageToolbarSlot.Provider value={slot}>
<Activity />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
}
@@ -93,7 +103,7 @@ const lines = () =>
.filter((b) => b.hasAttribute("aria-expanded"));
beforeEach(() => {
window.history.replaceState(null, "", "/?page=logs");
window.history.replaceState(null, "", "/?page=activity&at=logs");
facetsMock.mockResolvedValue(FACETS);
});
afterEach(() => {
@@ -104,8 +114,21 @@ afterEach(() => {
});
describe("AdminLogs", () => {
it("a dialog closing onto the same address leaves the open line open", async () => {
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
fireEvent.click(lines()[0]!);
expect(lines()[0]!.getAttribute("aria-expanded")).toBe("true");
// A closing dialog steps back over its own history entry: a popstate onto the same address.
act(() => {
window.dispatchEvent(new PopStateEvent("popstate"));
});
expect(lines()[0]!.getAttribute("aria-expanded")).toBe("true");
});
it("a request id narrows the list to that request, with every other filter off", async () => {
window.history.replaceState(null, "", "/?page=logs&lgrp=error&lq=boom");
window.history.replaceState(null, "", "/?page=activity&at=logs&lgrp=error&aq=boom");
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
@@ -121,11 +144,15 @@ describe("AdminLogs", () => {
);
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
const url = new URLSearchParams(window.location.search);
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual(["req-00000001", null, null]);
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual(["req-00000001", null, null]);
});
it("Back from a request's lines returns to the view the pivot was made from", async () => {
window.history.replaceState({ sfPage: "logs" }, "", "/?page=logs&lgrp=error&lq=boom");
window.history.replaceState(
{ sfPage: "activity" },
"",
"/?page=activity&at=logs&lgrp=error&aq=boom",
);
const depth = window.history.length;
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
@@ -157,23 +184,46 @@ describe("AdminLogs", () => {
);
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
const url = new URLSearchParams(window.location.search);
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual([null, "error", "boom"]);
expect([url.get("lreq"), url.get("lgrp"), url.get("aq")]).toEqual([null, "error", "boom"]);
// Neither jump waited out the typing debounce: no read ever mixed one view's filters with the
// other's search text (the request with "boom", or the restored view without it).
const mixed = logsMock.mock.calls.filter(([q]) => (q.request_id != null) === (q.q === "boom"));
expect(mixed).toEqual([]);
});
it("keeps the account in use offered when the facets no longer count it", async () => {
window.history.replaceState(null, "", "/?page=logs&lu=7");
it("a run id narrows the list to that run's lines", async () => {
logsMock.mockResolvedValue({
items: [row(1, { run_id: 77 })],
accounts: {},
next_cursor: null,
});
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
fireEvent.click(lines()[0]!);
fireEvent.click(screen.getByRole("button", { name: "77" }));
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ run_id: 77, request_id: undefined }),
null,
),
);
expect(screen.getByText("adminLogs.pivotRun:77")).toBeTruthy();
expect(new URLSearchParams(window.location.search).get("lrun")).toBe("77");
});
it("keeps the account in use offered when the account list does not have it", async () => {
window.history.replaceState(null, "", "/?page=activity&at=logs&au=7");
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(facetsMock).toHaveBeenCalled());
await waitFor(() =>
expect(logsMock).toHaveBeenCalledWith(expect.objectContaining({ user: "7" }), null),
);
fireEvent.click(screen.getByTestId("logs-user"));
fireEvent.click(screen.getByTestId("activity-user"));
expect(await screen.findByTestId("logs-user-7")).toBeTruthy();
expect(screen.getByTestId("logs-user-1")).toBeTruthy();
expect(await screen.findByTestId("activity-user-7")).toBeTruthy();
expect(screen.getByTestId("activity-user-1")).toBeTruthy();
});
it("Live watches the newest page only", async () => {
@@ -258,6 +308,14 @@ describe("AdminLogs", () => {
expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed");
});
it("facets that failed say the filter menus may be short, in the other tabs' words", async () => {
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
facetsMock.mockRejectedValue(new Error("500"));
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
expect((await screen.findByRole("alert")).textContent).toContain("activity.filtersFailed");
});
it("offers no older page while a filter change loads", async () => {
let landErrors!: (page: LogPage) => void;
logsMock.mockImplementation((q) =>
+184 -258
View File
@@ -1,5 +1,4 @@
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
@@ -7,74 +6,66 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio, Search } from "lucide-react";
import { Copy, Radio } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTimeSec } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { nextPageOnClick } from "../lib/nextPage";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
DEFAULT_LOGS_STATE,
logsParamsToState,
logsParamUpdates,
WINDOW_HOURS,
type LogsUrlState,
type LogWindow,
} from "../lib/logsUrlState";
import { sinceOf } from "../lib/activityUrlState";
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../lib/logsUrlState";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { urlWithParams } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { HistoryFrame } from "./HistoryList";
import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold";
import { useStickyTop } from "./ui/stickyTop";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
import type { Shared } from "./ActivityRuns";
// Admin → Szervernapló: the server's stored log (`log_events`). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
// Activity → Server log: the server's stored log (`log_events`). One list, newest first; a line
// opens in place to its full record, and its request / job / run id narrows the list to that
// request, job or run (the page does that: a pivot is a new history entry with every other filter
// off). The time range, account and text come from the page's shared bar; the rest live in the URL
// (./logsUrlState), so a filtered view is a link. "Élő" re-reads the newest page every
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
// into older rows turns it off. A failed read stops the polling (the request already reported it
// once) and marks the list as possibly stale, instead of re-raising the failure every few seconds.
const LIVE_MS = 4000;
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
const WINDOWS: readonly LogWindow[] = ["1h", "24h", "7d"];
// A pivot shows everything about one request or job: the other filters would only hide part of it.
const PIVOT_RESET: Omit<LogsUrlState, "requestId" | "jobId"> = {
...DEFAULT_LOGS_STATE,
window: "7d",
};
function sinceOf(window: LogWindow): string {
return new Date(Date.now() - WINDOW_HOURS[window] * 3_600_000).toISOString();
}
function toQuery(state: LogsUrlState, q: string): LogQuery {
function toQuery(state: LogsUrlState, shared: Shared): LogQuery {
return {
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
sources: state.sources,
user: state.user ?? undefined,
user: shared.user ?? undefined,
event: state.event ?? undefined,
request_id: state.requestId ?? undefined,
job_id: state.jobId ?? undefined,
q: q.trim() || undefined,
run_id: state.runId ?? undefined,
q: shared.q.trim() || undefined,
};
}
export default function AdminLogs() {
/** The ids a line can narrow the list to. */
export type LogPivot = Pick<LogsUrlState, "requestId" | "jobId" | "runId">;
export default function AdminLogs({
shared,
landed,
onPivot,
}: {
shared: Shared;
/** The filters of the entry Back/Forward landed on (the page reads the address bar, once). */
landed: { logs: LogsUrlState } | null;
/** Show only one request's, job's or run's lines (the page clears every other filter). */
onPivot: (pivot: LogPivot) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
@@ -82,14 +73,9 @@ export default function AdminLogs() {
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLiveState] = useState(false);
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
useStickyTop();
// The facet row folds away when the screen is short.
const fold = useFilterFold();
const facetFilters =
state.sources.length +
(state.user ? 1 : 0) +
(state.event ? 1 : 0) +
(state.window !== DEFAULT_LOGS_STATE.window ? 1 : 0);
const facetFilters = state.sources.length + (state.event ? 1 : 0);
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
// after Live was switched on, or a filter set revisited with older pages still cached.
@@ -99,41 +85,37 @@ export default function AdminLogs() {
setLiveState(on);
};
const [open, setOpen] = useState<number | null>(null);
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the
// search box's text then applies at once instead of after the typing debounce.
const [jumps, setJumps] = useState(0);
const q = useDebounced(state.q, 300, jumps);
useUrlMirror(logsParamUpdates(state));
// Back/Forward lands on another view of the console (a pivot pushed one, below): read it back.
useEffect(() => {
const onPop = () => {
// 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). Adopted during the render that brings it, with
// the page's half.
const [seen, setSeen] = useState(landed);
if (landed !== seen) {
setSeen(landed);
if (landed && JSON.stringify(landed.logs) !== JSON.stringify(state)) {
setOpen(null);
setJumps((n) => n + 1);
setState(logsParamsToState(new URLSearchParams(window.location.search)));
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
setState(landed.logs);
}
}
const update = (patch: Partial<LogsUrlState>) => {
setOpen(null);
setState((s) => ({ ...s, ...patch }));
};
const query = toQuery(state, q);
const key = qk.adminLogs({ ...query, window: state.window });
const query = toQuery(state, shared);
const key = qk.adminLogs({ ...query, window: shared.window });
const logs = useInfiniteQuery({
queryKey: key,
queryFn: ({ pageParam }) =>
api.adminLogs({ ...query, since: sinceOf(state.window) }, pageParam),
api.adminLogs({ ...query, since: sinceOf(shared.window) }, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
placeholderData: keepPreviousData,
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
});
const facets = useQuery({
queryKey: qk.adminLogFacets({ ...query, window: state.window }),
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.window) }),
queryKey: qk.adminLogFacets({ ...query, window: shared.window }),
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(shared.window) }),
placeholderData: keepPreviousData,
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
});
@@ -146,15 +128,16 @@ export default function AdminLogs() {
? null
: (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null);
// A read that failed while lines were on screen: they are kept, and the message says what the
// failure means for them. (With no lines, the list's own error state says it.)
// failure means for them. (With no lines, the list's own error state says it.) Without the facets
// the Source and Event menus offer only "Any", as on the other tabs, so the same words say it.
const failure = !logs.data
? null
: logs.isFetchNextPageError
? "olderFailed"
? "adminLogs.olderFailed"
: logs.isError || (facets.isError && facets.data)
? "stale"
? "adminLogs.stale"
: facets.isError
? "countsFailed"
? "activity.filtersFailed"
: null;
const retryFailed = () => {
if (logs.isFetchNextPageError)
@@ -217,19 +200,9 @@ export default function AdminLogs() {
return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined;
};
// A pivot follows a link to another view, so it is its own history entry and Back returns to the
// view it was made from. The other filters are adjustments of the view on screen and replace it.
const pivot = (patch: Pick<LogsUrlState, "requestId" | "jobId">) => {
const pivot = (patch: Partial<LogPivot>) => {
setLive(false);
const next = { ...state, ...PIVOT_RESET, ...patch };
window.history.pushState(
{ ...rememberNavPosition(), sfScroll: nextNavId() },
"",
urlWithParams(logsParamUpdates(next)),
);
notifyNav();
setJumps((n) => n + 1);
update(next);
onPivot({ requestId: null, jobId: null, runId: null, ...patch });
};
// "Any", then what the facet counted. The value in use stays offered even at zero — under the
@@ -253,194 +226,130 @@ export default function AdminLogs() {
};
return (
<div className="p-4 max-w-6xl w-full mx-auto">
<Section card title={t("adminLogs.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("adminLogs.intro")}</p>
<HistoryFrame
testId="admin-logs"
list={logs}
rowCount={rows.length}
rowsClassName="font-mono text-2xs"
failure={failure && { text: t(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 className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</div>
<div
data-testid="admin-logs"
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
// to it instead of to the page.
className="overflow-clip rounded-xl border border-border bg-bg"
>
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
under it while the lines scroll, so a filter is in reach at line 500. */}
<div className="sticky top-0 z-base bg-bg fade-below">
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[12rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<InnerInput
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
{!fold.folded && (
<>
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
{!fold.folded && (
<>
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
</>
)}
<span className="ml-auto flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
</>
)}
</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) => (
<LogLine
navIndex={index}
key={row.id}
ts={row.ts}
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTimeSec(row.ts, lang)}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
<RowDetail
row={row}
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
onRequest={(requestId) => pivot({ requestId, jobId: null })}
onJob={(jobId) => pivot({ requestId: null, jobId })}
/>
</LogLine>
))}
</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>{t("adminLogs.count", { count: rows.length })}</span>
{olderCursor !== null && (
<span className="ml-auto flex gap-1.5">
<Button
variant="outline"
size="xs"
disabled={logs.isFetchingNextPage}
onClick={loadOlder}
icon={<Radio className="h-3.5 w-3.5" />}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.older")}
{t("adminLogs.live")}
</Button>
)}
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null || state.runId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
{state.runId !== null && (
<PivotChip
label={t("adminLogs.pivotRun", { id: state.runId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ runId: null })}
/>
)}
</div>
)}
</div>
</Section>
</div>
}
>
{rows.map((row, index) => (
<LogLine
navIndex={index}
key={row.id}
ts={row.ts}
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTimeSec(row.ts, lang)}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
<RowDetail
row={row}
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
onRequest={(requestId) => pivot({ requestId })}
onJob={(jobId) => pivot({ jobId })}
onRun={(runId) => pivot({ runId })}
/>
</LogLine>
))}
</HistoryFrame>
);
}
@@ -465,11 +374,13 @@ function RowDetail({
account,
onRequest,
onJob,
onRun,
}: {
row: ServerLogRow;
account: string | null;
onRequest: (id: string) => void;
onJob: (id: number) => void;
onRun: (id: number) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -516,6 +427,21 @@ function RowDetail({
</dd>
</>
)}
{row.run_id !== null && (
<>
<dt>{t("adminLogs.fields.run")}</dt>
<dd>
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showRun")}
onClick={() => onRun(row.run_id!)}
>
{row.run_id}
</Button>
</dd>
</>
)}
{row.version && (
<>
<dt>{t("adminLogs.fields.version")}</dt>
@@ -1,48 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { ChevronRight } from "lucide-react";
import type { AuditRow } from "../lib/api";
import { Button } from "./ui/Button";
import { RelativeTime } from "./ui/RelativeTime";
/** A folded run of one job's successful runs (lib/auditGroups): the row shows the newest, this
* opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */
export default function AuditEarlierRuns({
runs,
detailOf,
}: {
runs: AuditRow[];
detailOf: (run: AuditRow) => string | null;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const earlier = runs.slice(1);
return (
<div className="mt-1">
<Button
variant="link-muted"
aria-expanded={open}
onClick={() => setOpen(!open)}
icon={<ChevronRight className={clsx("transition-transform", open && "rotate-90")} />}
>
{t("audit.earlierRuns", { count: earlier.length })}
</Button>
{open && (
<ul className="mt-1 space-y-0.5 text-2xs text-muted">
{earlier.map((run) => (
<li key={run.id} className="flex gap-2">
<RelativeTime
at={run.created_at}
seconds
empty="—"
className="shrink-0 tabular-nums"
/>
<span className="min-w-0 wrap-break-word">{detailOf(run)}</span>
</li>
))}
</ul>
)}
</div>
);
}
-107
View File
@@ -1,107 +0,0 @@
import { useMemo } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { api } from "../lib/api";
import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
import { Button } from "./ui/Button";
import { Section } from "./ui/form";
import { useConfirm } from "./confirmContext";
import DataTable from "./ui/DataTable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns";
import { notifyActionError } from "../lib/actionErrors";
import { compareText } from "../lib/collate";
import { foldJobRuns } from "../lib/auditGroups";
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
export default function AuditLog() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
const data = q.data;
// Back-to-back successful runs of one job fold into one row (lib/auditGroups).
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
const actionOptions = useMemo(() => {
// By what the reader sees — the named action, in their alphabet — not by the code.
return Array.from(new Set(rows.map((r) => r.action)))
.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }))
.sort((a, b) => compareText(a.label, b.label, lang));
}, [rows, t, lang]);
const columns = useMemo(() => auditColumns(t, lang, actionOptions), [t, lang, actionOptions]);
const clear = useMutation({
mutationFn: () => api.clearAudit(),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: qk.adminAudit() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
},
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
});
const onClear = async () => {
if (
await confirm({
title: t("audit.clearTitle"),
message: t("audit.clearConfirm"),
confirmLabel: t("audit.clear"),
danger: true,
})
)
clear.mutate();
};
const truncated = !!data && data.total > data.returned;
return (
<div className="p-4 max-w-5xl w-full mx-auto">
<Section card title={t("audit.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState shape="table" />
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("audit.empty")}</p>
) : (
<>
<DataTable
rows={rows}
columns={columns}
rowKey={(r) => String(r.id)}
persistKey={LS.auditTable}
controlsPosition="top"
controlsInBand
controlsBandClassName="px-4 max-w-5xl mx-auto"
controlsLeading={
<Button
variant="outline-danger"
size="xs"
onClick={onClear}
disabled={clear.isPending}
icon={<Trash2 className="w-3.5 h-3.5" />}
>
{t("audit.clear")}
</Button>
}
emptyText={t("audit.empty")}
/>
{truncated && (
<p className="text-2xs text-muted mt-2">
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
</p>
)}
</>
)}
</Section>
</div>
);
}
+3 -1
View File
@@ -515,7 +515,9 @@ export default function Channels() {
id: "backfill",
label: t("channels.backfillEverything"),
title: t("channels.backfillEverythingHint"),
icon: <History className={`h-4 w-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />,
icon: (
<History className={`h-4 w-4 ${deepAll.isPending ? "motion-safe:animate-pulse" : ""}`} />
),
onSelect: () => deepAll.mutate(),
disabled: deepAll.isPending,
},
+4 -1
View File
@@ -54,7 +54,10 @@ export default function DownloadButton({
>
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
<Download
className={clsx((downloaded || inQueue) && "text-accent", inQueue && "animate-pulse")}
className={clsx(
(downloaded || inQueue) && "text-accent",
inQueue && "motion-safe:animate-pulse",
)}
/>
</IconButton>
{open && (
+1 -1
View File
@@ -98,7 +98,7 @@ function Progress({ job }: { job: QueueJob }) {
style={{ width: `${job.progress}%` }}
/>
) : (
<span className="block h-full w-full animate-pulse bg-accent/60" />
<span className="block h-full w-full motion-safe:animate-pulse bg-accent/60" />
)}
</span>
{bytes && <span className="w-8 shrink-0 text-right tabular-nums">{job.progress}%</span>}
+144
View File
@@ -0,0 +1,144 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { nextPageOnClick } from "../lib/nextPage";
import type { HistoryList as HistoryListState } from "../lib/useHistoryList";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The frame of a history list on the activity page — the runs, the admin actions, the server log:
// 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>({
state,
empty,
count,
children,
}: {
state: HistoryListState<T>;
/** Shown when the filters match nothing. */
empty: string;
/** "N runs shown", for the footer. */
count: string;
/** The rows, drawn from `state.rows`. */
children: ReactNode;
}) {
const { t } = useTranslation();
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.
const failure: FrameFailure | null = !list.data
? null
: list.isFetchNextPageError
? { text: t("activity.olderFailed"), onRetry: fetchOlder }
: list.isError
? { text: t("activity.stale"), onRetry: () => void list.refetch() }
: null;
return (
<HistoryFrame
list={list}
rowCount={rows.length}
failure={failure}
older={
hasOlder
? { label: t("activity.older"), onClick: fetchOlder, disabled: list.isFetchingNextPage }
: null
}
count={count}
empty={empty}
>
{children}
</HistoryFrame>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
// One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
// its lines through this): its cells on one line, and the row opens in place to its details. One frame
// 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({
cells,
grid,
navIndex,
navText,
open,
onToggle,
title,
line = false,
children,
}: {
/** The row's cells, laid out by `grid`. */
cells: ReactNode;
/** The grid's column template classes (phone first, then wider screens). */
grid: string;
navIndex: number;
/** What the keyboard's type-ahead matches. */
navText: string;
open: boolean;
onToggle: () => void;
/** The row button's tooltip — the full date. */
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. */
children: ReactNode;
}) {
return (
<div
className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, navText, { inset: true })}
>
<Pressable
selectable
layer={!open}
data-nav-open
aria-expanded={open}
onClick={onToggle}
title={title}
className={`grid w-full gap-x-2 px-3 text-left touch-target pointer-coarse:content-center ${
line ? "items-baseline py-1" : "items-center py-1.5 text-xs"
} ${grid} ${open ? "bg-accent/10" : ""}`}
>
{cells}
</Pressable>
{open && (
<div className={`bg-accent/5 px-3 py-2 text-muted ${line ? "" : "text-xs"}`}>
{children}
</div>
)}
</div>
);
}
@@ -112,6 +112,7 @@ const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
request_id: null,
user_id: 1,
job_id: null,
run_id: null,
debug_session_id: SID,
version: null,
...over,
+23 -26
View File
@@ -1,14 +1,14 @@
import type { ReactNode } from "react";
import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav";
import HistoryRow from "./HistoryRow";
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
// 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.
// 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({
ts,
@@ -36,29 +36,26 @@ export default function LogLine({
}) {
const tag = LEVEL_TAG[level];
return (
<div
className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, `${event} ${msg}`, { inset: true })}
<HistoryRow
line
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}
navText={`${event} ${msg}`}
open={open}
onToggle={onToggle}
title={title}
cells={
<>
<span className="tabular-nums text-muted">{logClock(ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</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
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>
</>
}
>
<Pressable
selectable
layer={!open}
data-nav-open
aria-expanded={open}
onClick={onToggle}
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 ${
open ? "bg-accent/10" : ""
}`}
>
<span className="tabular-nums text-muted">{logClock(ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</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
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>
</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 { 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
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
@@ -151,11 +153,12 @@ describe("NavigationProvider · setPage's same-page guard", () => {
expect(result.current.page).toBe("plex");
});
it("leaves every page's own URL keys behind, the log console's included", () => {
it("leaves every page's own URL keys behind, the activity page's included", () => {
window.history.replaceState(
{ sfPage: "logs" },
{ sfPage: "activity" },
"",
"/?page=logs&lgrp=error&lreq=req-abcdef12&pscope=movie&ctab=hidden&keep=1",
"/?page=activity&at=logs&aw=24h&au=none&aq=x&rjob=backfill&rst=error&aact=config_set" +
"&lgrp=error&lreq=req-abcdef12&lrun=7&pscope=movie&ctab=hidden&keep=1",
);
const { result } = render();
act(() => result.current.setPage("plex"));
@@ -218,6 +221,66 @@ describe("NavigationProvider · Back", () => {
expect(window.location.search).toBe("?page=files&keep=1");
});
// The audit log and the server log became the activity page's tabs: an old link, bookmark or entry
// opens that tab, with the filters it carried.
it("opens a retired page's tab on the page that replaced it", () => {
window.history.replaceState(null, "", "/?page=audit&keep=1");
const { result } = render();
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&keep=1&at=admin");
});
it("keeps a retired page's own filter as it opens its tab", () => {
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
render();
const url = new URLSearchParams(window.location.search);
expect([url.get("page"), url.get("at"), url.get("lreq")]).toEqual([
"activity",
"logs",
"req-00000009",
]);
});
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", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "audit" }, "", "/?page=audit");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "audit" } }));
});
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&at=admin");
});
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
it("does not put back the focus stamp the landing spent", () => {
+13 -6
View File
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { useLatest } from "../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useMe } from "../lib/useMe";
import { resolvePage, urlWithParams, type Page } from "../lib/urlState";
import { LOGS_KEYS } from "../lib/logsUrlState";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../lib/urlState";
import { ACTIVITY_KEYS } from "../lib/activityUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage";
import { getAccountRaw, setAccountRaw } from "../lib/storage";
import {
backStaysInDocument,
closeOverlaysThenNavigate,
@@ -92,7 +92,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
}),
);
notifyNav();
@@ -193,7 +193,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// 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
// ?chan / ?v / ?pl / filter params the loaders already read.
const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage });
// 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({
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage,
});
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
} else {
@@ -213,7 +220,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.replaceState(
{ ...window.history.state, sfPage: p },
"",
urlWithParams({ page: p === "feed" ? null : p }),
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }),
);
setPageState(p);
setAccountRaw(PAGE_KEY, p);
+29
View File
@@ -0,0 +1,29 @@
// A background run's status as a dot — the Scheduler's rows and the activity page's run list draw
// the same one, so a state reads the same on both.
// Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set
// apart from a skipped run (the same colour, filled) and from a failure.
const DOT_CLASS = {
running: "bg-accent motion-safe:animate-pulse",
ok: "bg-success",
error: "bg-danger",
skipped: "bg-warn",
interrupted: "border-2 border-warn",
idle: "bg-border",
} as const;
export type DotKey = keyof typeof DOT_CLASS;
/** A status dot. `label` names it for a screen reader (the tooltip shows only on hover, and only with
* hints on); a legend's dot has its words beside it and stays unnamed. */
export function StatusDot({ k, label }: { k: DotKey; label?: string }) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
data-dot={k}
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`}
/>
);
}
+119 -2
View File
@@ -6,7 +6,7 @@
// because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the
// thing a poll can overwrite at the wrong moment.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
@@ -15,7 +15,8 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import Scheduler from "./Scheduler";
import { api, type SchedulerStatus } from "../lib/api";
import { NavigationActionsContext } from "./navigationContext";
import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api";
import { qk } from "../lib/queryKeys";
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
@@ -34,9 +35,14 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
last_result: null,
result: null,
last_error: null,
last_trigger: null,
last_run_id: null,
quota_units: null,
recent: [],
progress: null,
},
],
ad_hoc: [],
queue: {
channels_recent_pending: 0,
channels_deep_pending: 0,
@@ -281,3 +287,114 @@ describe("a job's last result", () => {
expect(screen.queryByText(/new=3/)).toBeNull();
});
});
describe("a row's run history", () => {
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
const run = (id: number, status: RunBrief["status"], minutesAgo: number): RunBrief => ({
id,
status,
trigger: "scheduled",
started_at: at(minutesAgo),
finished_at: status === "running" ? null : at(minutesAgo),
});
const dots = (root: HTMLElement = document.body) =>
within(root)
.getAllByRole("img")
.map((d) => d.getAttribute("data-dot"));
// An interrupted run fell through to "idle" — a grey dot that said no run was ever recorded.
it("shows the last runs newest first, an interrupted one as such", async () => {
const base = status();
await mount({
...base,
jobs: [
{
...base.jobs[0]!,
status: "interrupted",
last_finished: at(2),
recent: [run(3, "interrupted", 2), run(2, "error", 12), run(1, "ok", 22)],
},
],
});
expect(dots()).toEqual(["interrupted", "error", "ok"]);
expect(screen.getByText(/scheduler\.interrupted/)).toBeTruthy();
expect(screen.getByText("scheduler.dot.interrupted")).toBeTruthy(); // the legend names it
});
it("shows one idle dot for a job that never ran", async () => {
await mount();
expect(dots()).toEqual(["idle"]);
});
it("gives a run the job has claimed, but not yet recorded, the newest dot", async () => {
const base = status();
await mount({
...base,
jobs: [
{
...base.jobs[0]!,
running: true,
recent: [run(3, "ok", 10), run(2, "ok", 20), run(1, "error", 30)],
},
],
});
expect(dots()).toEqual(["running", "ok", "ok"]);
});
// The one-channel full-history backfill ran unseen: a background task only the log heard of.
it("lists a user's ad-hoc run beside the jobs, with its channel and no run button", async () => {
const base = status();
const adHoc: SchedulerAdHoc = {
...base.jobs[0]!,
id: "channel_backfill",
running: false,
target: "UC_bbc",
target_title: "BBC Earth",
last_finished: at(60),
result: { videos_added: 69 },
recent: [run(9, "ok", 60), run(8, "ok", 120)],
};
await mount({ ...base, ad_hoc: [adHoc] });
expect(screen.getByText("scheduler.adHocTitle")).toBeTruthy();
const row = screen.getByText("scheduler.jobs.channel_backfill").closest<HTMLElement>(".p-3")!;
expect(dots(row)).toEqual(["ok", "ok"]);
expect(within(row).getByText(/BBC Earth/)).toBeTruthy();
expect(within(row).getByText(/scheduler\.result\.videos_added: 69/)).toBeTruthy();
expect(within(row).getByText("scheduler.adHocStarted")).toBeTruthy();
expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull();
});
});
describe("a row's history", () => {
// The dots show three runs; the rest is the activity page's run list, narrowed to the row's job.
it("opens the activity page's runs of that job", async () => {
const setPage = vi.fn((_page: string, then?: () => void) => then?.());
window.history.replaceState(null, "", "/?page=scheduler");
current = status();
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<NavigationActionsContext.Provider
value={{
setPage,
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
}}
>
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>
</NavigationActionsContext.Provider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
const job = current.jobs[0]!.id;
const row = screen.getByText(`scheduler.jobs.${job}`).closest<HTMLElement>(".p-3")!;
fireEvent.click(within(row).getByRole("button", { name: "scheduler.history" }));
expect(setPage).toHaveBeenCalledWith("activity", expect.any(Function));
const url = new URLSearchParams(window.location.search);
expect([url.get("page"), url.get("rjob")]).toEqual(["activity", job]);
});
});
@@ -32,9 +32,14 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
last_result: null,
result: null,
last_error: null,
last_trigger: null,
last_run_id: null,
quota_units: null,
recent: [],
progress: null,
},
],
ad_hoc: [],
queue: {
channels_recent_pending: 0,
channels_deep_pending: 0,
+156 -53
View File
@@ -10,6 +10,7 @@ import {
Clock,
Database,
Gauge,
History,
Pause,
Pencil,
Play,
@@ -17,7 +18,14 @@ import {
Trash2,
X,
} from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import {
api,
type RunBrief,
type RunFields,
type SchedulerAdHoc,
type SchedulerJob,
type SchedulerStatus,
} from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
@@ -36,6 +44,9 @@ import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { jobFailureText, jobResultText } from "../lib/jobResult";
import { StatusDot, type DotKey } from "./RunStatusDot";
import { useNavigationActions } from "./navigationContext";
import { urlWithParams } from "../lib/urlState";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -57,41 +68,13 @@ function fmtCountdown(t: TFunction, s: number, lang: string): string {
return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`;
}
const DOT_CLASS: Record<string, string> = {
running: "bg-accent animate-pulse",
ok: "bg-success",
error: "bg-danger",
skipped: "bg-warn",
idle: "bg-border",
};
function statusKey(job: SchedulerJob): keyof typeof DOT_CLASS {
if (job.running) return "running";
if (job.status === "ok") return "ok";
if (job.status === "error") return "error";
if (job.status === "skipped") return "skipped";
return "idle";
}
function StatusDot({
k,
withTooltip = true,
}: {
k: keyof typeof DOT_CLASS;
withTooltip?: boolean;
}) {
const { t } = useTranslation();
const dot = <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`} />;
return withTooltip ? <Tooltip hint={t(`scheduler.dot.${k}`)}>{dot}</Tooltip> : dot;
}
function StatusLegend() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-2xs text-muted mb-2">
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => (
{(["running", "ok", "idle", "error", "skipped", "interrupted"] as const).map((k) => (
<span key={k} className="inline-flex items-center gap-1.5">
<StatusDot k={k} withTooltip={false} />
<StatusDot k={k} />
{t(`scheduler.dot.${k}`)}
</span>
))}
@@ -99,6 +82,132 @@ function StatusLegend() {
);
}
// The server sends a row's newest runs, at most this many (RECENT_RUNS in app/scheduler.py).
const MAX_DOTS = 3;
/** A row's last runs, newest first (leftmost), each saying in its tooltip how it went, when, and who
* started it. A row with no run yet shows one idle dot. While a run goes on the newest dot pulses:
* the job's own claim says so a moment before its run row exists, so that moment gets a dot too. */
function RunDots({ runs, running }: { runs: RunBrief[]; running: boolean }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const claimed = running && runs[0]?.status !== "running";
const shown: (RunBrief | null)[] = claimed ? [null, ...runs].slice(0, MAX_DOTS) : runs;
const dots = shown.length === 0 ? [undefined] : shown;
return (
<span className="inline-flex items-center gap-1 w-10 shrink-0">
{dots.map((run) => {
const hint =
run === undefined
? t("scheduler.dot.idle")
: run === null
? t("scheduler.dot.running")
: t("scheduler.dotRun", {
state: t(`scheduler.dot.${run.status}`),
time: relativeTime(run.started_at, lang, t),
trigger: t(`scheduler.trigger.${run.trigger}`),
});
const k: DotKey = run === undefined ? "idle" : run === null ? "running" : run.status;
return (
<Tooltip key={run?.id ?? String(run)} hint={hint}>
<StatusDot k={k} label={hint} />
</Tooltip>
);
})}
</span>
);
}
/** "Last run 2 min ago · <what it did>", the line under a row's name: how the newest ended run went
* — its failure, its result, or that the process stopped under it. */
function LastRun({ row, running, target }: { row: RunFields; running: boolean; target?: string }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
return (
<div
className="text-2xs text-muted truncate"
title={(!running && formatDateTime(row.last_finished, lang)) || undefined}
>
{running
? t("scheduler.runningNow")
: row.last_finished
? t("scheduler.lastRun", { time: relativeTime(row.last_finished, lang, t) })
: t("scheduler.neverRun")}
{target && <span> · {target}</span>}
{row.last_error ? (
<span className="text-danger">
{" · "}
{jobFailureText(t, row.result, lang) ?? row.last_error}
</span>
) : row.status === "interrupted" ? (
<span className="text-warn"> · {t("scheduler.interrupted")}</span>
) : row.result ? (
<span> · {jobResultText(t, row.result, lang)}</span>
) : row.last_result ? (
<span> · {row.last_result}</span>
) : null}
</div>
);
}
/** A row's way to its whole history: the activity page's run list, narrowed to this job. */
function HistoryButton({ job }: { job: string }) {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
return (
<Tooltip hint={t("scheduler.history")}>
<IconButton
label={t("scheduler.history")}
noTitle
onClick={() =>
setPage("activity", () =>
window.history.replaceState(
window.history.state,
"",
urlWithParams({ page: "activity", rjob: job }),
),
)
}
>
<History />
</IconButton>
</Tooltip>
);
}
/** A user's run on one target (a channel's full history): its runs as a job's are shown, but it has
* no interval and no "run now" — it needs its target, so it is started where that is chosen. */
function AdHocRow({ row }: { row: SchedulerAdHoc }) {
const { t } = useTranslation();
return (
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
<RunDots runs={row.recent} running={row.running} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
<Tooltip hint={t(`scheduler.jobDesc.${row.id}`, "")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t(`scheduler.jobs.${row.id}`, row.id)}
</span>
</Tooltip>
</div>
<LastRun
row={row}
running={row.running}
target={row.target_title ?? row.target ?? undefined}
/>
</div>
<div className="shrink-0 text-right text-2xs text-muted">
{row.running ? (
<span className="text-accent">{t("scheduler.active")}</span>
) : (
t("scheduler.adHocStarted")
)}
</div>
<HistoryButton job={row.id} />
</div>
);
}
// Shown for ANY running job. With reported counts it's a determinate bar; for a job that
// runs but doesn't report progress (or hasn't yet) it falls back to a generic "working"
// label and an indeterminate sliver — so every active run is visible, not just the ones
@@ -129,7 +238,7 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
/>
) : (
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
<div className="h-full w-1/3 rounded-full bg-accent animate-pulse" />
<div className="h-full w-1/3 rounded-full bg-accent motion-safe:animate-pulse" />
)}
</div>
</div>
@@ -186,7 +295,7 @@ function JobRow({
return (
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
<StatusDot k={statusKey(job)} />
<RunDots runs={job.recent} running={job.running} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`, "")}>
@@ -247,26 +356,7 @@ function JobRow({
</Tooltip>
)}
</div>
<div
className="text-2xs text-muted truncate"
title={(!job.running && formatDateTime(job.last_finished, lang)) || undefined}
>
{job.running
? t("scheduler.runningNow")
: job.last_finished
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang, t) })
: t("scheduler.neverRun")}
{job.last_error ? (
<span className="text-danger">
{" · "}
{jobFailureText(t, job.result, lang) ?? job.last_error}
</span>
) : job.result ? (
<span> · {jobResultText(t, job.result, lang)}</span>
) : job.last_result ? (
<span> · {job.last_result}</span>
) : null}
</div>
<LastRun row={job} running={job.running} />
{job.running && <JobProgress p={job.progress} />}
</div>
<div className="shrink-0 text-right text-2xs text-muted tabular-nums">
@@ -295,6 +385,7 @@ function JobRow({
<Play />
</IconButton>
</Tooltip>
<HistoryButton job={job.id} />
</div>
);
}
@@ -646,6 +737,18 @@ export default function Scheduler() {
</div>
</div>
{/* Ad-hoc runs: a user's work on one target, shown beside the jobs because it spends the same quota */}
{data.ad_hoc.length > 0 && (
<div>
<SectionLabel className="mb-2">{t("scheduler.adHocTitle")}</SectionLabel>
<div className="space-y-1.5">
{data.ad_hoc.map((row) => (
<AdHocRow key={row.id} row={row} />
))}
</div>
</div>
)}
{/* Queue */}
<div>
<SectionLabel className="mb-2">{t("scheduler.queueTitle")}</SectionLabel>
+3 -1
View File
@@ -259,7 +259,9 @@ export default function SetupWizard() {
onClick={sendTest}
disabled={testState === "sending" || !testTo.trim()}
>
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
<Send
className={`w-4 h-4 ${testState === "sending" ? "motion-safe:animate-pulse" : ""}`}
/>
{t("setup.smtp.test")}
</Button>
</div>
+8 -210
View File
@@ -1,67 +1,27 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow } from "../lib/api";
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
import { History, RefreshCw } from "lucide-react";
import { api } from "../lib/api";
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./wizardContext";
import { LS, useTabPersistedState } from "../lib/storage";
import Tooltip from "./ui/Tooltip";
import { LoadingState, StateMessage } from "./ui/QueryState";
import FilterChipGroup from "./ui/FilterChipGroup";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { Section } from "./ui/form";
import { SectionLabel } from "./ui/Headings";
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system";
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
// "System" is the admin-only instance-wide quota dashboard + background-sync control. The
// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only.
// Usage & stats page: your sync status, your own API usage and the manual sync actions (they used to
// live in Settings → Sync; they moved here so Settings stays config-only). The instance-wide quota
// view is the activity page's Quota tab.
export default function Stats() {
const me = useMe();
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tabRaw, setTab] = useTabPersistedState(LS.statsTab, "overview");
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
const panel = useTabPanel();
return (
// A tab panel only where there are tabs: everyone but an admin sees the Overview alone.
<div {...(isAdmin ? panel.panelProps(tab) : {})} className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
{/* Only admins have a second (System) tab; everyone else just sees the Overview. The tabs are
the page's band (B4), so they stay in reach on the long System tab. */}
{isAdmin && (
<PageToolbar>
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<Tabs
idBase={panel.idBase}
active={tab}
onChange={setTab}
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
id,
label: t(`stats.tabs.${id}`),
}))}
className="flex flex-wrap gap-1 pb-2"
/>
</div>
</PageToolbar>
)}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview />}
<div className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
<Overview />
</div>
);
}
@@ -230,165 +190,3 @@ function Overview() {
</>
);
}
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
function SystemStats() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const [days, setDays] = useState<number>(30);
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e, paused) =>
notifyActionError(
e,
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
const s = status.data;
return (
<>
{s && (
<Section title={t("stats.sync.admin")}>
<Tooltip hint={t("stats.sync.adminPauseHint")}>
<Button
variant="glass"
icon={s.paused ? <Play /> : <Pause />}
loading={pauseResume.isPending}
onClick={() => pauseResume.mutate(s.paused)}
>
{s.paused
? t("stats.sync.resumeBackgroundSync")
: t("stats.sync.pauseBackgroundSync")}
</Button>
</Tooltip>
</Section>
)}
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
<FilterChipGroup
ariaLabel={t("stats.rangeLabel")}
className="flex items-center gap-1.5"
options={RANGES.map((d) => ({
value: String(d),
label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }),
}))}
value={String(days)}
onChange={(v) => setDays(Number(v))}
/>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
{t("stats.introBefore")}
<b className="text-fg/80">{t("stats.introSystem")}</b>
{t("stats.introAfter")}
</p>
{q.isError && !data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("stats.loading")} />
) : !data ? (
<div className="text-muted py-8">{t("stats.noData")}</div>
) : (
<>
{/* Daily totals (instance-wide, Pacific days) */}
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", {
d: formatUnit(data.range_days, "day", lang),
units: formatNumber(grandTotal, lang),
})}
</div>
{data.daily.length === 0 ? (
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
) : (
<div className="flex items-end gap-0.5 h-24">
{data.daily.map((d) => (
<div
key={d.day}
// The day under the pointer is lit by the B3 layer over its whole column, not by
// dimming the bar: an opacity dim reads as "disabled".
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
title={t("stats.dailyTooltip", {
day: d.day,
units: formatNumber(d.total, lang),
})}
>
<div
className="w-full bg-accent rounded-t"
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
/>
</div>
))}
</div>
)}
</div>
{/* Per-user breakdown */}
<div className="flex flex-col gap-1.5">
{data.rows.length === 0 ? (
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
) : (
data.rows.map((r) => (
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
))
)}
</div>
</>
)}
</>
);
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
return (
<div className={`${SURFACE.card} p-3`}>
<Pressable
selectable
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="w-full flex items-center gap-3"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? t("stats.system") : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{formatNumber(row.total, lang)}
</span>
</Pressable>
{/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full"
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
/>
</div>
{open && actions.length > 0 && (
<div className="mt-2 space-y-1 text-xs text-muted">
{actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units, lang)}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -1,205 +0,0 @@
// @vitest-environment jsdom
//
// An audit row whose server summary is a fixed English line ("rss_poll: ok — new=5, failed=0",
// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import i18n, { setLanguage } from "../i18n";
import type { AuditRow } from "../lib/api";
import { auditColumns } from "./auditColumns";
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
// run that render landed after the test environment was gone ("window is not defined").
afterEach(() => {
cleanup();
setLanguage("en");
});
const row = (over: Partial<AuditRow>): AuditRow => ({
id: 1,
created_at: null,
actor: null,
action: "job_run",
target_type: null,
target_id: null,
summary: null,
before: null,
after: null,
...over,
});
function details(r: AuditRow): string {
setLanguage("hu");
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const { container } = render(<>{col.render!(r)}</>);
return [...container.querySelectorAll(".wrap-break-word")].map((e) => e.textContent).join(" | ");
}
describe("an audit row's details", () => {
it("name a job run and its result in the reader's language", () => {
const r = row({
target_type: "job",
target_id: "rss_poll",
summary: "rss_poll: ok — new=5, failed=0",
after: { status: "ok", summary: "new=5, failed=0" },
});
expect(details(r)).toBe("RSS-lekérdezés (új feltöltések) kész | új videó: 5 · sikertelen: 0");
});
it("keep a failed run's error text, which is diagnostic", () => {
const r = row({
target_type: "job",
target_id: "enrich",
after: { status: "error", summary: "HTTP 403: quotaExceeded" },
});
expect(details(r)).toBe(
"Nem sikerült lefuttatni: Adatdúsítás + élő frissítés | HTTP 403: quotaExceeded",
);
});
it("name a config change's field and values", () => {
const set = row({
action: "config_set",
target_type: "config",
target_id: "plex_enabled",
summary: "plex_enabled = True",
before: { value: false },
after: { value: true },
});
expect(details(set)).toMatch(/ \| Ki → Be$/);
expect(details(set)).not.toContain("plex_enabled");
const reset = row({
action: "config_reset",
target_type: "config",
target_id: "download_upload_max_bytes",
summary: "download_upload_max_bytes → default",
before: { value: 5242880 },
});
expect(details(reset)).toMatch(/ \| 5\s242\s880 → alapérték$/);
const secret = row({
action: "config_set",
target_type: "config",
target_id: "youtube_api_key",
summary: "youtube_api_key = (secret updated)",
});
expect(details(secret)).toMatch(/ \| titok frissítve/);
});
it("name a nested result and a skipped run from the row's data", () => {
const nested = row({
target_type: "job",
target_id: "people_enrich",
after: {
status: "ok",
summary: "bios={'enriched': 12}, portraits={'cached': 40}",
result: { bios: { enriched: 12 }, portraits: { cached: 40 } },
},
});
expect(details(nested)).not.toContain("{");
expect(details(nested)).toMatch(/^Személyadatok és portrék \(Plex\) kész \| /);
const skipped = row({
target_type: "job",
target_id: "rss_poll",
after: { status: "skipped", summary: "sync paused" },
});
expect(details(skipped)).toBe("RSS-lekérdezés (új feltöltések) kihagyva | a szinkron szünetel");
});
it("tell a secret's reset from its update, and name a choice", () => {
const reset = row({
action: "config_reset",
target_type: "config",
target_id: "tmdb_api_key",
summary: "tmdb_api_key → default",
});
expect(details(reset)).toMatch(/ \| titok visszaállítva/);
const choice = row({
action: "config_set",
target_type: "config",
target_id: "plex_transcode_hw",
before: { value: "auto" },
after: { value: "on" },
});
expect(details(choice)).toMatch(/ \| Automatikus → GPU$/);
});
it("name an interval, a batch and a count in the reader's language", () => {
expect(
details(
row({
action: "scheduler_interval",
target_type: "job",
target_id: "rss_poll",
summary: "rss_poll → 15 min/run",
after: { interval_minutes: 15 },
}),
),
).toBe("RSS-lekérdezés (új feltöltések) | 15 percenként");
expect(
details(row({ action: "maintenance_tune", after: { revalidate_batch: 50000 } })),
).toMatch(/^Futásonként újraellenőrzött videók \| 50\s000$/);
expect(
details(
row({ action: "audit_clear", summary: "Cleared 12 audit entries", after: { cleared: 12 } }),
),
).not.toContain("Cleared");
});
it("add nothing to a pause, whose action already says it", () => {
expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe("");
});
it("keep a summary that is data", () => {
expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe(
"someone@example.com",
);
});
});
// A folded run (lib/auditGroups) shows its newest run and opens to the earlier ones; a failed run
// is the line to see in a page of routine ones.
describe("a folded run of one job", () => {
const ok = (id: number, enriched: number) =>
row({
id,
target_type: "job",
target_id: "enrich",
after: { status: "ok", result: { enriched } },
});
it("names how many earlier runs it holds, and opens to each", () => {
setLanguage("hu");
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const runs = [ok(3, 50), ok(2, 40), ok(1, 30)];
const { getByRole, container } = render(<>{col.render!({ ...runs[0]!, runs })}</>);
const toggle = getByRole("button", { name: "2 korábbi futás" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector("li")).toBeNull();
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect([...container.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
"—dúsítva: 40",
"—dúsítva: 30",
]);
});
it("has no toggle for a lone run", () => {
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const { container } = render(<>{col.render!(ok(1, 5))}</>);
expect(container.querySelector("button")).toBeNull();
});
it("paints a failed run's line in the danger colour", () => {
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const failed = row({ target_type: "job", target_id: "enrich", after: { status: "error" } });
const { container } = render(<>{col.render!(failed)}</>);
expect(container.querySelector(".wrap-break-word")!.className).toContain("text-danger");
const { container: fine } = render(<>{col.render!(ok(2, 5))}</>);
expect(fine.querySelector(".wrap-break-word")!.className).not.toContain("text-danger");
});
});
-171
View File
@@ -1,171 +0,0 @@
import type { TFunction } from "i18next";
import clsx from "clsx";
import type { AuditRow } from "../lib/api";
import type { AuditListRow } from "../lib/auditGroups";
import AuditEarlierRuns from "./AuditEarlierRuns";
import { formatNumber } from "../lib/format";
import type { JobResult } from "../lib/api";
import { jobResultText, jobRunText } from "../lib/jobResult";
import type { Column } from "./ui/DataTable";
import { RelativeTime } from "./ui/RelativeTime";
function actionLabel(t: TFunction, action: string): string {
// Canonical key → localized label; falls back to the raw key for any unmapped action.
return t(`auditActions.${action}`, action);
}
function fmt(v: unknown): string {
return v == null || v === "" ? "—" : String(v);
}
/** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a
* choice is named as the Configuration page names it. */
function configValue(t: TFunction, key: string, v: unknown, lang: string): string {
if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off");
if (typeof v === "number") return formatNumber(v, lang);
if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v });
return fmt(v);
}
/** A row's `after` as a job-style result (counts), or null when it holds none. */
function counts(after: Record<string, unknown>): JobResult | null {
const out: JobResult = {};
for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v;
return Object.keys(out).length ? out : null;
}
/** The details of the rows whose server `summary` is a fixed English line, rebuilt from the row's
* data in the reader's language: a job run names the job and its result (as the inbox does), a config
* change names the field and its values, an interval or batch change its new value, a count-bearing
* action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or
* an older row without the data); the generic summary + change rendering applies. */
function localDetails(
t: TFunction,
row: AuditRow,
lang: string,
): { head: string | null; sub: string | null } | undefined {
const after = row.after ?? {};
const before = row.before ?? {};
if (row.action === "job_run" && row.target_type === "job" && row.target_id) {
const run = jobRunText(t, { job_id: row.target_id, ...after }, lang);
return { head: run.title, sub: run.body };
}
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
const key = row.target_id;
const head = t(`config.fields.${key}.label`, key);
const reset = row.action === "config_reset";
// A secret's value is never logged: there is no before/after to show.
if (!row.before && !row.after)
return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") };
const to = reset ? t("audit.default") : configValue(t, key, after.value, lang);
return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` };
}
if (
row.action === "scheduler_interval" &&
row.target_id &&
typeof after.interval_minutes === "number"
)
return {
head: t(`scheduler.jobs.${row.target_id}`, row.target_id),
sub: t("scheduler.everyMin", { count: after.interval_minutes }),
};
if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number")
return {
head: t("scheduler.maintenance.batchLabel"),
sub: formatNumber(after.revalidate_batch, lang),
};
if (row.action === "audit_clear" || row.action === "discovery_purge") {
const result = counts(after);
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
}
if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null };
return undefined;
}
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
function changeText(row: AuditRow): string | null {
const { before, after } = row;
if (before && after) {
return Object.keys(after)
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
.join(", ");
}
if (after) {
return Object.entries(after)
.map(([k, v]) => `${k}: ${fmt(v)}`)
.join(", ");
}
return null;
}
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
* the data (built by the page) so the Action column's select filter only offers real values. */
export function auditColumns(
t: TFunction,
lang: string,
actionOptions: { value: string; label: string }[],
): Column<AuditListRow>[] {
return [
{
key: "when",
header: t("audit.cols.when"),
nowrap: true,
sortable: true,
sortValue: (r) => r.created_at ?? "",
cardPrimary: true,
render: (r) => (
<RelativeTime at={r.created_at} seconds empty="—" className="text-muted tabular-nums" />
),
},
{
key: "actor",
header: t("audit.cols.actor"),
sortable: true,
sortValue: (r) => r.actor ?? "",
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
render: (r) =>
r.actor ? (
<span className="truncate">{r.actor}</span>
) : (
<span className="text-muted italic">{t("audit.system")}</span>
),
},
{
key: "action",
header: t("audit.cols.action"),
nowrap: true,
sortable: true,
sortValue: (r) => actionLabel(t, r.action),
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
},
{
key: "details",
header: t("audit.cols.details"),
render: (r) => {
const local = localDetails(t, r, lang);
const summary = local ? local.head : r.summary;
const change = local ? local.sub : changeText(r);
// A failed run is the row to see in a page of routine ones: its line in the danger colour.
const failed = r.action === "job_run" && r.after?.status === "error";
return (
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
// sizes to its longest row, so one long summary/change stretched the whole table past the
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0">
{summary && (
<div className={clsx("wrap-break-word", failed && "text-danger")}>{summary}</div>
)}
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
{r.runs && r.runs.length > 1 && (
<AuditEarlierRuns
runs={r.runs}
detailOf={(run) => localDetails(t, run, lang)?.sub ?? run.summary}
/>
)}
</div>
);
},
},
];
}
@@ -24,10 +24,7 @@ const CARDS = [
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
const EXEMPT: Record<string, string> = {
"components/AuditLog.tsx#DataTable#1":
"an admin log read in place: its rows open nothing, so stepping onto them would only move a ring",
};
const EXEMPT: Record<string, string> = {};
interface Tag {
start: number;
+2 -4
View File
@@ -32,8 +32,7 @@ const Stats = lazyWithPreload(() => import("./Stats"));
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
const AdminLogs = lazyWithPreload(() => import("./AdminLogs"));
const Activity = lazyWithPreload(() => import("./Activity"));
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
export type PageDef = {
@@ -60,8 +59,7 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog },
logs: { Component: AdminLogs },
activity: { Component: Activity },
settings: { Component: SettingsPanel },
};
+1
View File
@@ -13,6 +13,7 @@ function Skeleton({ className, inline = false }: { className?: string; inline?:
return (
<Tag
aria-hidden="true"
data-skeleton=""
className={clsx(
"rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none",
inline && "inline-block align-middle",
@@ -269,7 +269,7 @@ export function usePlaylistActions() {
id: "push",
label: linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube"),
icon: pushing.has(pl.id) ? (
<Upload className="w-4 h-4 animate-pulse" />
<Upload className="w-4 h-4 motion-safe:animate-pulse" />
) : linked ? (
<YouTubeIcon className="w-4 h-4" />
) : (
@@ -1,4 +1,17 @@
[
{
"version": "0.108.0",
"date": "2026-10-18",
"new": [
"Activity: one admin page for background runs, admin actions, the server log and quota, with one time range, account and search. A run opens to its outcome and its log lines, an admin action to its request's lines; old audit and log links open their tab.",
"Scheduler: each job shows its last three runs (a run cut short by a restart is marked) and a button opens all of them; turning on a channel's full history is now a run of its own, listed under Ad-hoc runs.",
"Configuration: how long the run history is kept (Activity › Run history, 180 days by default). Stats keeps your own numbers; the system quota view moved to Activity › Quota."
],
"fixed": [
"Configuration: saving a setting with the value already in force now keeps it, instead of dropping the change.",
"Configuration: the unused SponsorBlock default setting is gone."
]
},
{
"version": "0.107.3",
"date": "2026-10-17",
@@ -1,4 +1,17 @@
[
{
"version": "0.108.0",
"date": "2026-10-18",
"new": [
"Napló: egy admin oldal a háttérfutásoknak, az admin műveleteknek, a szervernaplónak és a kvótának, közös időszakkal, fiókkal és kereséssel. Egy futás az eredményét és naplósorait mutatja, egy admin művelet a kérése sorait; a régi linkek a fülüket nyitják.",
"Ütemező: minden feladat mutatja az utolsó három futását (az újraindítás miatt megszakadtat jelölve), egy gomb pedig mindet megnyitja; egy csatorna teljes előzményének bekapcsolása most saját futás, az Alkalmi futások között.",
"Konfiguráció: beállítható, meddig maradnak meg a futási előzmények (Napló › Futási előzmények, alapból 180 nap). A Statisztika a saját számaidat mutatja; a rendszerkvóta a Napló › Kvóta fülre költözött."
],
"fixed": [
"Konfiguráció: ha egy beállítást a már érvényes értékkel mentesz, most megmarad, nem vész el.",
"Konfiguráció: eltűnt a használatlan SponsorBlock-alapérték beállítás."
]
},
{
"version": "0.107.3",
"date": "2026-10-17",
+39 -5
View File
@@ -1,5 +1,7 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { RUN_STATUSES } from "../lib/activityUrlState";
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
@@ -10,7 +12,7 @@ import { vocabKey } from "../lib/vocab";
// the registries straight from the backend sources:
// - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint`, and for a
// "choice" key `config.choices.<key>.<option>` — the options read from its Literal in config.py
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - `sysconfig.JOB_INTERVAL_DEFAULTS` and `scheduler.AD_HOC_JOBS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
// and the other way round: a name left behind for an id the backend dropped is dead weight that reads
// as a live setting to the next person (`plex_sync_interval_min` moved to the scheduler long ago).
@@ -56,14 +58,31 @@ const choicesOf = (attr: string): string[] => {
const choiceIds = specs
.filter((s) => s.type === "choice")
.flatMap((s) => choicesOf(s.attr).map((c) => `config.choices.${s.key}.${c}`));
const schedulerPy = backend("scheduler.py");
// The interval jobs (sysconfig's `JOB_INTERVAL_DEFAULTS`: job → its default's setting), and the
// ad-hoc ones (`AD_HOC_JOBS` in scheduler.py, a tuple of constants named beside it).
const jobs = [
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]!
.matchAll(/"(\w+)": settings\./g),
].map((m) => m[1]!);
...[
...(backend("sysconfig.py").split("JOB_INTERVAL_DEFAULTS: dict[str, str] = {")[1] ?? "")
.split("}")[0]!
.matchAll(/"(\w+)": "\w+",/g),
].map((m) => m[1]!),
...[...(/^AD_HOC_JOBS = \(([^)]*)\)/m.exec(schedulerPy)?.[1] ?? "").matchAll(/\b[A-Z_]+\b/g)].map(
(m) => new RegExp(`^${m[0]} = "(\\w+)"$`, "m").exec(schedulerPy)?.[1] ?? m[0],
),
];
const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
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");
// 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
@@ -252,6 +271,8 @@ describe("backend ids have their names in both languages", () => {
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
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(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
@@ -280,10 +301,23 @@ describe("backend ids have their names in both languages", () => {
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"]) {
const keys = keysOf(lang);
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`, () => {
expect(
missing([
@@ -0,0 +1,73 @@
{
"title": "Activity",
"tabs": {
"runs": "Runs",
"admin": "Admin actions",
"logs": "Server log",
"quota": "Quota"
},
"intro": {
"runs": "Every background run, newest first — scheduled, started by an admin, or a user's work on one channel. A row opens to what it did or why it failed, and to the lines it logged.",
"admin": "An append-only record of admin actions — who changed what, and when. A row opens to its before and after, and to the lines its request logged.",
"logs": "The server's stored log — lines from the API, the downloader and browsers, kept until the retention period ends. A line opens in place; its request, job or run id keeps only that one's lines."
},
"search": "Search…",
"windowLabel": "Period",
"windows": {
"1h": "1 hour",
"24h": "24 hours",
"7d": "7 days",
"30d": "30 days",
"90d": "90 days",
"all": "All"
},
"account": "Account",
"any": "Any",
"noAccount": "No account (background)",
"older": "Load older",
"stale": "Couldn't refresh — what you see may be out of date",
"olderFailed": "Couldn't load the older rows",
"filtersFailed": "Couldn't load the filter choices: the menus may be missing some.",
"accountsFailed": "Couldn't load the accounts for the account filter.",
"runs": {
"job": "Job",
"statusLabel": "Filter by status",
"status": {
"error": "Failed",
"interrupted": "Interrupted",
"skipped": "Skipped",
"running": "Running",
"ok": "OK"
},
"empty": "No runs match the filters.",
"count_one": "{{count}} run shown · newest first",
"count_other": "{{count}} runs shown · newest first",
"units_one": "{{units}} unit",
"units_other": "{{units}} units",
"fields": {
"started": "Started by",
"when": "Start → end",
"target": "Target",
"quota": "Quota units"
},
"logLines_one": "Its {{count}} log line →",
"logLines_other": "Its {{count}} log lines →",
"noLogLines": "It logged no lines",
"jobRuns": "This job's runs →"
},
"audit": {
"action": "Action",
"target": "Target",
"requestLines": "Its request's log lines →",
"noRequestLines": "Its request's log lines are no longer kept",
"count_one": "{{count}} action shown · newest first",
"count_other": "{{count}} actions shown · newest first"
},
"quota": {
"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"
}
}
+5 -13
View File
@@ -1,17 +1,7 @@
{
"title": "Server log",
"intro": "The server's stored log — lines from the API, the downloader and browsers, kept until the retention period ends. A line opens in place; click its request or job id to keep only that request's or job's lines.",
"search": "Search the message, code, stack…",
"levelLabel": "Filter by level",
"source": "Source",
"account": "Account",
"event": "Event",
"window": "Period",
"windows": {
"1h": "1 hour",
"24h": "24 hours",
"7d": "7 days"
},
"any": "Any",
"noAccount": "No account",
"noEvent": "No code",
@@ -28,7 +18,6 @@
"empty": "No lines match the filters.",
"stale": "Couldn't refresh — what you see may be out of date",
"olderFailed": "Couldn't load the older lines",
"countsFailed": "Couldn't load the filter counts",
"count_one": "{{count}} line loaded · newest first",
"count_other": "{{count}} lines loaded · newest first",
"older": "Load older",
@@ -38,6 +27,9 @@
"account": "Account",
"request": "Request",
"job": "Job",
"version": "Version"
}
"version": "Version",
"run": "Run"
},
"pivotRun": "run {{id}}",
"showRun": "Only this run's lines"
}
+10 -14
View File
@@ -1,26 +1,22 @@
{
"title": "Audit log",
"intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.",
"empty": "No audit entries yet.",
"empty": "No admin actions match the filters.",
"system": "System",
"clear": "Clear audit log",
"clearTitle": "Clear the audit log?",
"clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)",
"cleared_one": "Cleared {{count}} audit entry",
"cleared_other": "Cleared {{count}} audit entries",
"clearFailed": "Couldn't clear the audit log",
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
"clear": "Clear admin actions",
"clearTitle": "Clear the admin actions?",
"clearConfirm": "Permanently delete every admin action entry? This can't be undone. (One entry recording that you cleared them is kept.)",
"cleared_one": "Cleared {{count}} admin action entry",
"cleared_other": "Cleared {{count}} admin action entries",
"clearFailed": "Couldn't clear the admin actions",
"on": "On",
"off": "Off",
"default": "default",
"secretUpdated": "secret updated (the value is never logged)",
"secretReset": "secret reset to its default (the value is never logged)",
"tokenRevoked": "revoked, with every session of its account",
"viaToken": "By API token {{token}}",
"cols": {
"when": "When",
"actor": "Who",
"action": "Action",
"details": "Details"
},
"earlierRuns_one": "{{count}} earlier run",
"earlierRuns_other": "{{count}} earlier runs"
}
}
@@ -16,8 +16,7 @@
"maintenance_tune": "Maintenance tuned",
"sync_pause": "Sync paused",
"sync_resume": "Sync resumed",
"job_run": "Scheduler job run",
"audit_clear": "Audit log cleared",
"audit_clear": "Admin actions cleared",
"download_credential_set": "Download cookies set",
"download_credential_delete": "Download cookies deleted",
"invite_delete": "Whitelist entry deleted",
+7 -7
View File
@@ -21,7 +21,7 @@
"downloads": "Downloads",
"plex": "Plex",
"tmdb": "TMDB",
"audit": "Audit log",
"audit": "Activity",
"diagnostics": "Diagnostics",
"issues": "Bug reports"
},
@@ -162,13 +162,13 @@
"label": "Worker concurrency",
"hint": "How many downloads the worker processes in parallel."
},
"sponsorblock_default": {
"label": "SponsorBlock by default",
"hint": "Enable SponsorBlock (skip sponsor segments) for newly created custom download profiles."
},
"audit_retention_days": {
"label": "Retention (days)",
"hint": "How long admin audit-log entries are kept before the audit-log cleanup job prunes them. 0 = keep forever. Default 180."
"label": "Admin actions (days)",
"hint": "How long admin action entries are kept before the cleanup job prunes them. 0 = keep forever. Default 180."
},
"job_run_retention_days": {
"label": "Run history (days)",
"hint": "How long the history of background runs (scheduled, run now, a channel's full history) is kept before the cleanup job prunes it. 0 = keep forever. Default 180."
},
"log_level": {
"label": "Log level",
+2 -3
View File
@@ -32,9 +32,8 @@
"signOut": "Sign out",
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}",
"audit": "Audit log",
"logs": "Server log",
"googleAddress": "Google account: {{email}}"
"googleAddress": "Google account: {{email}}",
"activity": "Activity"
},
"sync": {
"yours": "yours",
+2 -3
View File
@@ -18,9 +18,8 @@
"scheduler": "Scheduler",
"config": "Config",
"users": "Users",
"audit": "Audit",
"admin": "Admin",
"logs": "Logs",
"settings": "Settings"
"settings": "Settings",
"activity": "Activity"
}
}
+27 -11
View File
@@ -18,7 +18,7 @@
"everyMin_other": "every {{count}} min",
"runningNow": "Running now…",
"lastRun": "Last run {{time}}",
"neverRun": "Hasn't run yet this session",
"neverRun": "No run recorded yet",
"active": "active",
"nextRun": "next",
"quotaUsed": "{{used}} / {{budget}} units used today",
@@ -56,10 +56,11 @@
},
"dot": {
"running": "running now",
"ok": "last run OK",
"idle": "idle (not run yet this session)",
"error": "last run failed",
"skipped": "skipped (sync paused)"
"ok": "OK",
"idle": "no run recorded yet",
"error": "failed",
"skipped": "skipped (sync paused)",
"interrupted": "interrupted (the server restarted)"
},
"jobDesc": {
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your Feed lags behind YouTube.",
@@ -75,10 +76,11 @@
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
"audit_gc": "Prunes admin audit-log entries and YouTube quota history older than their retention windows (Configuration → Audit log, Quota). If it stops, both just keep growing — harmless but unbounded.",
"audit_gc": "Prunes admin action entries, background-run history and YouTube quota history older than their retention windows (Configuration → Activity, Quota). If it stops, all three just keep growing — harmless but unbounded.",
"log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.",
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.",
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later."
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later.",
"channel_backfill": "Turning on full history for a channel fetches its whole back-catalogue at once, as its own run. The Backfill job finishes what this one could not (a very large channel, low quota, or two of these already running)."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
@@ -95,9 +97,10 @@
"plex_watch_sync": "Plex watch sync (incremental)",
"plex_watch_reconcile": "Plex watch reconcile (full)",
"tmdb_enrich": "TMDB enrichment (Plex)",
"audit_gc": "Audit-log and quota-history cleanup",
"audit_gc": "Activity and quota-history cleanup",
"log_gc": "Log cleanup",
"people_enrich": "People details and portraits (Plex)"
"people_enrich": "People details and portraits (Plex)",
"channel_backfill": "A channel's full history"
},
"queue": {
"recentPending": "Channels to sync",
@@ -185,7 +188,10 @@
"syncpaused": "sync paused",
"lowquota": "low quota",
"noreadscope": "no YouTube read access",
"busy": "another sync is running"
"busy": "another sync is running",
"channelbusy": "this channel is already being backfilled",
"atcapacity": "two full-history backfills are already running — the scheduler finishes this one",
"nothingtodo": "nothing left to fetch"
},
"failure": {
"rss_all_failed_one": "The RSS feed did not answer for the one channel, {{channel}}: {{cause}}. Read through the API instead: {{read}}.",
@@ -196,5 +202,15 @@
"transport": "unreachable",
"apply": "could not be saved",
"error": "unexpected error"
}
},
"dotRun": "{{state}} · {{time}} · {{trigger}}",
"trigger": {
"scheduled": "on schedule",
"manual": "started by an admin",
"ad_hoc": "started by a user"
},
"interrupted": "interrupted: the server restarted during the run",
"adHocTitle": "Ad-hoc runs",
"adHocStarted": "started by a user",
"history": "This job's runs"
}
+4 -16
View File
@@ -1,19 +1,9 @@
{
"tabs": {
"overview": "Overview",
"system": "System"
},
"title": "API quota usage",
"rangeDays": "{{d}}",
"rangeLabel": "Time range",
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
"introSystem": "System",
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
"loading": "Loading…",
"noData": "No data.",
"dailyTotal": "Daily total ({{d}} · {{units}} units)",
"noUsageInRange": "No usage in this range.",
"dailyTooltip": "{{day}}: {{units}} units",
"system": "System (background)",
"sync": {
"myStatus": "My sync status",
@@ -49,10 +39,8 @@
"syncFailed": "Couldn't sync subscriptions",
"fullHistoryRequested_one": "Full history requested for {{count}} channel",
"fullHistoryRequested_other": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"admin": "Admin",
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
"resumeBackgroundSync": "Resume background sync",
"pauseBackgroundSync": "Pause background sync"
}
"fullHistoryFailed": "Couldn't request full history"
},
"dailyTooltip_one": "{{day}}: {{units}} unit",
"dailyTooltip_other": "{{day}}: {{units}} units"
}
@@ -0,0 +1,73 @@
{
"title": "Napló",
"tabs": {
"runs": "Futások",
"admin": "Admin műveletek",
"logs": "Szervernapló",
"quota": "Kvóta"
},
"intro": {
"runs": "Minden háttérfutás, a legújabbal kezdve — ütemezett, adminisztrátor indította, vagy egy felhasználó munkája egy csatornán. Egy sor kinyitva megmutatja, mit végzett vagy miért hiúsult meg, és a naplósorait.",
"admin": "Az adminisztrátori műveletek csak bővülő nyilvántartása — ki mit változtatott és mikor. Egy sor kinyitva megmutatja az előtte–utána állapotot és a kérése naplósorait.",
"logs": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés-, feladat- vagy futásazonosítójára kattintva csak az ahhoz tartozó sorok maradnak."
},
"search": "Keresés…",
"windowLabel": "Időtartomány",
"windows": {
"1h": "1 óra",
"24h": "24 óra",
"7d": "7 nap",
"30d": "30 nap",
"90d": "90 nap",
"all": "Mind"
},
"account": "Fiók",
"any": "Bármelyik",
"noAccount": "Fiók nélkül (háttér)",
"older": "Régebbiek",
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat",
"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": {
"job": "Feladat",
"statusLabel": "Szűrés állapotra",
"status": {
"error": "Hibás",
"interrupted": "Megszakadt",
"skipped": "Kihagyva",
"running": "Fut",
"ok": "Rendben"
},
"empty": "Nincs a szűrőknek megfelelő futás.",
"count_one": "{{count}} futás látszik · a legújabb elöl",
"count_other": "{{count}} futás látszik · a legújabb elöl",
"units_one": "{{units}} egység",
"units_other": "{{units}} egység",
"fields": {
"started": "Indította",
"when": "Kezdés → vége",
"target": "Cél",
"quota": "Kvótaegység"
},
"logLines_one": "A futás {{count}} naplósora →",
"logLines_other": "A futás {{count}} naplósora →",
"noLogLines": "Nem naplózott sort",
"jobRuns": "Ennek a feladatnak a futásai →"
},
"audit": {
"action": "Művelet",
"target": "Cél",
"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_other": "{{count}} művelet látszik · a legújabb elöl"
},
"quota": {
"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"
}
}
+5 -13
View File
@@ -1,17 +1,7 @@
{
"title": "Szervernapló",
"intro": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés- vagy feladat-azonosítójára kattintva csak az ahhoz tartozó sorok maradnak.",
"search": "Keresés az üzenetben, a kódban, a hívási veremben…",
"levelLabel": "Szűrés szintre",
"source": "Forrás",
"account": "Fiók",
"event": "Esemény",
"window": "Időszak",
"windows": {
"1h": "1 óra",
"24h": "24 óra",
"7d": "7 nap"
},
"any": "Bármi",
"noAccount": "Fiók nélkül",
"noEvent": "Kód nélkül",
@@ -28,7 +18,6 @@
"empty": "Nincs a szűrésnek megfelelő sor.",
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat",
"countsFailed": "Nem sikerült betölteni a szűrők számait",
"count_one": "{{count}} sor betöltve · a legújabb felül",
"count_other": "{{count}} sor betöltve · a legújabb felül",
"older": "Régebbiek betöltése",
@@ -38,6 +27,9 @@
"account": "Fiók",
"request": "Kérés",
"job": "Feladat",
"version": "Verzió"
}
"version": "Verzió",
"run": "Futás"
},
"pivotRun": "futás: {{id}}",
"showRun": "Csak ennek a futásnak a sorai"
}
+10 -14
View File
@@ -1,26 +1,22 @@
{
"title": "Eseménynapló",
"intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.",
"empty": "Még nincs naplóbejegyzés.",
"empty": "Nincs a szűrőknek megfelelő admin művelet.",
"system": "Rendszer",
"clear": "Eseménynapló ürítése",
"clearTitle": "Üríted az eseménynaplót?",
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
"cleared_one": "{{count}} naplóbejegyzés törölve",
"cleared_other": "{{count}} naplóbejegyzés törölve",
"clearFailed": "Nem sikerült üríteni az eseménynaplót",
"truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.",
"clear": "Admin műveletek ürítése",
"clearTitle": "Üríted az admin műveleteket?",
"clearConfirm": "Véglegesen törlöd az összes admin művelet bejegyzését? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
"cleared_one": "{{count}} bejegyzés törölve",
"cleared_other": "{{count}} bejegyzés törölve",
"clearFailed": "Nem sikerült üríteni az admin műveleteket",
"on": "Be",
"off": "Ki",
"default": "alapérték",
"secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)",
"secretReset": "titok visszaállítva az alapértékre (az értéke sosem kerül a naplóba)",
"tokenRevoked": "visszavonva, a fiók minden munkamenetével együtt",
"viaToken": "API-tokennel: {{token}}",
"cols": {
"when": "Mikor",
"actor": "Ki",
"action": "Művelet",
"details": "Részletek"
},
"earlierRuns_one": "{{count}} korábbi futás",
"earlierRuns_other": "{{count}} korábbi futás"
}
}
@@ -16,8 +16,7 @@
"maintenance_tune": "Karbantartás hangolva",
"sync_pause": "Szinkron szüneteltetve",
"sync_resume": "Szinkron folytatva",
"job_run": "Ütemezett feladat lefutott",
"audit_clear": "Eseménynapló ürítve",
"audit_clear": "Admin műveletek ürítve",
"download_credential_set": "Letöltési cookie-k beállítva",
"download_credential_delete": "Letöltési cookie-k törölve",
"invite_delete": "Fehérlista-bejegyzés törölve",
+7 -7
View File
@@ -21,7 +21,7 @@
"downloads": "Letöltések",
"plex": "Plex",
"tmdb": "TMDB",
"audit": "Eseménynapló",
"audit": "Napló",
"diagnostics": "Diagnosztika",
"issues": "Hibajegyek"
},
@@ -162,13 +162,13 @@
"label": "Párhuzamos letöltések",
"hint": "Hány letöltést dolgoz fel egyszerre a letöltő folyamat."
},
"sponsorblock_default": {
"label": "SponsorBlock alapból",
"hint": "SponsorBlock (szponzorszakaszok átugrása) bekapcsolása az újonnan létrehozott egyéni letöltési profilokhoz."
},
"audit_retention_days": {
"label": "Megőrzés (nap)",
"hint": "Meddig maradnak meg az admin-naplóbejegyzések, mielőtt az eseménynapló-takarító feladat törli őket. 0 = örökre. Alapérték 180."
"label": "Admin műveletek (nap)",
"hint": "Meddig maradnak meg az admin műveletek bejegyzései, mielőtt a takarító feladat törli őket. 0 = örökre. Alapérték 180."
},
"job_run_retention_days": {
"label": "Futási előzmények (nap)",
"hint": "Meddig maradnak meg a háttérfutások (ütemezett, azonnali, egy csatorna teljes előzménye) adatai, mielőtt a takarító feladat törli őket. 0 = örökre. Alapérték 180."
},
"log_level": {
"label": "Naplózási szint",
+2 -3
View File
@@ -32,9 +32,8 @@
"signOut": "Kijelentkezés",
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}",
"audit": "Eseménynapló",
"logs": "Szervernapló",
"googleAddress": "Google-fiók: {{email}}"
"googleAddress": "Google-fiók: {{email}}",
"activity": "Napló"
},
"sync": {
"yours": "tiéd",
+2 -3
View File
@@ -18,9 +18,8 @@
"scheduler": "Ütemező",
"config": "Konfig",
"users": "Felhasználók",
"audit": "Eseménynapló",
"admin": "Admin",
"logs": "Napló",
"settings": "Beállítások"
"settings": "Beállítások",
"activity": "Napló"
}
}
+27 -11
View File
@@ -18,7 +18,7 @@
"everyMin_other": "{{count}} percenként",
"runningNow": "Most fut…",
"lastRun": "Utolsó futás {{time}}",
"neverRun": "Ebben a munkamenetben még nem futott",
"neverRun": "Még nincs rögzített futás",
"active": "aktív",
"nextRun": "köv.",
"quotaUsed": "{{used}} / {{budget}} egység ma felhasználva",
@@ -56,10 +56,11 @@
},
"dot": {
"running": "most fut",
"ok": "utolsó futás OK",
"idle": "tétlen (ebben a munkamenetben még nem futott)",
"error": "utolsó futás hibázott",
"skipped": "kihagyva (szinkron szüneteltetve)"
"ok": "rendben",
"idle": "még nincs rögzített futás",
"error": "hibával ért véget",
"skipped": "kihagyva (szinkron szüneteltetve)",
"interrupted": "megszakadt (újraindult a szerver)"
},
"jobDesc": {
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor a videók behúzása eléri őket — így a Feed lemarad a YouTube mögött.",
@@ -75,10 +76,11 @@
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.",
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
"plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.",
"audit_gc": "Törli a megőrzési időnél régebbi admin-naplóbejegyzéseket és YouTube-kvótaelőzményeket (Konfiguráció → Eseménynapló, Kvóta). Ha leáll, mindkettő csak nő — ártalmatlan, de korlátlanul.",
"audit_gc": "Törli a megőrzési időnél régebbi admin műveleteket, háttérfutás-előzményeket és YouTube-kvótaelőzményeket (Konfiguráció → Napló, Kvóta). Ha leáll, mindhárom csak nő — ártalmatlan, de korlátlanul.",
"log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.",
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.",
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később."
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később.",
"channel_backfill": "Ha egy csatornánál bekapcsolod a teljes előzményt, a teljes korábbi kínálata egyszerre, saját futásként töltődik le. Amit ez nem tud befejezni (egy nagyon nagy csatorna, kevés kvóta, vagy ha már két ilyen fut), azt a „Videók behúzása” feladat fejezi be."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -95,9 +97,10 @@
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
"plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)",
"tmdb_enrich": "TMDB-dúsítás (Plex)",
"audit_gc": "Eseménynapló- és kvótaelőzmény-takarítás",
"audit_gc": "Napló- és kvótaelőzmény-takarítás",
"log_gc": "Naplótakarítás",
"people_enrich": "Személyadatok és portrék (Plex)"
"people_enrich": "Személyadatok és portrék (Plex)",
"channel_backfill": "Egy csatorna teljes előzménye"
},
"queue": {
"recentPending": "Szinkronra váró csatorna",
@@ -185,7 +188,10 @@
"syncpaused": "a szinkron szünetel",
"lowquota": "kevés a kvóta",
"noreadscope": "nincs YouTube-olvasási jogosultság",
"busy": "egy másik szinkron fut"
"busy": "egy másik szinkron fut",
"channelbusy": "ennek a csatornának a betöltése már fut",
"atcapacity": "már két teljes előzmény-betöltés fut — ezt az ütemező fejezi be",
"nothingtodo": "nincs több letöltendő"
},
"failure": {
"rss_all_failed_one": "Az RSS-hírcsatorna az egyetlen csatornánál sem válaszolt ({{channel}}): {{cause}}. Az API-n át beolvasva helyette: {{read}}.",
@@ -196,5 +202,15 @@
"transport": "nem érhető el",
"apply": "nem sikerült menteni",
"error": "váratlan hiba"
}
},
"dotRun": "{{state}} · {{time}} · {{trigger}}",
"trigger": {
"scheduled": "ütemezve",
"manual": "admin indította",
"ad_hoc": "felhasználó indította"
},
"interrupted": "megszakadt: a futás közben újraindult a szerver",
"adHocTitle": "Alkalmi futások",
"adHocStarted": "felhasználó indítja",
"history": "A feladat futásai"
}
+4 -16
View File
@@ -1,19 +1,9 @@
{
"tabs": {
"overview": "Áttekintés",
"system": "Rendszer"
},
"title": "API-kvóta használat",
"rangeDays": "{{d}}",
"rangeLabel": "Időtartomány",
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
"introSystem": "Rendszer",
"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…",
"noData": "Nincs adat.",
"dailyTotal": "Napi összesen ({{d}} · {{units}} egység)",
"noUsageInRange": "Nincs használat ebben az időszakban.",
"dailyTooltip": "{{day}}: {{units}} egység",
"system": "Rendszer (háttér)",
"sync": {
"myStatus": "Saját szinkronizálási állapot",
@@ -49,10 +39,8 @@
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
"fullHistoryRequested_one": "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",
"admin": "Adminisztrátor",
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
}
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni"
},
"dailyTooltip_one": "{{day}}: {{units}} egység",
"dailyTooltip_other": "{{day}}: {{units}} egység"
}
@@ -16,12 +16,7 @@ import { describe, expect, it } from "vitest";
// keys — so it covers nothing; a key it reads that nothing else names is listed below. Plural forms
// count through their base key. A key read in a way this cannot see goes in ALLOWED with the reason,
// rather than the check being loosened for everyone.
const LOG_FAILURE = "AdminLogs reads `adminLogs.${failure}`, a word its query hook returns";
const ALLOWED: Record<string, string> = {
"adminLogs.olderFailed": LOG_FAILURE,
"adminLogs.stale": LOG_FAILURE,
"adminLogs.countsFailed": LOG_FAILURE,
};
const ALLOWED: Record<string, string> = {};
// Namespaces that are one backend enum each, and held key-for-value to it by backendIds.contract.
const BACKEND_ENUMS = ["auditActions.", "quotaActions."];
+65
View File
@@ -0,0 +1,65 @@
// The activity page's view in the address bar: a link carries the tab and every filter, a hand-edited
// value the API would refuse degrades to "no filter", a filter at its default leaves no key behind,
// and a link to a page this one replaced opens that page's tab.
import { describe, expect, it } from "vitest";
import {
ACTIVITY_KEYS,
activityParamsToState,
activityParamUpdates,
DEFAULT_ACTIVITY_STATE,
sinceOf,
ACTIVITY_WINDOWS,
type ActivityUrlState,
} from "./activityUrlState";
const params = (updates: Record<string, string | null>) =>
new URLSearchParams(Object.entries(updates).filter((e): e is [string, string] => e[1] !== null));
describe("activityUrlState", () => {
it("round-trips the tab and every filter", () => {
const state: ActivityUrlState = {
tab: "admin",
window: "30d",
user: "none",
q: "100% done",
job: "channel_backfill",
status: "interrupted",
action: "config_set",
};
expect(activityParamsToState(params(activityParamUpdates(state)))).toEqual(state);
});
it("leaves no key for a filter at its default, and opens on the runs of the last 7 days", () => {
expect(
Object.values(activityParamUpdates(DEFAULT_ACTIVITY_STATE)).every((v) => v === null),
).toBe(true);
expect([DEFAULT_ACTIVITY_STATE.tab, DEFAULT_ACTIVITY_STATE.window]).toEqual(["runs", "7d"]);
});
it("drops a value the API would refuse instead of sending it", () => {
const state = activityParamsToState(
new URLSearchParams(
"at=everything&aw=1y&au=me&rjob=Not-A-Job&rst=finished&aact=DROP%20TABLE",
),
);
expect(state).toEqual(DEFAULT_ACTIVITY_STATE);
});
it("clears its own keys and the server log's on a page change", () => {
expect(ACTIVITY_KEYS).toEqual(expect.arrayContaining(["at", "aw", "au", "aq", "rjob", "lreq"]));
});
it("starts the window that many hours back", () => {
const now = Date.UTC(2026, 9, 18, 12);
expect(sinceOf("24h", now)).toBe("2026-10-17T12: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");
});
});
+104
View File
@@ -0,0 +1,104 @@
// The activity page's view in the address bar (`?page=activity&at=runs&aw=24h&rjob=backfill`), so a
// filtered view is a link: which tab, the filters every tab shares (time range, account, text), and
// 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
// re-mirrors them while it is open.
import type { RunStatus } from "./api";
import { LOGS_KEYS } from "./logsUrlState";
export type ActivityTab = "runs" | "admin" | "logs" | "quota";
export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
/** The run statuses the list can narrow to: the API's `RunStatus` (held by backendIds.contract.test). */
export type RunStatusFilter = RunStatus;
export interface ActivityUrlState {
tab: ActivityTab;
window: ActivityWindow;
/** An account id, or `none` for what no account did (the background). */
user: string | null;
q: string;
/** Runs: one job's runs. */
job: string | null;
status: RunStatusFilter | null;
/** Admin actions: one action. */
action: string | null;
}
export const ACTIVITY_TABS: readonly ActivityTab[] = ["runs", "admin", "logs", "quota"];
// `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[] = [
"error",
"interrupted",
"skipped",
"running",
"ok",
];
const WINDOW_HOURS: Record<ActivityWindow, number | null> = {
"1h": 1,
"24h": 24,
"7d": 24 * 7,
"30d": 24 * 30,
"90d": 24 * 90,
all: null,
};
export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = {
tab: "runs",
window: "7d",
user: null,
q: "",
job: null,
status: null,
action: null,
};
const OWN_KEYS = ["at", "aw", "au", "aq", "rjob", "rst", "aact"] as const;
export const ACTIVITY_KEYS = [...OWN_KEYS, ...LOGS_KEYS] as const;
// The same shapes the API validates: a hand-edited link degrades to "no filter" here instead of to
// a 422 on every request the page makes.
const USER_RE = /^(none|\d{1,10})$/;
const ID_RE = /^[a-z][a-z0-9_]*$/;
const Q_MAX = 200;
const pick = (value: string | null, re: RegExp, max: number) =>
value && value.length <= max && re.test(value) ? value : null;
const oneOf = <T extends string>(value: string | null, list: readonly T[]): T | null =>
list.includes(value as T) ? (value as T) : null;
export function activityParamsToState(params: URLSearchParams): ActivityUrlState {
return {
// A link to a page this one replaced arrives with its tab already set (urlState's RETIRED).
tab: oneOf(params.get("at"), ACTIVITY_TABS) ?? DEFAULT_ACTIVITY_STATE.tab,
window: oneOf(params.get("aw"), ACTIVITY_WINDOWS) ?? DEFAULT_ACTIVITY_STATE.window,
user: pick(params.get("au"), USER_RE, 10),
q: (params.get("aq") ?? "").slice(0, Q_MAX),
job: pick(params.get("rjob"), ID_RE, 40),
status: oneOf(params.get("rst"), RUN_STATUSES),
action: pick(params.get("aact"), ID_RE, 48),
};
}
/** The URL updates for a state: a filter at its default clears its key. */
export function activityParamUpdates(state: ActivityUrlState): Record<string, string | null> {
const d = DEFAULT_ACTIVITY_STATE;
return {
at: state.tab === d.tab ? null : state.tab,
aw: state.window === d.window ? null : state.window,
au: state.user,
aq: state.q.trim() ? state.q : null,
rjob: state.job,
rst: state.status,
aact: state.action,
};
}
/** 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 {
const hours = WINDOW_HOURS[window];
return new Date(hours === null ? 0 : now - hours * 3_600_000).toISOString();
}
+120 -12
View File
@@ -95,18 +95,72 @@ export interface AdminUserAccess extends AdminUserRow {
export interface AuditRow {
id: number;
created_at: string | null;
actor: string | null; // email of the acting admin; null = System (scheduler/background)
/** The acting admin (address in the page's `accounts`); null = the background. */
actor_id: number | null;
action: string; // canonical key, localized on the client via auditActions.<action>
target_type: string | null;
target_id: string | null;
summary: string | null;
before: 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. */
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;
}
export interface AuditListResult {
items: AuditRow[];
total: number; // total rows in the log (may exceed `items` if capped)
returned: number;
/** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */
export interface HistoryPage<T> {
items: T[];
/** The accounts this page names, by id; an erased one is absent. */
accounts: Record<string, string>;
next_cursor: string | null;
}
/** The filters every activity tab shares, as the APIs take them. */
export interface SharedQuery {
/** An account id, or `none` for what no account did. */
user?: string;
q?: string;
since?: string;
}
export interface AuditQuery extends SharedQuery {
action?: string;
}
/** One background run (`job_runs`), as the activity page lists it. */
export interface RunRow {
id: number;
job: string;
trigger: RunBrief["trigger"];
actor_id: number | null;
/** What a user's ad-hoc run worked on (a channel id), and that channel's title. */
target: string | null;
target_title: string | null;
status: RunBrief["status"];
started_at: string;
finished_at: string | null;
summary: string | null;
result: JobResult | null;
error: string | 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;
/** How many stored log lines the run left (WARNING and up only). */
log_lines: number;
}
export interface RunQuery extends SharedQuery {
job?: string;
status?: string;
}
/** The job menu and the status chips, each counted under every filter but its own. */
export interface RunFacets {
jobs: LogFacet[];
statuses: LogFacet[];
total: number;
}
/** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's
@@ -124,6 +178,7 @@ export interface ServerLogRow {
request_id: string | null;
user_id: number | null;
job_id: number | null;
run_id: number | null;
debug_session_id: string | null;
version: string | null;
}
@@ -136,6 +191,7 @@ export interface LogQuery {
event?: string;
request_id?: string;
job_id?: number;
run_id?: number;
q?: string;
since?: string;
}
@@ -171,6 +227,7 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
event: query.event,
request_id: query.request_id,
job_id: query.job_id,
run_id: query.run_id,
q: query.q,
since: query.since,
...extra,
@@ -182,14 +239,37 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
return s ? `?${s}` : "";
}
/** `?a=1&b=x` from the set values (an empty string or null is left out), or "" when none is. */
function queryString(values: object): string {
const p = new URLSearchParams();
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
}
const s = p.toString();
return s ? `?${s}` : "";
}
export type JobResult = { [key: string]: number | string | JobResult };
export interface SchedulerJob {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
status: "ok" | "error" | "skipped" | null;
/** 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 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 RunStatus = RunOutcome | "running";
/** One past run, as the row's history dots show it. */
export interface RunBrief {
id: number;
status: RunOutcome | "running";
trigger: "scheduled" | "manual" | "ad_hoc";
started_at: string;
finished_at: string | null;
}
/** The fields every Scheduler row has from its runs: how the newest ended one went, and the few
* before it. */
export interface RunFields {
status: RunOutcome | null;
last_started: string | null;
last_finished: string | null;
last_result: string | null; // the English one-liner — the fallback for a job with no `result`
@@ -197,16 +277,37 @@ export interface SchedulerJob {
// named in the reader's language by lib/jobResult.
result: JobResult | null;
last_error: string | null;
last_trigger: RunBrief["trigger"] | null;
last_run_id: number | null;
quota_units: number | null;
/** The newest runs, newest first (at most three). */
recent: RunBrief[];
}
export interface SchedulerJob extends RunFields {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
// Live progress while running (null when idle or for jobs that don't report it).
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
progress: { current: number; total: number | null; phase: string | null } | null;
}
/** Work a user starts on one target (a channel's full history): no interval, no "run now". */
export interface SchedulerAdHoc extends RunFields {
id: string;
running: boolean;
target: string | null;
target_title: string | null;
}
export interface SchedulerStatus {
running_here: boolean;
enabled: boolean;
paused: boolean;
jobs: SchedulerJob[];
ad_hoc: SchedulerAdHoc[];
queue: {
channels_recent_pending: number;
channels_deep_pending: number;
@@ -245,7 +346,14 @@ export const adminApi = {
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
req("/api/admin/config/test-issues", { method: "POST" }),
// --- users & roles ---
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
adminAudit: (query: AuditQuery, before: string | null): Promise<HistoryPage<AuditRow>> =>
req(`/api/admin/audit${queryString({ ...query, before })}`),
adminAuditFacets: (query: AuditQuery): Promise<{ actions: LogFacet[] }> =>
req(`/api/admin/audit/facets${queryString(query)}`),
adminRuns: (query: RunQuery, before: string | null): Promise<HistoryPage<RunRow>> =>
req(`/api/admin/runs${queryString({ ...query, before })}`),
adminRunFacets: (query: RunQuery): Promise<RunFacets> =>
req(`/api/admin/runs/facets${queryString(query)}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
// --- diagnostics console ---
adminLogs: (query: LogQuery, before: string | null): Promise<LogPage> =>
+3 -2
View File
@@ -18,12 +18,13 @@ export interface AdminQuotaRow {
}
export interface AdminQuota {
range_days: number;
rows: AdminQuotaRow[];
daily: { day: string; total: number }[];
}
export const quotaApi = {
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
/** Spend from `since` to now, optionally for one account (`none` = the background). */
adminQuota: (since: string, user: string | null): Promise<AdminQuota> =>
req(`/api/quota/admin?${new URLSearchParams({ since, ...(user ? { user } : {}) }).toString()}`),
};

Some files were not shown because too many files have changed in this diff Show More