diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json
index 824be795..af17ec70 100644
--- a/backend/.basedpyright/baseline.json
+++ b/backend/.basedpyright/baseline.json
@@ -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": {
diff --git a/backend/alembic/versions/0111_audit_request_id.py b/backend/alembic/versions/0111_audit_request_id.py
new file mode 100644
index 00000000..cbf2832d
--- /dev/null
+++ b/backend/alembic/versions/0111_audit_request_id.py
@@ -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")
diff --git a/backend/app/audit.py b/backend/app/audit.py
index 52349979..0123a173 100644
--- a/backend/app/audit.py
+++ b/backend/app/audit.py
@@ -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
@@ -102,6 +103,7 @@ def record(
summary=(summary or None) and summary[:255],
before=before,
after=after,
+ request_id=logs.current_request_id(),
)
)
diff --git a/backend/app/logs.py b/backend/app/logs.py
index fc6551bd..3cd315fa 100644
--- a/backend/app/logs.py
+++ b/backend/app/logs.py
@@ -93,6 +93,11 @@ _CONTEXT_VARS = {
}
+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()
diff --git a/backend/app/main.py b/backend/app/main.py
index 9fd2f132..1c5c00c6 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 2b2d2730..d9914a0f 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -572,11 +572,12 @@ class QuotaEvent(Base):
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"
@@ -593,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):
diff --git a/backend/app/routes/_keyset.py b/backend/app/routes/_keyset.py
new file mode 100644
index 00000000..f57368a4
--- /dev/null
+++ b/backend/app/routes/_keyset.py
@@ -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}
diff --git a/backend/app/routes/admin_logs.py b/backend/app/routes/admin_logs.py
index 60e2d50b..6ec025cf 100644
--- a/backend/app/routes/admin_logs.py
+++ b/backend/app/routes/admin_logs.py
@@ -8,33 +8,28 @@ 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._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
@@ -47,35 +42,6 @@ Facet = Literal["levels", "sources", "user", "event"]
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}
-
-
@dataclass(frozen=True)
class LogFilters:
"""The console's filters, shared by the page and its facet counts."""
@@ -117,9 +83,9 @@ class LogFilters:
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(
@@ -165,28 +131,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,
}
@@ -217,6 +175,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,
}
diff --git a/backend/app/routes/audit.py b/backend/app/routes/audit.py
index 1c007231..bc0c8986 100644
--- a/backend/app/routes/audit.py
+++ b/backend/app/routes/audit.py
@@ -1,11 +1,14 @@
"""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
@@ -13,55 +16,123 @@ 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.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
+# `none` selects the rows no account made (the background) — a value to filter on, not "any".
+NONE = "none"
-def _serialize(row: AuditLog, actors: dict[int, str]) -> dict:
+def _serialize(row: AuditLog) -> 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,
}
+@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(
+ AuditLog.actor_id.is_(None)
+ if self.actor == NONE
+ else AuditLog.actor_id == int(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=r"^(none|[0-9]{1,10})$"),
+ 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())
return {
- "items": [_serialize(r, actors) for r in rows],
- "total": total,
- "returned": len(rows),
+ "items": [_serialize(r) 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)."""
diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py
index 053b3627..343bcb56 100644
--- a/backend/app/routes/config.py
+++ b/backend/app/routes/config.py
@@ -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)
diff --git a/backend/app/routes/quota.py b/backend/app/routes/quota.py
index 4db9508d..1f4800c7 100644
--- a/backend/app/routes/quota.py
+++ b/backend/app/routes/quota.py
@@ -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,7 @@ 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._keyset import utc
router = APIRouter(prefix="/api/quota", tags=["quota"])
@@ -45,23 +46,34 @@ 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=r"^(none|[0-9]{1,10})$"),
_: 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(
+ QuotaEvent.user_id.is_(None) if user == "none" else QuotaEvent.user_id == int(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 +96,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}
diff --git a/backend/app/routes/runs.py b/backend/app/routes/runs.py
new file mode 100644
index 00000000..c39fd244
--- /dev/null
+++ b/backend/app/routes/runs.py
@@ -0,0 +1,161 @@
+"""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._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"])
+
+# `none` selects the runs no account started (the scheduled ones).
+NONE = "none"
+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(
+ JobRun.actor_id.is_(None)
+ if self.actor == NONE
+ else JobRun.actor_id == int(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=r"^(none|[0-9]{1,10})$"),
+ 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,
+ }
diff --git a/backend/tests/test_activity_reads.py b/backend/tests/test_activity_reads.py
new file mode 100644
index 00000000..f5b3f69c
--- /dev/null
+++ b/backend/tests/test_activity_reads.py
@@ -0,0 +1,155 @@
+"""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.models import AuditLog, Channel, JobRun, LogEvent, QuotaEvent
+
+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_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()
+ 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}
diff --git a/backend/tests/test_sysconfig_registry.py b/backend/tests/test_sysconfig_registry.py
index 87333fa2..fbcc6667 100644
--- a/backend/tests/test_sysconfig_registry.py
+++ b/backend/tests/test_sysconfig_registry.py
@@ -22,6 +22,7 @@ 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"}
@@ -180,3 +181,14 @@ def test_the_intervals_are_keys_of_the_scheduler_page_only():
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
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index f8774b57..e417c297 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -668,127 +668,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 ``" | `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 `` | 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 `