From 24448e7c6f31994fee2019915d198d0d5ca01852 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 18 Oct 2026 03:45:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(activity):=20one=20Activity=20page=20?= =?UTF-8?q?=E2=80=94=20runs,=20admin=20actions,=20server=20log=20and=20quo?= =?UTF-8?q?ta=20under=20one=20filter=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab), each with its own filters, and the background runs had no list at all. Now one page ("Napló" / "Activity") reads all four, each still its own table on the server: a shared bar (text, a period of 1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL. The old page ids open their tab. Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The quota breakdown takes since/until and one account. The Scheduler rows get a history button to their job's runs. Also fixed on the way: re-saving the value in force on the Configuration page staged its override without committing it (the commit sat inside the audit branch), so the save was lost. --- backend/.basedpyright/baseline.json | 34 -- .../alembic/versions/0111_audit_request_id.py | 28 ++ backend/app/audit.py | 12 +- backend/app/logs.py | 5 + backend/app/main.py | 2 + backend/app/models.py | 12 +- backend/app/routes/_keyset.py | 66 +++ backend/app/routes/admin_logs.py | 80 +-- backend/app/routes/audit.py | 125 ++++- backend/app/routes/config.py | 4 +- backend/app/routes/quota.py | 30 +- backend/app/routes/runs.py | 161 ++++++ backend/tests/test_activity_reads.py | 155 ++++++ backend/tests/test_sysconfig_registry.py | 12 + docs/DESIGN.md | 250 +++++----- frontend/e2e/a11y.spec.ts | 2 +- frontend/e2e/nav.spec.ts | 2 +- frontend/src/components/Activity.dom.test.tsx | 208 ++++++++ frontend/src/components/Activity.tsx | 190 ++++++++ frontend/src/components/ActivityAudit.tsx | 214 ++++++++ frontend/src/components/ActivityQuota.tsx | 137 ++++++ frontend/src/components/ActivityRuns.tsx | 257 ++++++++++ .../src/components/AdminLogs.dom.test.tsx | 85 +++- frontend/src/components/AdminLogs.tsx | 460 ++++++++---------- frontend/src/components/AuditLog.tsx | 105 ---- frontend/src/components/HistoryList.tsx | 84 ++++ frontend/src/components/HistoryRow.tsx | 55 +++ .../src/components/IssueComposer.dom.test.tsx | 1 + .../NavigationProvider.dom.test.tsx | 7 +- .../src/components/NavigationProvider.tsx | 4 +- frontend/src/components/RunStatusDot.tsx | 29 ++ .../src/components/Scheduler.dom.test.tsx | 35 ++ frontend/src/components/Scheduler.tsx | 58 ++- frontend/src/components/Stats.tsx | 218 +-------- .../listNav.wiring.contract.test.ts | 5 +- frontend/src/components/moduleRegistry.tsx | 6 +- frontend/src/i18n/locales/en/activity.json | 63 +++ frontend/src/i18n/locales/en/adminLogs.json | 17 +- frontend/src/i18n/locales/en/audit.json | 4 - frontend/src/i18n/locales/en/header.json | 5 +- frontend/src/i18n/locales/en/nav.json | 5 +- frontend/src/i18n/locales/en/scheduler.json | 3 +- frontend/src/i18n/locales/en/stats.json | 15 +- frontend/src/i18n/locales/hu/activity.json | 63 +++ frontend/src/i18n/locales/hu/adminLogs.json | 17 +- frontend/src/i18n/locales/hu/audit.json | 4 - frontend/src/i18n/locales/hu/header.json | 5 +- frontend/src/i18n/locales/hu/nav.json | 5 +- frontend/src/i18n/locales/hu/scheduler.json | 3 +- frontend/src/i18n/locales/hu/stats.json | 15 +- frontend/src/lib/activityUrlState.test.ts | 64 +++ frontend/src/lib/activityUrlState.ts | 104 ++++ frontend/src/lib/api/admin.ts | 83 +++- frontend/src/lib/api/quota.ts | 5 +- .../auditText.dom.test.ts} | 16 +- .../auditColumns.tsx => lib/auditText.ts} | 84 +--- frontend/src/lib/issueDraft.test.ts | 1 + frontend/src/lib/logsUrlState.test.ts | 8 +- frontend/src/lib/logsUrlState.ts | 39 +- frontend/src/lib/modules.ts | 18 +- frontend/src/lib/pageMeta.test.ts | 3 +- frontend/src/lib/queryKeys.test.ts | 2 +- frontend/src/lib/queryKeys.ts | 7 +- frontend/src/lib/storage.ts | 6 +- frontend/src/lib/urlState.ts | 7 +- frontend/src/lib/useHistoryList.ts | 33 ++ 66 files changed, 2723 insertions(+), 1119 deletions(-) create mode 100644 backend/alembic/versions/0111_audit_request_id.py create mode 100644 backend/app/routes/_keyset.py create mode 100644 backend/app/routes/runs.py create mode 100644 backend/tests/test_activity_reads.py create mode 100644 frontend/src/components/Activity.dom.test.tsx create mode 100644 frontend/src/components/Activity.tsx create mode 100644 frontend/src/components/ActivityAudit.tsx create mode 100644 frontend/src/components/ActivityQuota.tsx create mode 100644 frontend/src/components/ActivityRuns.tsx delete mode 100644 frontend/src/components/AuditLog.tsx create mode 100644 frontend/src/components/HistoryList.tsx create mode 100644 frontend/src/components/HistoryRow.tsx create mode 100644 frontend/src/components/RunStatusDot.tsx create mode 100644 frontend/src/i18n/locales/en/activity.json create mode 100644 frontend/src/i18n/locales/hu/activity.json create mode 100644 frontend/src/lib/activityUrlState.test.ts create mode 100644 frontend/src/lib/activityUrlState.ts rename frontend/src/{components/auditColumns.dom.test.tsx => lib/auditText.dom.test.ts} (85%) rename frontend/src/{components/auditColumns.tsx => lib/auditText.ts} (58%) create mode 100644 frontend/src/lib/useHistoryList.ts 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 ` + + + {state.rows.map((row, index) => { + const { head, sub } = auditText(t, row, lang); + const who = actor(row); + return ( + setOpen(open === row.id ? null : row.id)} + title={formatDateTimeSec(row.created_at, lang)} + cells={ + <> + {auditActionLabel(t, row.action)} + + {formatDateTime(row.created_at, lang)} + + + {who ?? t("audit.system")} + + + {formatDateTime(row.created_at, lang)} + + + {[head, sub].filter(Boolean).join(" · ")} + + + } + > + + + ); + })} + + {facets.isError &&

{t("activity.countsFailed")}

} + + ); +} + +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 ( +
+
+
{t("audit.cols.actor")}
+
{who ?? t("audit.system")}
+
{t("audit.cols.when")}
+
{formatDateTimeSec(row.created_at, lang)}
+ {(head || sub) && ( + <> +
{t("audit.cols.details")}
+
{[head, sub].filter(Boolean).join(" · ")}
+ + )} + {row.target_id && ( + <> +
{t("activity.audit.target")}
+
{row.target_id}
+ + )} +
+ {(row.before || row.after) && ( +
+          {JSON.stringify({ before: row.before, after: row.after }, null, 2)}
+        
+ )} + {row.request_id && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/ActivityQuota.tsx b/frontend/src/components/ActivityQuota.tsx new file mode 100644 index 00000000..12698596 --- /dev/null +++ b/frontend/src/components/ActivityQuota.tsx @@ -0,0 +1,137 @@ +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 ( +
+

+ {t("stats.introBefore")} + {t("stats.introSystem")} + {t("stats.introAfter")} +

+ {q.isError && !data ? ( + void q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !data ? ( + + ) : ( + <> + {q.isError && ( + void q.refetch()} className="mb-3"> + {t("activity.stale")} + + )} +
+
+ {t("activity.quota.total", { + window: t(`activity.windows.${shared.window}`), + units: formatNumber(grandTotal, lang), + })} +
+ {data.daily.length === 0 ? ( +
{t("stats.noUsageInRange")}
+ ) : ( +
+ {data.daily.map((d) => ( +
+
+
+ ))} +
+ )} +
+ +
+ {data.rows.length === 0 ? ( +
{t("stats.noUsageInRange")}
+ ) : ( + data.rows.map((r) => ( + + )) + )} +
+ + )} +
+ ); +} + +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 ( +
+ setOpen((o) => !o)} + aria-expanded={open} + className="w-full flex items-center gap-3" + > + + {isSystem ? t("stats.system") : row.email} + + + {formatNumber(row.total, lang)} + + + {/* Proportion bar */} +
+
+
+ {open && actions.length > 0 && ( +
+ {actions.map(([action, units]) => ( +
+ {quotaActionLabel(t, action)} + {formatNumber(units, lang)} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ActivityRuns.tsx b/frontend/src/components/ActivityRuns.tsx new file mode 100644 index 00000000..3d0ea4d4 --- /dev/null +++ b/frontend/src/components/ActivityRuns.tsx @@ -0,0 +1,257 @@ +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 { formatDateTime, formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format"; +import { jobFailureText, jobResultText } from "../lib/jobResult"; +import { qk } from "../lib/queryKeys"; +import { useHistoryList } from "../lib/useHistoryList"; +import HistoryList from "./HistoryList"; +import HistoryRow from "./HistoryRow"; +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(0,12rem)_8rem_8rem_4rem_3.5rem_minmax(0,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(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 ( +
+
+ ({ value: f.value, label: jobName(f.value), count: f.count })), + ]} + selected={[job ?? ""]} + onChange={([value]) => onFilter({ job: value || null })} + testId="runs-job" + /> + + 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), + })), + ]} + /> +
+ + {state.rows.map((run, index) => ( + setOpen(open === run.id ? null : run.id)} + title={formatDateTimeSec(run.started_at, lang)} + cells={ + + } + > + onLogs(run.id)} + onJob={() => onFilter({ job: run.job })} + /> + + ))} + + {facets.isError &&

{t("activity.countsFailed")}

} +
+ ); +} + +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" }; + if (run.result) return { text: jobResultText(t, run.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 ( + <> + + + {name} + {run.target_title && · {run.target_title}} + + {/* A phone has room for the name and the time only; the row opens to the rest. */} + + {formatDateTime(run.started_at, lang)} + + + {t(`scheduler.trigger.${run.trigger}`)} + {actor && ` · ${actor}`} + + + {formatDateTime(run.started_at, lang)} + + + {secs === null ? "—" : formatRuntime(Math.max(1, secs), lang)} + + + {formatNumber(run.quota_units, lang)} + + {outcome.text} + + ); +} + +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 ( +
+
+
{t("activity.runs.fields.started")}
+
+ {run.actor_id === null + ? t(`scheduler.trigger.${run.trigger}`) + : `${actor ?? `#${run.actor_id}`} · ${t(`scheduler.trigger.${run.trigger}`)}`} +
+
{t("activity.runs.fields.when")}
+
+ {formatDateTimeSec(run.started_at, lang)} + {run.finished_at && ` → ${formatDateTimeSec(run.finished_at, lang)}`} +
+ {run.target && ( + <> +
{t("activity.runs.fields.target")}
+
+ {run.target_title ? `${run.target_title} · ` : ""} + {run.target} +
+ + )} +
{t("activity.runs.fields.quota")}
+
{formatNumber(run.quota_units, lang)}
+
+ {run.error && ( +
{run.error}
+ )} + {run.result && ( +
+          {JSON.stringify(run.result, null, 2)}
+        
+ )} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/AdminLogs.dom.test.tsx b/frontend/src/components/AdminLogs.dom.test.tsx index 9b9c28de..cfbb7062 100644 --- a/frontend/src/components/AdminLogs.dom.test.tsx +++ b/frontend/src/components/AdminLogs.dom.test.tsx @@ -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; an old link to the +// retired log page opens this tab; 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 { return { @@ -60,6 +67,7 @@ function row(id: number, extra: Partial = {}): 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( - + + + , ); } @@ -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(() => { @@ -105,7 +115,7 @@ afterEach(() => { describe("AdminLogs", () => { 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 +131,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 +171,60 @@ 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("an old link to the server log page opens this tab with its filter", async () => { + window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009"); logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null }); renderConsole(); - await waitFor(() => expect(facetsMock).toHaveBeenCalled()); - fireEvent.click(screen.getByTestId("logs-user")); + await waitFor(() => + expect(logsMock).toHaveBeenCalledWith( + expect.objectContaining({ request_id: "req-00000009" }), + null, + ), + ); + expect(new URLSearchParams(window.location.search).get("page")).toBe("activity"); + }); - expect(await screen.findByTestId("logs-user-7")).toBeTruthy(); - expect(screen.getByTestId("logs-user-1")).toBeTruthy(); + 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(logsMock).toHaveBeenCalledWith(expect.objectContaining({ user: "7" }), null), + ); + + fireEvent.click(screen.getByTestId("activity-user")); + + expect(await screen.findByTestId("activity-user-7")).toBeTruthy(); + expect(screen.getByTestId("activity-user-1")).toBeTruthy(); }); it("Live watches the newest page only", async () => { diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index 0dad5d23..e9932427 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -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,26 +6,17 @@ 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 LogLine from "./LogLine"; import NavGrid from "./ui/NavGrid"; @@ -35,46 +25,45 @@ 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 = { - ...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; + +export default function AdminLogs({ + shared, + onPivot, +}: { + shared: Shared; + /** 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 +71,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 +83,26 @@ export default function AdminLogs() { setLiveState(on); }; const [open, setOpen] = useState(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 = () => { - setOpen(null); - setJumps((n) => n + 1); - setState(logsParamsToState(new URLSearchParams(window.location.search))); - }; - window.addEventListener("popstate", onPop); - return () => window.removeEventListener("popstate", onPop); - }, []); - const update = (patch: Partial) => { 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), }); @@ -217,19 +186,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) => { + const pivot = (patch: Partial) => { 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,193 +212,155 @@ export default function AdminLogs() { }; return ( -
-
-

{t("adminLogs.intro")}

- -
+ +
+ + {!fold.folded && ( + <> + v)} + selected={[state.sources[0] ?? ""]} + onChange={([source]) => update({ sources: source ? [source] : [] })} + testId="logs-source" + /> + + v === "none" ? t("adminLogs.noEvent") : v, + )} + selected={[state.event ?? ""]} + onChange={([event]) => update({ event: event || null })} + testId="logs-event" + /> + + )} + + + + +
+ + {(state.requestId || state.jobId !== null || state.runId !== null) && ( +
+ {state.requestId && ( + update({ requestId: null })} + /> + )} + {state.jobId !== null && ( + update({ jobId: null })} + /> + )} + {state.runId !== null && ( + update({ runId: null })} + /> + )} +
+ )} +
+ + {failure && ( + + {t(`adminLogs.${failure}`)} + + )} + +
+ {logs.isError && !logs.data ? ( + void logs.refetch()}> + {t("common.loadError")} + + ) : logs.isLoading ? ( + + ) : rows.length === 0 ? ( +

+ {t("adminLogs.empty")} +

+ ) : ( + + {rows.map((row, index) => ( + setOpen(open === row.id ? null : row.id)} + > + pivot({ requestId })} + onJob={(jobId) => pivot({ jobId })} + onRun={(runId) => pivot({ runId })} + /> + + ))} + + )} +
+ +
+ {t("adminLogs.count", { count: rows.length })} + {olderCursor !== null && ( + + )} +
); } @@ -465,11 +386,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 +439,21 @@ function RowDetail({ )} + {row.run_id !== null && ( + <> +
{t("adminLogs.fields.run")}
+
+ +
+ + )} {row.version && ( <>
{t("adminLogs.fields.version")}
diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx deleted file mode 100644 index 94bb5fc8..00000000 --- a/frontend/src/components/AuditLog.tsx +++ /dev/null @@ -1,105 +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"; - -// Admin-only audit trail: an append-only who/what/when log of admin actions (never per-row; a -// background run is not one — runs have their own history). 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; - const rows = useMemo(() => 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 ( -
-
-

{t("audit.intro")}

- {q.isError && !q.data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading ? ( - - ) : rows.length === 0 ? ( -

{t("audit.empty")}

- ) : ( - <> - String(r.id)} - persistKey={LS.auditTable} - controlsPosition="top" - controlsInBand - controlsBandClassName="px-4 max-w-5xl mx-auto" - controlsLeading={ - - } - emptyText={t("audit.empty")} - /> - {truncated && ( -

- {t("audit.truncated", { returned: data!.returned, total: data!.total })} -

- )} - - )} -
-
- ); -} diff --git a/frontend/src/components/HistoryList.tsx b/frontend/src/components/HistoryList.tsx new file mode 100644 index 00000000..8862417f --- /dev/null +++ b/frontend/src/components/HistoryList.tsx @@ -0,0 +1,84 @@ +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 rows (or the honest state instead of them — +// a failed read is an error with a retry, never an empty list), the count, and "Older". + +export default function HistoryList({ + state, + empty, + count, + children, +}: { + state: HistoryListState; + /** 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; + // A read that failed while rows were on screen keeps them, and says what the failure means. + const failure = !list.data + ? null + : list.isFetchNextPageError + ? "olderFailed" + : list.isError + ? "stale" + : null; + return ( +
+ {failure && ( + + failure === "olderFailed" + ? void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage)) + : void list.refetch() + } + className="border-b border-border" + > + {failure === "olderFailed" ? t("activity.olderFailed") : t("activity.stale")} + + )} +
+ {list.isError && !list.data ? ( + void list.refetch()}> + {t("common.loadError")} + + ) : list.isLoading ? ( + + ) : rows.length === 0 ? ( +

{empty}

+ ) : ( + {children} + )} +
+
+ {count} + {hasOlder && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/HistoryRow.tsx b/frontend/src/components/HistoryRow.tsx new file mode 100644 index 00000000..f48f57f2 --- /dev/null +++ b/frontend/src/components/HistoryRow.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; + +import { navItemAttrs } from "../lib/listNav"; +import { Pressable } from "./ui/Pressable"; + +// One dense row of a history list on the activity page — a background run, an admin action: its +// cells on one line, and the row opens in place to its details, as a server log line does +// (./LogLine). 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, + 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; + /** The detail shown while the row is open. */ + children: ReactNode; +}) { + return ( +
+ + {cells} + + {open &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/IssueComposer.dom.test.tsx b/frontend/src/components/IssueComposer.dom.test.tsx index 32437c55..02d9fbfd 100644 --- a/frontend/src/components/IssueComposer.dom.test.tsx +++ b/frontend/src/components/IssueComposer.dom.test.tsx @@ -112,6 +112,7 @@ const row = (over: Partial): ServerLogRow => ({ request_id: null, user_id: 1, job_id: null, + run_id: null, debug_session_id: SID, version: null, ...over, diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index 5d020e2f..60ee5a80 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -151,11 +151,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")); diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index a8d18c0f..d73a2dec 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -3,7 +3,7 @@ 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 { ACTIVITY_KEYS } from "../lib/activityUrlState"; import { pageTitleKey } from "../lib/pageMeta"; import { useDocumentObjectTitle } from "../lib/documentTitle"; import { setAccountRaw } from "../lib/storage"; @@ -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(); diff --git a/frontend/src/components/RunStatusDot.tsx b/frontend/src/components/RunStatusDot.tsx new file mode 100644 index 00000000..cb3ba7f5 --- /dev/null +++ b/frontend/src/components/RunStatusDot.tsx @@ -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 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 ( + + ); +} diff --git a/frontend/src/components/Scheduler.dom.test.tsx b/frontend/src/components/Scheduler.dom.test.tsx index 89477800..6f875298 100644 --- a/frontend/src/components/Scheduler.dom.test.tsx +++ b/frontend/src/components/Scheduler.dom.test.tsx @@ -15,6 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import Scheduler from "./Scheduler"; +import { NavigationActionsContext } from "./navigationContext"; import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api"; import { qk } from "../lib/queryKeys"; @@ -363,3 +364,37 @@ describe("a row's run history", () => { 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( + {}, + closeChannel: () => {}, + enterYtSearch: () => {}, + exitYtSearch: () => {}, + }} + > + + + + , + ); + await screen.findByText("scheduler.maintenance.batchLabel"); + const job = current.jobs[0]!.id; + const row = screen.getByText(`scheduler.jobs.${job}`).closest(".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]); + }); +}); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 91d8333f..1b1cd714 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -10,6 +10,7 @@ import { Clock, Database, Gauge, + History, Pause, Pencil, Play, @@ -43,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. @@ -64,33 +68,6 @@ function fmtCountdown(t: TFunction, s: number, lang: string): string { return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`; } -// 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 animate-pulse", - ok: "bg-success", - error: "bg-danger", - skipped: "bg-warn", - interrupted: "border-2 border-warn", - idle: "bg-border", -} as const; - -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. */ -function StatusDot({ k, label }: { k: DotKey; label?: string }) { - return ( - - ); -} - function StatusLegend() { const { t } = useTranslation(); return ( @@ -173,6 +150,31 @@ function LastRun({ row, running, target }: { row: RunFields; running: boolean; t ); } +/** 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 ( + + + setPage("activity", () => + window.history.replaceState( + window.history.state, + "", + urlWithParams({ page: "activity", rjob: job }), + ), + ) + } + > + + + + ); +} + /** 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 }) { @@ -201,6 +203,7 @@ function AdHocRow({ row }: { row: SchedulerAdHoc }) { t("scheduler.adHocStarted") )}
+ ); } @@ -382,6 +385,7 @@ function JobRow({ + ); } diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 3c895cd2..79dd7daf 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -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. -
- {/* 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 && ( - -
- ({ - id, - label: t(`stats.tabs.${id}`), - }))} - className="flex flex-wrap gap-1 pb-2" - /> -
-
- )} - - {tab === "system" && isAdmin ? : } +
+
); } @@ -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(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 && ( -
- - - -
- )} - -
-

{t("stats.title")}

- ({ - value: String(d), - label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }), - }))} - value={String(days)} - onChange={(v) => setDays(Number(v))} - /> -
- -

- {t("stats.introBefore")} - {t("stats.introSystem")} - {t("stats.introAfter")} -

- - {q.isError && !data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading ? ( - - ) : !data ? ( -
{t("stats.noData")}
- ) : ( - <> - {/* Daily totals (instance-wide, Pacific days) */} -
-
- {t("stats.dailyTotal", { - d: formatUnit(data.range_days, "day", lang), - units: formatNumber(grandTotal, lang), - })} -
- {data.daily.length === 0 ? ( -
{t("stats.noUsageInRange")}
- ) : ( -
- {data.daily.map((d) => ( -
-
-
- ))} -
- )} -
- - {/* Per-user breakdown */} -
- {data.rows.length === 0 ? ( -
{t("stats.noUsageInRange")}
- ) : ( - data.rows.map((r) => ( - - )) - )} -
- - )} - - ); -} - -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 ( -
- setOpen((o) => !o)} - aria-expanded={open} - className="w-full flex items-center gap-3" - > - - {isSystem ? t("stats.system") : row.email} - - - {formatNumber(row.total, lang)} - - - {/* Proportion bar */} -
-
-
- {open && actions.length > 0 && ( -
- {actions.map(([action, units]) => ( -
- {quotaActionLabel(t, action)} - {formatNumber(units, lang)} -
- ))} -
- )} -
- ); -} diff --git a/frontend/src/components/listNav.wiring.contract.test.ts b/frontend/src/components/listNav.wiring.contract.test.ts index 9d20ef98..a80d94d7 100644 --- a/frontend/src/components/listNav.wiring.contract.test.ts +++ b/frontend/src/components/listNav.wiring.contract.test.ts @@ -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 = { - "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 = {}; interface Tag { start: number; diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx index 27945fcf..63d46ef5 100644 --- a/frontend/src/components/moduleRegistry.tsx +++ b/frontend/src/components/moduleRegistry.tsx @@ -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 = { scheduler: { Component: Scheduler }, config: { Component: ConfigPanel }, users: { Component: AdminUsers, maxWidth: "48rem" }, - audit: { Component: AuditLog }, - logs: { Component: AdminLogs }, + activity: { Component: Activity }, settings: { Component: SettingsPanel }, }; diff --git a/frontend/src/i18n/locales/en/activity.json b/frontend/src/i18n/locales/en/activity.json new file mode 100644 index 00000000..8aeb5efb --- /dev/null +++ b/frontend/src/i18n/locales/en/activity.json @@ -0,0 +1,63 @@ +{ + "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" + }, + "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", + "countsFailed": "Couldn't load the filter counts", + "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", + "fields": { + "started": "Started by", + "when": "Start → end", + "target": "Target", + "quota": "Quota units" + }, + "logLines_one": "Its {{count}} log line →", + "logLines_other": "Its {{count}} log lines →", + "jobRuns": "This job's runs →" + }, + "audit": { + "action": "Action", + "target": "Target", + "requestLines": "Its request's log lines →", + "count_one": "{{count}} action shown · newest first", + "count_other": "{{count}} actions shown · newest first" + }, + "quota": { + "total": "Spend over {{window}}: {{units}} units" + } +} diff --git a/frontend/src/i18n/locales/en/adminLogs.json b/frontend/src/i18n/locales/en/adminLogs.json index 519ee9d8..96b3a827 100644 --- a/frontend/src/i18n/locales/en/adminLogs.json +++ b/frontend/src/i18n/locales/en/adminLogs.json @@ -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", @@ -38,6 +28,9 @@ "account": "Account", "request": "Request", "job": "Job", - "version": "Version" - } + "version": "Version", + "run": "Run" + }, + "pivotRun": "run {{id}}", + "showRun": "Only this run's lines" } diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index 6e6eb7c6..62ce2a59 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -1,6 +1,4 @@ { - "title": "Audit log", - "intro": "An append-only record of admin actions — who did what, and when. Logged at action granularity, never per item.", "empty": "No audit entries yet.", "system": "System", "clear": "Clear audit log", @@ -9,7 +7,6 @@ "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.", "on": "On", "off": "Off", "default": "default", @@ -18,7 +15,6 @@ "cols": { "when": "When", "actor": "Who", - "action": "Action", "details": "Details" } } diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index ac80bdd2..442f9099 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -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", diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json index f8cc9142..b2cf7533 100644 --- a/frontend/src/i18n/locales/en/nav.json +++ b/frontend/src/i18n/locales/en/nav.json @@ -18,9 +18,8 @@ "scheduler": "Scheduler", "config": "Config", "users": "Users", - "audit": "Audit", "admin": "Admin", - "logs": "Logs", - "settings": "Settings" + "settings": "Settings", + "activity": "Activity" } } diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 808df3f3..1ed1eda7 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -211,5 +211,6 @@ }, "interrupted": "interrupted: the server restarted during the run", "adHocTitle": "Ad-hoc runs", - "adHocStarted": "started by a user" + "adHocStarted": "started by a user", + "history": "This job's runs" } diff --git a/frontend/src/i18n/locales/en/stats.json b/frontend/src/i18n/locales/en/stats.json index ae3935e9..8ab0cb00 100644 --- a/frontend/src/i18n/locales/en/stats.json +++ b/frontend/src/i18n/locales/en/stats.json @@ -1,17 +1,8 @@ { - "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)", @@ -49,10 +40,6 @@ "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" } } diff --git a/frontend/src/i18n/locales/hu/activity.json b/frontend/src/i18n/locales/hu/activity.json new file mode 100644 index 00000000..8ca6cad2 --- /dev/null +++ b/frontend/src/i18n/locales/hu/activity.json @@ -0,0 +1,63 @@ +{ + "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" + }, + "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", + "countsFailed": "Nem sikerült betölteni a szűrők darabszámait", + "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", + "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 →", + "jobRuns": "Ennek a feladatnak a futásai →" + }, + "audit": { + "action": "Művelet", + "target": "Cél", + "requestLines": "A kérés naplósorai →", + "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": { + "total": "Felhasználás ({{window}}): {{units}} egység" + } +} diff --git a/frontend/src/i18n/locales/hu/adminLogs.json b/frontend/src/i18n/locales/hu/adminLogs.json index 6a319a8a..e1ddefc5 100644 --- a/frontend/src/i18n/locales/hu/adminLogs.json +++ b/frontend/src/i18n/locales/hu/adminLogs.json @@ -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", @@ -38,6 +28,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" } diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 0ec91b4d..86f7ae76 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -1,6 +1,4 @@ { - "title": "Eseménynapló", - "intro": "Az admin-műveletek 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.", "system": "Rendszer", "clear": "Eseménynapló ürítése", @@ -9,7 +7,6 @@ "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.", "on": "Be", "off": "Ki", "default": "alapérték", @@ -18,7 +15,6 @@ "cols": { "when": "Mikor", "actor": "Ki", - "action": "Művelet", "details": "Részletek" } } diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index f19bca06..972073b8 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json index 1300d07a..c8088179 100644 --- a/frontend/src/i18n/locales/hu/nav.json +++ b/frontend/src/i18n/locales/hu/nav.json @@ -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ó" } } diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 154ade1b..7b9b511f 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -211,5 +211,6 @@ }, "interrupted": "megszakadt: a futás közben újraindult a szerver", "adHocTitle": "Alkalmi futások", - "adHocStarted": "felhasználó indítja" + "adHocStarted": "felhasználó indítja", + "history": "A feladat futásai" } diff --git a/frontend/src/i18n/locales/hu/stats.json b/frontend/src/i18n/locales/hu/stats.json index eee941bf..94e73362 100644 --- a/frontend/src/i18n/locales/hu/stats.json +++ b/frontend/src/i18n/locales/hu/stats.json @@ -1,17 +1,8 @@ { - "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)", @@ -49,10 +40,6 @@ "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" } } diff --git a/frontend/src/lib/activityUrlState.test.ts b/frontend/src/lib/activityUrlState.test.ts new file mode 100644 index 00000000..9291d6e6 --- /dev/null +++ b/frontend/src/lib/activityUrlState.test.ts @@ -0,0 +1,64 @@ +// 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, + type ActivityUrlState, +} from "./activityUrlState"; + +const params = (updates: Record) => + 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("opens the tab of the page a link still names", () => { + expect(activityParamsToState(new URLSearchParams("page=audit")).tab).toBe("admin"); + expect(activityParamsToState(new URLSearchParams("page=logs")).tab).toBe("logs"); + // A tab the link names wins over the retired page's. + expect(activityParamsToState(new URLSearchParams("page=logs&at=quota")).tab).toBe("quota"); + }); + + 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"); + }); +}); diff --git a/frontend/src/lib/activityUrlState.ts b/frontend/src/lib/activityUrlState.ts new file mode 100644 index 00000000..f8c25ce8 --- /dev/null +++ b/frontend/src/lib/activityUrlState.ts @@ -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 { LOGS_KEYS } from "./logsUrlState"; + +export type ActivityTab = "runs" | "admin" | "logs" | "quota"; +export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d"; +/** The run statuses the list can narrow to (the API's `RunStatus`). */ +export type RunStatusFilter = "running" | "ok" | "error" | "skipped" | "interrupted"; + +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"]; +export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d"]; +export const RUN_STATUSES: readonly RunStatusFilter[] = [ + "error", + "interrupted", + "skipped", + "running", + "ok", +]; + +export const WINDOW_HOURS: Record = { + "1h": 1, + "24h": 24, + "7d": 24 * 7, + "30d": 24 * 30, + "90d": 24 * 90, +}; + +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 pages this one replaced: a link or a bookmark to one opens its tab here (urlState's RETIRED +// sends the page id itself here). +const RETIRED_TAB: Readonly> = { audit: "admin", logs: "logs" }; + +// 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 = (value: string | null, list: readonly T[]): T | null => + list.includes(value as T) ? (value as T) : null; + +export function activityParamsToState(params: URLSearchParams): ActivityUrlState { + return { + tab: + oneOf(params.get("at"), ACTIVITY_TABS) ?? + RETIRED_TAB[params.get("page") ?? ""] ?? + 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 { + 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. */ +export function sinceOf(window: ActivityWindow, now = Date.now()): string { + return new Date(now - WINDOW_HOURS[window] * 3_600_000).toISOString(); +} diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index a844bd12..158de107 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -95,18 +95,69 @@ 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. target_type: string | null; target_id: string | null; summary: string | null; before: Record | null; after: Record | null; + /** The request the change was made in — its log lines are one pivot away. Null on older rows. */ + request_id: string | null; } -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 { + items: T[]; + /** The accounts this page names, by id; an erased one is absent. */ + accounts: Record; + 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; + failure: { code: 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 +175,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 +188,7 @@ export interface LogQuery { event?: string; request_id?: string; job_id?: number; + run_id?: number; q?: string; since?: string; } @@ -171,6 +224,7 @@ function logParams(query: LogQuery, extra: Record => req("/api/admin/config/test-issues", { method: "POST" }), // --- users & roles --- - adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`), + adminAudit: (query: AuditQuery, before: string | null): Promise> => + 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> => + req(`/api/admin/runs${queryString({ ...query, before })}`), + adminRunFacets: (query: RunQuery): Promise => + 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 => diff --git a/frontend/src/lib/api/quota.ts b/frontend/src/lib/api/quota.ts index 964ef7f7..a0f2e132 100644 --- a/frontend/src/lib/api/quota.ts +++ b/frontend/src/lib/api/quota.ts @@ -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 => req("/api/quota/my-usage"), - adminQuota: (days = 30): Promise => 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 => + req(`/api/quota/admin?${new URLSearchParams({ since, ...(user ? { user } : {}) }).toString()}`), }; diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/lib/auditText.dom.test.ts similarity index 85% rename from frontend/src/components/auditColumns.dom.test.tsx rename to frontend/src/lib/auditText.dom.test.ts index 34a8a0e8..a0906ad5 100644 --- a/frontend/src/components/auditColumns.dom.test.tsx +++ b/frontend/src/lib/auditText.dom.test.ts @@ -4,37 +4,33 @@ // min/run", "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, render } from "@testing-library/react"; import i18n, { setLanguage } from "../i18n"; -import type { AuditRow } from "../lib/api"; -import { auditColumns } from "./auditColumns"; +import type { AuditRow } from "./api"; +import { auditText } from "./auditText"; -// 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 => ({ id: 1, created_at: null, - actor: null, + actor_id: null, action: "sync_pause", target_type: null, target_id: null, summary: null, before: null, after: null, + request_id: 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(" | "); + const { head, sub } = auditText(t, r, "hu"); + return [head, sub].filter(Boolean).join(" | "); } describe("an audit row's details", () => { diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/lib/auditText.ts similarity index 58% rename from frontend/src/components/auditColumns.tsx rename to frontend/src/lib/auditText.ts index a11631a7..dd31a20c 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/lib/auditText.ts @@ -1,15 +1,7 @@ import type { TFunction } from "i18next"; -import type { AuditRow } from "../lib/api"; -import { formatNumber } from "../lib/format"; -import type { JobResult } from "../lib/api"; -import { jobResultText } 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); -} +import type { AuditRow, JobResult } from "./api"; +import { formatNumber } from "./format"; +import { jobResultText } from "./jobResult"; function fmt(v: unknown): string { return v == null || v === "" ? "—" : String(v); @@ -90,64 +82,18 @@ function changeText(row: AuditRow): string | null { 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( +/** An audit row in the reader's language: `head` names what changed (a config field, a job; else + * the server's summary), `sub` the change itself (before → after, a count). Either may be null. */ +export function auditText( t: TFunction, + row: AuditRow, lang: string, - actionOptions: { value: string; label: string }[], -): Column[] { - return [ - { - key: "when", - header: t("audit.cols.when"), - nowrap: true, - sortable: true, - sortValue: (r) => r.created_at ?? "", - cardPrimary: true, - render: (r) => ( - - ), - }, - { - 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 ? ( - {r.actor} - ) : ( - {t("audit.system")} - ), - }, - { - 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) => {actionLabel(t, r.action)}, - }, - { - 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); - 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. -
- {summary &&
{summary}
} - {change &&
{change}
} -
- ); - }, - }, - ]; +): { head: string | null; sub: string | null } { + const local = localDetails(t, row, lang); + return local ?? { head: row.summary, sub: changeText(row) }; +} + +export function auditActionLabel(t: TFunction, action: string): string { + // Canonical key → localized label; falls back to the raw key for any unmapped action. + return t(`auditActions.${action}`, action); } diff --git a/frontend/src/lib/issueDraft.test.ts b/frontend/src/lib/issueDraft.test.ts index 495a8c3d..4843c7d7 100644 --- a/frontend/src/lib/issueDraft.test.ts +++ b/frontend/src/lib/issueDraft.test.ts @@ -19,6 +19,7 @@ const row = (over: Partial): ServerLogRow => ({ request_id: null, user_id: 1, job_id: null, + run_id: null, debug_session_id: "0123456789abcdef", version: null, ...over, diff --git a/frontend/src/lib/logsUrlState.test.ts b/frontend/src/lib/logsUrlState.test.ts index 67c80712..31f666ea 100644 --- a/frontend/src/lib/logsUrlState.test.ts +++ b/frontend/src/lib/logsUrlState.test.ts @@ -17,12 +17,10 @@ describe("logsUrlState", () => { const state: LogsUrlState = { group: "error", sources: ["worker"], - user: "none", event: "download.job.failed", requestId: "req-abcdef12", jobId: 42, - q: "100% done", - window: "7d", + runId: 7, }; expect(logsParamsToState(params(logsParamUpdates(state)))).toEqual(state); }); @@ -33,9 +31,7 @@ describe("logsUrlState", () => { it("drops a value the API would refuse instead of sending it", () => { const state = logsParamsToState( - new URLSearchParams( - "lgrp=loud&lsrc=api,WORKER,x y&lu=me&lev=Not.A.Code&lreq=short&ljob=0&lwin=1y", - ), + new URLSearchParams("lgrp=loud&lsrc=api,WORKER,x y&lev=Not.A.Code&lreq=short&ljob=0&lrun=-1"), ); expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] }); }); diff --git a/frontend/src/lib/logsUrlState.ts b/frontend/src/lib/logsUrlState.ts index 3105af84..57db7eb5 100644 --- a/frontend/src/lib/logsUrlState.ts +++ b/frontend/src/lib/logsUrlState.ts @@ -1,67 +1,56 @@ -// The admin diagnostics console's filters in the address bar (`?page=logs&lgrp=error&lreq=…`), so a -// filtered view — a request's lines, say — is a link. Keys are namespaced (`l…`) like the Plex and -// Channels ones; setPage clears them (LOGS_KEYS) and the console re-mirrors them while it is open. +// The server-log tab's own filters in the address bar (`?page=activity&at=logs&lgrp=error&lreq=…`), +// so a filtered view — a request's lines, say — is a link. The filters every activity tab shares +// (time range, account, text) live in ./activityUrlState. Keys are namespaced (`l…`) like the Plex +// and Channels ones; setPage clears them (via ACTIVITY_KEYS) and the tab re-mirrors them while open. import type { LevelGroup } from "./log"; -export type LogWindow = "1h" | "24h" | "7d"; - export interface LogsUrlState { group: LevelGroup; sources: string[]; - /** An account id, or `none` for the rows without one. */ - user: string | null; /** An event code, or `none` for the rows without one. */ event: string | null; requestId: string | null; jobId: number | null; - q: string; - window: LogWindow; + /** A background run's lines (`job_runs.id`). */ + runId: number | null; } export const DEFAULT_LOGS_STATE: LogsUrlState = { group: "all", sources: [], - user: null, event: null, requestId: null, jobId: null, - q: "", - window: "24h", + runId: null, }; -export const LOGS_KEYS = ["lgrp", "lsrc", "lu", "lev", "lreq", "ljob", "lq", "lwin"] as const; - -export const WINDOW_HOURS: Record = { "1h": 1, "24h": 24, "7d": 24 * 7 }; +export const LOGS_KEYS = ["lgrp", "lsrc", "lev", "lreq", "ljob", "lrun"] as const; const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"]; // The same shapes the API validates: a link someone edited by hand degrades to "no filter" here // instead of to a 422 on every request the console makes. -const USER_RE = /^(none|\d{1,10})$/; const EVENT_RE = /^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$/; const REQUEST_RE = /^[A-Za-z0-9._-]{8,64}$/; const SOURCE_RE = /^[a-z]{1,16}$/; const SOURCES_MAX = 8; -const Q_MAX = 200; const pick = (value: string | null, re: RegExp, max = 64) => value && value.length <= max && re.test(value) ? value : null; +const positive = (value: string | null) => + value && /^\d{1,10}$/.test(value) && Number(value) > 0 ? Number(value) : null; export function logsParamsToState(params: URLSearchParams): LogsUrlState { const group = params.get("lgrp"); - const window = params.get("lwin"); - const job = params.get("ljob"); return { group: GROUPS.includes(group as LevelGroup) ? (group as LevelGroup) : DEFAULT_LOGS_STATE.group, sources: (params.get("lsrc") ?? "") .split(",") .filter((s) => SOURCE_RE.test(s)) .slice(0, SOURCES_MAX), - user: pick(params.get("lu"), USER_RE), event: pick(params.get("lev"), EVENT_RE), requestId: pick(params.get("lreq"), REQUEST_RE), - jobId: job && /^\d{1,10}$/.test(job) && Number(job) > 0 ? Number(job) : null, - q: (params.get("lq") ?? "").slice(0, Q_MAX), - window: window && window in WINDOW_HOURS ? (window as LogWindow) : DEFAULT_LOGS_STATE.window, + jobId: positive(params.get("ljob")), + runId: positive(params.get("lrun")), }; } @@ -70,11 +59,9 @@ export function logsParamUpdates(state: LogsUrlState): Record = { scheduler: "header.scheduler", config: "header.configuration", users: "header.users", - audit: "audit.title", - logs: "adminLogs.title", + activity: "activity.title", settings: "settings.title", }; diff --git a/frontend/src/lib/queryKeys.test.ts b/frontend/src/lib/queryKeys.test.ts index f44d2303..c3b23b96 100644 --- a/frontend/src/lib/queryKeys.test.ts +++ b/frontend/src/lib/queryKeys.test.ts @@ -39,7 +39,7 @@ describe("qk — single-id and scalar keys", () => { expect(qk.downloadLinks(5)).toEqual(["download-links", 5]); expect(qk.dlQuota(3)).toEqual(["dl-quota", 3]); expect(qk.storyboard(5)).toEqual(["storyboard", 5]); - expect(qk.adminQuota(30)).toEqual(["admin-quota", 30]); + expect(qk.adminQuota({ window: "7d" })).toEqual(["admin-quota", { window: "7d" }]); expect(qk.plexPeopleCards("both", ["Actor"])).toEqual(["plex-people-cards", "both", ["Actor"]]); }); diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 91aadcf5..a2de239b 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -87,7 +87,10 @@ export const qk = { adminInvites: () => ["admin-invites"] as const, adminPendingAccess: () => ["admin-pending-access"] as const, adminConfig: () => ["admin-config"] as const, - adminAudit: () => ["admin-audit"] as const, + adminAudit: (filters: object) => ["admin-audit", filters] as const, + adminAuditFacets: (filters: object) => ["admin-audit-facets", filters] as const, + adminRuns: (filters: object) => ["admin-runs", filters] as const, + adminRunFacets: (filters: object) => ["admin-run-facets", filters] as const, // The console's filters are part of the key; `since` is not (it moves with the clock) — the // window it came from is. adminLogs: (filters: object) => ["admin-logs", filters] as const, @@ -105,7 +108,7 @@ export const qk = { debugSessionPreview: (id: string, text: string) => ["debug-sessions", id, "preview", text] as const, adminDownloads: () => ["admin-downloads"] as const, - adminQuota: (days: number) => ["admin-quota", days] as const, + adminQuota: (filters: object) => ["admin-quota", filters] as const, // --- plex --- // The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 9f4c4bed..43061a31 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -64,12 +64,10 @@ export const LS = { // before the subscription list has landed. A cache, rewritten from the list on every load. channelNames: "siftlode.channelNames", channelDiscoveryTable: "siftlode.channelDiscoveryTable", - // A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and - // statsTab below. + // A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab + // below. settingsTab: "siftlode.settingsTab", configTab: "siftlode.configTab", - auditTable: "siftlode.auditTable", - statsTab: "siftlode.statsTab", adminUsersTab: "siftlode.adminUsersTab", filterCollapsed: "siftlode.filterCollapsed", playlist: "siftlode.playlist", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 09a62083..98c2f8c1 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -128,8 +128,7 @@ const PAGES = [ "scheduler", "config", "users", - "audit", - "logs", + "activity", "notifications", "files", "plex", @@ -148,6 +147,10 @@ function isPage(p: string | null | undefined): p is Page { const RETIRED: Readonly> = { // The download queue became the tray; what it downloaded lives in Files. downloads: "files", + // The audit log and the server log became tabs of the activity page (activityUrlState opens the + // right tab from the old page id). + audit: "activity", + logs: "activity", }; /** A page id as stored or linked: a live page, a retired page's successor, else null. */ diff --git a/frontend/src/lib/useHistoryList.ts b/frontend/src/lib/useHistoryList.ts new file mode 100644 index 00000000..d1e0e188 --- /dev/null +++ b/frontend/src/lib/useHistoryList.ts @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; + +import type { HistoryPage } from "./api"; + +/** A history list read a page at a time, newest first (the server's keyset paging): the rows loaded + * so far, the accounts they name, and whether there is an older page to ask for. While a filter + * change loads, the previous filter's rows stay on screen (dimmed by the caller) and no older page is + * offered — the cursor on screen belongs to the rows of the old filter. */ +export function useHistoryList( + queryKey: readonly unknown[], + fetchPage: (before: string | null) => Promise>, +) { + const list = useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam }) => fetchPage(pageParam), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.next_cursor ?? undefined, + placeholderData: keepPreviousData, + }); + const rows = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]); + const accounts = useMemo( + () => + Object.assign({}, ...(list.data?.pages.map((p) => p.accounts) ?? [])) as Record< + string, + string + >, + [list.data], + ); + return { list, rows, accounts, hasOlder: !list.isPlaceholderData && list.hasNextPage }; +} + +export type HistoryList = ReturnType>;