feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
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.
This commit is contained in:
@@ -630,41 +630,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/admin_logs.py": [
|
|
||||||
{
|
|
||||||
"code": "reportReturnType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 15,
|
|
||||||
"endColumn": 47,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/audit.py": [
|
"./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",
|
"code": "reportAttributeAccessIssue",
|
||||||
"range": {
|
"range": {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
Design (locked): log at ACTION granularity, NEVER per-row — an action that touches thousands of
|
||||||
touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are
|
rows is ONE audit row summarising the outcome. Background runs are not actions: they have their
|
||||||
only logged when they actually changed something (or errored); all admin actions and all manual
|
own history (app/jobruns.py). Secrets are never stored (config changes log the key name only).
|
||||||
runs are always logged. 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
|
`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
|
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 import delete
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.models import AuditLog
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ def record(
|
|||||||
summary=(summary or None) and summary[:255],
|
summary=(summary or None) and summary[:255],
|
||||||
before=before,
|
before=before,
|
||||||
after=after,
|
after=after,
|
||||||
|
request_id=logs.current_request_id(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
def current_run_id() -> int | None:
|
||||||
"""The background run this context is doing, if any — `bind(run_id=…)` sets it."""
|
"""The background run this context is doing, if any — `bind(run_id=…)` sets it."""
|
||||||
return _run_id.get()
|
return _run_id.get()
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
|||||||
plex as plex_routes,
|
plex as plex_routes,
|
||||||
public as public_routes,
|
public as public_routes,
|
||||||
quota,
|
quota,
|
||||||
|
runs as runs_routes,
|
||||||
saved_views,
|
saved_views,
|
||||||
scheduler as scheduler_routes,
|
scheduler as scheduler_routes,
|
||||||
search as search_routes,
|
search as search_routes,
|
||||||
@@ -345,6 +346,7 @@ app.include_router(config_routes.router)
|
|||||||
app.include_router(scheduler_routes.router)
|
app.include_router(scheduler_routes.router)
|
||||||
app.include_router(audit_routes.router)
|
app.include_router(audit_routes.router)
|
||||||
app.include_router(admin_logs_routes.router)
|
app.include_router(admin_logs_routes.router)
|
||||||
|
app.include_router(runs_routes.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
app.include_router(setup_routes.router)
|
app.include_router(setup_routes.router)
|
||||||
|
|||||||
@@ -572,11 +572,12 @@ class QuotaEvent(Base):
|
|||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
"""Append-only admin/scheduler audit trail: who did what, when, and (where it applies)
|
"""Append-only admin audit trail: who did what, when, and (where it applies) the before->after
|
||||||
the before->after values. `actor_id` is the acting admin; NULL means the scheduler /
|
values. `actor_id` is the acting admin; NULL means the background did it (SET NULL on user
|
||||||
background did it (SET NULL on user delete keeps the trail). Logged at ACTION / job-run
|
delete keeps the trail). Logged per ACTION, never per row; a background run is not an action
|
||||||
granularity — never per-row — so a scheduler run that touches thousands of videos is ONE
|
and has its own history (`JobRun`). `before`/`after` are small JSON snapshots (secrets
|
||||||
row summarising the outcome. `before`/`after` are small JSON snapshots (secrets excluded)."""
|
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"
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
@@ -593,6 +594,7 @@ class AuditLog(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), index=True
|
DateTime(timezone=True), server_default=func.now(), index=True
|
||||||
)
|
)
|
||||||
|
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
|
|
||||||
|
|
||||||
class LogEvent(Base):
|
class LogEvent(Base):
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
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.
|
`[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
|
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":
|
||||||
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":
|
|
||||||
several processes write this table, and a batch's ids are taken when it inserts, not when it
|
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.
|
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 dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import Text, and_, cast, func, or_, select
|
from sqlalchemy import Text, cast, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logsink
|
from app import logsink
|
||||||
from app.auth import admin_user
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import LogEvent, User
|
from app.models import LogEvent, User
|
||||||
|
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
||||||
from app.utils import like_escape
|
from app.utils import like_escape
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
|
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
|
# 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.
|
# through the free-text search, and the response stays small whatever a self-host logs.
|
||||||
FACET_EVENTS_MAX = 100
|
FACET_EVENTS_MAX = 100
|
||||||
@@ -47,35 +42,6 @@ Facet = Literal["levels", "sources", "user", "event"]
|
|||||||
NONE = "none"
|
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)
|
@dataclass(frozen=True)
|
||||||
class LogFilters:
|
class LogFilters:
|
||||||
"""The console's filters, shared by the page and its facet counts."""
|
"""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:
|
if self.run_id is not None:
|
||||||
out.append(LogEvent.run_id == self.run_id)
|
out.append(LogEvent.run_id == self.run_id)
|
||||||
if self.since is not None:
|
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:
|
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():
|
if self.q and self.q.strip():
|
||||||
like = f"%{like_escape(self.q.strip())}%"
|
like = f"%{like_escape(self.q.strip())}%"
|
||||||
out.append(
|
out.append(
|
||||||
@@ -165,28 +131,20 @@ def list_logs(
|
|||||||
continues from a previous page's `next_cursor`."""
|
continues from a previous page's `next_cursor`."""
|
||||||
query = select(LogEvent).where(*filters.conditions())
|
query = select(LogEvent).where(*filters.conditions())
|
||||||
if before is not None:
|
if before is not None:
|
||||||
ts, row_id = _decode_cursor(before)
|
query = query.where(older_than(LogEvent.ts, LogEvent.id, before))
|
||||||
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
|
rows, cursor = page(
|
||||||
# `ts` index scan instead of filtering every newer row first.
|
list(
|
||||||
query = query.where(
|
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
|
||||||
and_(
|
.scalars()
|
||||||
LogEvent.ts <= ts,
|
.all()
|
||||||
or_(LogEvent.ts < ts, and_(LogEvent.ts == ts, LogEvent.id < row_id)),
|
),
|
||||||
)
|
limit,
|
||||||
)
|
lambda r: r.ts,
|
||||||
|
|
||||||
# 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()
|
|
||||||
)
|
)
|
||||||
more = len(rows) > limit
|
|
||||||
rows = rows[:limit]
|
|
||||||
return {
|
return {
|
||||||
"items": [logsink.serialize(r) for r in rows],
|
"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}),
|
"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,
|
"next_cursor": cursor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -217,6 +175,6 @@ def log_facets(
|
|||||||
"sources": counts(LogEvent.source, "sources"),
|
"sources": counts(LogEvent.source, "sources"),
|
||||||
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
|
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
|
||||||
"users": [{**u, "value": str(u["value"])} for u in users],
|
"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,
|
"total": db.scalar(total) or 0,
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-27
@@ -1,11 +1,14 @@
|
|||||||
"""Admin audit log API: read the append-only who/what/when trail, and clear it.
|
"""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
|
Admin-only. The activity page reads it a page at a time, newest first, filtered on the server like
|
||||||
this returns the most recent window of rows (capped) plus the true total, rather than paging
|
the server log (`_keyset` paging), so the whole trail is reachable however long it grows."""
|
||||||
server-side — the log stays small in practice (state-changing runs + admin actions only)."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from dataclasses import dataclass
|
||||||
from sqlalchemy import delete, func, select
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import audit
|
from app import audit
|
||||||
@@ -13,55 +16,123 @@ from app.audit import AuditAction
|
|||||||
from app.auth import admin_user
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import AuditLog, User
|
from app.models import AuditLog, 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"])
|
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
|
# `none` selects the rows no account made (the background) — a value to filter on, not "any".
|
||||||
# switch the DataTable to server-side paging.
|
NONE = "none"
|
||||||
MAX_ROWS = 2000
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row: AuditLog, actors: dict[int, str]) -> dict:
|
def _serialize(row: AuditLog) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
"created_at": iso(row.created_at),
|
"created_at": iso(row.created_at),
|
||||||
# actor_id NULL = the scheduler/background did it (client shows "System"); a set id that
|
# NULL = the background did it (the client shows "System"); the address is in `accounts`.
|
||||||
# can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present.
|
"actor_id": row.actor_id,
|
||||||
"actor": actors.get(row.actor_id) if row.actor_id is not None else None,
|
|
||||||
"action": row.action,
|
"action": row.action,
|
||||||
"target_type": row.target_type,
|
"target_type": row.target_type,
|
||||||
"target_id": row.target_id,
|
"target_id": row.target_id,
|
||||||
"summary": row.summary,
|
"summary": row.summary,
|
||||||
"before": row.before,
|
"before": row.before,
|
||||||
"after": row.after,
|
"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("")
|
@router.get("")
|
||||||
def list_audit(
|
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),
|
_: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
limit = max(1, min(limit, MAX_ROWS))
|
"""One page of rows, newest first, matching every filter given; `before` continues from a
|
||||||
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
|
previous page's `next_cursor`."""
|
||||||
rows = (
|
query = select(AuditLog).where(*filters.conditions())
|
||||||
db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit))
|
if before is not None:
|
||||||
.scalars()
|
query = query.where(older_than(AuditLog.created_at, AuditLog.id, before))
|
||||||
.all()
|
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 {
|
return {
|
||||||
"items": [_serialize(r, actors) for r in rows],
|
"items": [_serialize(r) for r in rows],
|
||||||
"total": total,
|
"accounts": accounts(db, {r.actor_id for r in rows if r.actor_id is not None}),
|
||||||
"returned": len(rows),
|
"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("")
|
@router.delete("")
|
||||||
def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
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)."""
|
"""Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence)."""
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ def set_config(
|
|||||||
before=None if s.secret else {"value": _redact(old)},
|
before=None if s.secret else {"value": _redact(old)},
|
||||||
after=None if s.secret else {"value": _redact(new)},
|
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)
|
_apply_runtime(db, key)
|
||||||
return sysconfig.describe(db)
|
return sysconfig.describe(db)
|
||||||
|
|
||||||
|
|||||||
@@ -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 datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ from app import quota
|
|||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import QuotaEvent, User
|
from app.models import QuotaEvent, User
|
||||||
|
from app.routes._keyset import utc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/quota", tags=["quota"])
|
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")
|
@router.get("/admin")
|
||||||
def admin_usage(
|
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),
|
_: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
days = max(1, min(days, 365))
|
"""Spend per account (and per action) and per day, between `since` (default: 30 days back) and
|
||||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
`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(
|
totals = db.execute(
|
||||||
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
|
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
|
||||||
.where(QuotaEvent.created_at >= since)
|
.where(*window)
|
||||||
.group_by(QuotaEvent.user_id)
|
.group_by(QuotaEvent.user_id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
per_action: dict[int | None, dict[str, int]] = {}
|
per_action: dict[int | None, dict[str, int]] = {}
|
||||||
for uid, action, units in db.execute(
|
for uid, action, units in db.execute(
|
||||||
select(QuotaEvent.user_id, QuotaEvent.action, func.sum(QuotaEvent.units))
|
select(QuotaEvent.user_id, QuotaEvent.action, func.sum(QuotaEvent.units))
|
||||||
.where(QuotaEvent.created_at >= since)
|
.where(*window)
|
||||||
.group_by(QuotaEvent.user_id, QuotaEvent.action)
|
.group_by(QuotaEvent.user_id, QuotaEvent.action)
|
||||||
).all():
|
).all():
|
||||||
per_action.setdefault(uid, {})[action] = int(units)
|
per_action.setdefault(uid, {})[action] = int(units)
|
||||||
@@ -84,16 +96,16 @@ def admin_usage(
|
|||||||
]
|
]
|
||||||
rows.sort(key=lambda r: r["total"], reverse=True)
|
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))
|
day_expr = func.date(func.timezone("America/Los_Angeles", QuotaEvent.created_at))
|
||||||
daily = [
|
daily = [
|
||||||
{"day": str(day), "total": int(units)}
|
{"day": str(day), "total": int(units)}
|
||||||
for day, units in db.execute(
|
for day, units in db.execute(
|
||||||
select(day_expr, func.sum(QuotaEvent.units))
|
select(day_expr, func.sum(QuotaEvent.units))
|
||||||
.where(QuotaEvent.created_at >= since)
|
.where(*window)
|
||||||
.group_by(day_expr)
|
.group_by(day_expr)
|
||||||
.order_by(day_expr)
|
.order_by(day_expr)
|
||||||
).all()
|
).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
return {"range_days": days, "rows": rows, "daily": daily}
|
return {"rows": rows, "daily": daily}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -22,6 +22,7 @@ import pytest
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
from app.models import SystemConfig
|
||||||
from app.config import Settings, choices_of
|
from app.config import Settings, choices_of
|
||||||
|
|
||||||
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
|
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("job_interval_rss_poll") is None
|
||||||
assert sysconfig.config_page_spec("quota_daily_budget") is not None
|
assert sysconfig.config_page_spec("quota_daily_budget") is not None
|
||||||
assert "scheduler" not in sysconfig.describe(_FakeDb())["groups"]
|
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
|
||||||
|
|||||||
+126
-124
@@ -668,127 +668,129 @@ of a full-width panel and nothing on the right.
|
|||||||
|
|
||||||
## Decision log
|
## Decision log
|
||||||
|
|
||||||
| Date | Decision | Chosen | Over | Why |
|
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||||
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||||||
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
| 2026-09-25 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||||
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||||||
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
| 2026-09-26 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 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 | 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 | 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-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||||
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
||||||
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
||||||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||||||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||||
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||||||
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||||||
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||||||
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||||||
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||||||
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||||||
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||||||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||||||
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||||||
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||||||
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||||||
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||||||
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||||||
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||||||
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||||||
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||||||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||||||
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||||
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
||||||
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
||||||
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|
||||||
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
|
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
|
||||||
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
|
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
|
||||||
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
||||||
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
||||||
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
||||||
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
|
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
|
||||||
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
|
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
|
||||||
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
||||||
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
||||||
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
||||||
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
|
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
|
||||||
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
|
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
|
||||||
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
|
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
|
||||||
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
|
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
|
||||||
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
|
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
|
||||||
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
|
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
|
||||||
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
|
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
|
||||||
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
|
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
|
||||||
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
||||||
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
|
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
|
||||||
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
|
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
|
||||||
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
|
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
|
||||||
|
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
|
||||||
|
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async function violations(page: Page): Promise<string[]> {
|
|||||||
|
|
||||||
test.describe("accessibility (axe)", () => {
|
test.describe("accessibility (axe)", () => {
|
||||||
const RAIL = ["feed", "channels", "playlists", "files", "stats", "settings"];
|
const RAIL = ["feed", "channels", "playlists", "files", "stats", "settings"];
|
||||||
const ADMIN = ["scheduler", "config", "users", "audit"];
|
const ADMIN = ["scheduler", "config", "users", "activity"];
|
||||||
|
|
||||||
for (const id of RAIL) {
|
for (const id of RAIL) {
|
||||||
test(`page: ${id}`, async ({ page }) => {
|
test(`page: ${id}`, async ({ page }) => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ test.describe("page switching", () => {
|
|||||||
// and it failed on every run for a while without anyone seeing it, because e2e was in no
|
// and it failed on every run for a while without anyone seeing it, because e2e was in no
|
||||||
// required gate (it is now part of `release`).
|
// required gate (it is now part of `release`).
|
||||||
const RAIL = ["channels", "playlists", "files", "stats", "settings", "feed"];
|
const RAIL = ["channels", "playlists", "files", "stats", "settings", "feed"];
|
||||||
const ADMIN = ["scheduler", "config", "users", "audit"];
|
const ADMIN = ["scheduler", "config", "users", "activity"];
|
||||||
|
|
||||||
test("each rail item activates its page", async ({ page }) => {
|
test("each rail item activates its page", async ({ page }) => {
|
||||||
await openApp(page);
|
await openApp(page);
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The activity page around its run and audit lists (the server-log tab has its own file): the view
|
||||||
|
// is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens
|
||||||
|
// to its log lines and an admin action to its request's, each a new history entry that Back undoes;
|
||||||
|
// a link to the retired audit page opens the admin tab; the account filter reaches the quota tab; a
|
||||||
|
// failed read is an error with a retry, never an empty list.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { AuditQuery, AuditRow, HistoryPage, LogQuery, RunQuery, RunRow } from "../lib/api";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const runsMock = vi.fn<(q: RunQuery, before: string | null) => Promise<HistoryPage<RunRow>>>();
|
||||||
|
const auditMock = vi.fn<(q: AuditQuery, before: string | null) => Promise<HistoryPage<AuditRow>>>();
|
||||||
|
const logsMock = vi.fn<(q: LogQuery, before: string | null) => Promise<unknown>>();
|
||||||
|
const quotaMock = vi.fn<(since: string, user: string | null) => Promise<unknown>>();
|
||||||
|
vi.mock("../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
adminRuns: (q: RunQuery, before: string | null) => runsMock(q, before),
|
||||||
|
adminRunFacets: () => Promise.resolve({ jobs: [], statuses: [], total: 0 }),
|
||||||
|
adminAudit: (q: AuditQuery, before: string | null) => auditMock(q, before),
|
||||||
|
adminAuditFacets: () => Promise.resolve({ actions: [] }),
|
||||||
|
adminLogs: (q: LogQuery, before: string | null) => logsMock(q, before),
|
||||||
|
adminLogFacets: () =>
|
||||||
|
Promise.resolve({ levels: [], sources: [], events: [], users: [], accounts: {}, total: 0 }),
|
||||||
|
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
|
||||||
|
adminQuota: (since: string, user: string | null) => quotaMock(since, user),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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"));
|
||||||
|
|
||||||
|
const RUN: RunRow = {
|
||||||
|
id: 41,
|
||||||
|
job: "backfill",
|
||||||
|
trigger: "manual",
|
||||||
|
actor_id: 1,
|
||||||
|
target: null,
|
||||||
|
target_title: null,
|
||||||
|
status: "error",
|
||||||
|
started_at: "2026-10-18T02:31:04Z",
|
||||||
|
finished_at: "2026-10-18T02:31:45Z",
|
||||||
|
summary: null,
|
||||||
|
result: null,
|
||||||
|
error: "quotaExceeded",
|
||||||
|
failure: null,
|
||||||
|
quota_units: 212,
|
||||||
|
log_lines: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION: AuditRow = {
|
||||||
|
id: 5,
|
||||||
|
created_at: "2026-10-18T02:27:00Z",
|
||||||
|
actor_id: 1,
|
||||||
|
action: "scheduler_interval",
|
||||||
|
target_type: "job",
|
||||||
|
target_id: "rss_poll",
|
||||||
|
summary: "rss_poll → 25 min/run",
|
||||||
|
before: null,
|
||||||
|
after: { interval_minutes: 25 },
|
||||||
|
request_id: "0efdd960c9ad1977",
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = <T,>(items: T[]): HistoryPage<T> => ({
|
||||||
|
items,
|
||||||
|
accounts: { "1": "admin@example.com" },
|
||||||
|
next_cursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PageToolbarSlot.Provider value={slot}>
|
||||||
|
<Activity />
|
||||||
|
</PageToolbarSlot.Provider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = () => new URLSearchParams(window.location.search);
|
||||||
|
const rowButton = (text: RegExp) =>
|
||||||
|
screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.hasAttribute("aria-expanded") && text.test(b.textContent ?? ""))!;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
runsMock.mockResolvedValue(page([RUN]));
|
||||||
|
auditMock.mockResolvedValue(page([ACTION]));
|
||||||
|
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||||
|
quotaMock.mockResolvedValue({ rows: [], daily: [] });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Activity", () => {
|
||||||
|
it("a link narrows the runs to one job, and a status chip narrows them further", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=activity&rjob=backfill");
|
||||||
|
renderPage();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(runsMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ job: "backfill", status: undefined, since: expect.any(String) }),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /activity\.runs\.status\.error/ }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(runsMock).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ job: "backfill", status: "error" }),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect([url().get("rjob"), url().get("rst")]).toEqual(["backfill", "error"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a run opens to its log lines, and Back returns to the runs", async () => {
|
||||||
|
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&aw=24h&aq=quota");
|
||||||
|
renderPage();
|
||||||
|
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
|
||||||
|
fireEvent.click(rowButton(/quotaExceeded/));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "activity.runs.logLines" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(logsMock).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ run_id: 41, q: undefined }),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Every other filter is off, and the range is the log's whole retention.
|
||||||
|
expect([url().get("at"), url().get("lrun"), url().get("aw"), url().get("aq")]).toEqual([
|
||||||
|
"logs",
|
||||||
|
"41",
|
||||||
|
"90d",
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const landed = new Promise((resolve) =>
|
||||||
|
window.addEventListener("popstate", resolve, { once: true }),
|
||||||
|
);
|
||||||
|
window.history.back();
|
||||||
|
await landed;
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(url().get("at")).toBeNull());
|
||||||
|
expect([url().get("aw"), url().get("aq"), url().get("lrun")]).toEqual(["24h", "quota", null]);
|
||||||
|
expect(screen.getByText("quotaExceeded")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a link to the retired audit page opens the admin tab, and an action opens its request's lines", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=audit");
|
||||||
|
renderPage();
|
||||||
|
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
|
||||||
|
expect(url().get("page")).toBe("activity");
|
||||||
|
expect(runsMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(rowButton(/auditActions\.scheduler_interval/));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "activity.audit.requestLines" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(logsMock).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ request_id: "0efdd960c9ad1977" }),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(url().get("lreq")).toBe("0efdd960c9ad1977");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the account filter reaches the quota tab, over the page's range", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=activity&at=quota&au=none&aw=30d");
|
||||||
|
renderPage();
|
||||||
|
await waitFor(() => expect(quotaMock).toHaveBeenCalled());
|
||||||
|
const [since, user] = quotaMock.mock.calls[0]!;
|
||||||
|
expect(user).toBe("none");
|
||||||
|
const days = (Date.now() - new Date(since).getTime()) / 86_400_000;
|
||||||
|
expect(Math.round(days)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed read is an error with a retry, not an empty list", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=activity");
|
||||||
|
runsMock.mockRejectedValue(new Error("502"));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("activity.runs.empty")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import {
|
||||||
|
ACTIVITY_TABS,
|
||||||
|
ACTIVITY_WINDOWS,
|
||||||
|
activityParamsToState,
|
||||||
|
activityParamUpdates,
|
||||||
|
type ActivityTab,
|
||||||
|
type ActivityUrlState,
|
||||||
|
type ActivityWindow,
|
||||||
|
} from "../lib/activityUrlState";
|
||||||
|
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
|
||||||
|
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "../lib/logsUrlState";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
import { urlWithParams } from "../lib/urlState";
|
||||||
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
|
import ActivityAudit from "./ActivityAudit";
|
||||||
|
import ActivityQuota from "./ActivityQuota";
|
||||||
|
import ActivityRuns from "./ActivityRuns";
|
||||||
|
import AdminLogs, { type LogPivot } from "./AdminLogs";
|
||||||
|
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||||
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
|
import { InnerInput } from "./ui/Input";
|
||||||
|
import { fieldClass } from "./ui/fieldStyles";
|
||||||
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
|
import Tabs from "./ui/Tabs";
|
||||||
|
import { useTabPanel } from "./ui/tabPanel";
|
||||||
|
|
||||||
|
// Activity ("Napló"): what happened, in one place — the background runs, the admins' actions, the
|
||||||
|
// server's log and the quota spend, each its own table on the server, read here as four tabs under
|
||||||
|
// one filter bar (time range, account, text). The view lives in the URL, so it is a link; a pivot to
|
||||||
|
// one run's or one request's log lines is a new history entry, and Back returns to where it was made.
|
||||||
|
|
||||||
|
// A pivot shows everything the run or request logged: every other filter would only hide part of it,
|
||||||
|
// and the longest range is the log's own retention.
|
||||||
|
const PIVOT_SHARED: Pick<ActivityUrlState, "window" | "user" | "q"> = {
|
||||||
|
window: "90d",
|
||||||
|
user: null,
|
||||||
|
q: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Activity() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<ActivityUrlState>(() =>
|
||||||
|
activityParamsToState(new URLSearchParams(window.location.search)),
|
||||||
|
);
|
||||||
|
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the search
|
||||||
|
// text then applies at once instead of after the typing debounce, and the server log re-reads its
|
||||||
|
// own filters from the address bar.
|
||||||
|
const [jumps, setJumps] = useState(0);
|
||||||
|
const q = useDebounced(state.q, 300, jumps);
|
||||||
|
const panel = useTabPanel();
|
||||||
|
// `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id.
|
||||||
|
useUrlMirror({ page: "activity", ...activityParamUpdates(state) });
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => {
|
||||||
|
setJumps((n) => n + 1);
|
||||||
|
setState(activityParamsToState(new URLSearchParams(window.location.search)));
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
const update = (patch: Partial<ActivityUrlState>) => setState((s) => ({ ...s, ...patch }));
|
||||||
|
|
||||||
|
const pivot = (target: LogPivot) => {
|
||||||
|
const next: ActivityUrlState = { ...state, ...PIVOT_SHARED, tab: "logs" };
|
||||||
|
window.history.pushState(
|
||||||
|
{ ...rememberNavPosition(), sfScroll: nextNavId() },
|
||||||
|
"",
|
||||||
|
urlWithParams({
|
||||||
|
page: "activity",
|
||||||
|
...activityParamUpdates(next),
|
||||||
|
...logsParamUpdates({ ...DEFAULT_LOGS_STATE, ...target }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
notifyNav();
|
||||||
|
setJumps((n) => n + 1);
|
||||||
|
setState(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shared = { window: state.window, user: state.user, q };
|
||||||
|
return (
|
||||||
|
<div {...panel.panelProps(state.tab)} className="px-4 pb-4 pt-3 max-w-6xl w-full mx-auto">
|
||||||
|
<PageToolbar>
|
||||||
|
<div className="px-4 pt-2 max-w-6xl w-full mx-auto">
|
||||||
|
<Tabs
|
||||||
|
idBase={panel.idBase}
|
||||||
|
active={state.tab}
|
||||||
|
onChange={(tab) => update({ tab: tab as ActivityTab })}
|
||||||
|
tabs={ACTIVITY_TABS.map((id) => ({ id, label: t(`activity.tabs.${id}`) }))}
|
||||||
|
className="flex flex-wrap gap-1 pb-2"
|
||||||
|
/>
|
||||||
|
<SharedBar state={state} onChange={update} />
|
||||||
|
</div>
|
||||||
|
</PageToolbar>
|
||||||
|
{/* The quota tab says what its numbers are itself, beside them. */}
|
||||||
|
{state.tab !== "quota" && (
|
||||||
|
<p className="mb-3 text-xs leading-relaxed text-muted">
|
||||||
|
{t(`activity.intro.${state.tab}`)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.tab === "runs" ? (
|
||||||
|
<ActivityRuns
|
||||||
|
shared={shared}
|
||||||
|
job={state.job}
|
||||||
|
status={state.status}
|
||||||
|
onFilter={update}
|
||||||
|
onLogs={(runId) => pivot({ requestId: null, jobId: null, runId })}
|
||||||
|
/>
|
||||||
|
) : state.tab === "admin" ? (
|
||||||
|
<ActivityAudit
|
||||||
|
shared={shared}
|
||||||
|
action={state.action}
|
||||||
|
onAction={(action) => update({ action })}
|
||||||
|
onRequest={(requestId) => pivot({ requestId, jobId: null, runId: null })}
|
||||||
|
/>
|
||||||
|
) : state.tab === "logs" ? (
|
||||||
|
// Keyed by the jumps: a pivot or Back/Forward hands the log its filters through the address
|
||||||
|
// bar, which it reads when it mounts.
|
||||||
|
<AdminLogs key={jumps} shared={shared} onPivot={pivot} />
|
||||||
|
) : (
|
||||||
|
<ActivityQuota shared={shared} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The filters every tab shares: the text (not on the quota tab — it has nothing to match), the time
|
||||||
|
* range, and the account. */
|
||||||
|
function SharedBar({
|
||||||
|
state,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
state: ActivityUrlState;
|
||||||
|
onChange: (patch: Partial<ActivityUrlState>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||||
|
const known = users.data ?? [];
|
||||||
|
// The account in use stays offered even when the list does not have it (an erased account).
|
||||||
|
const kept =
|
||||||
|
state.user && state.user !== "none" && !known.some((u) => String(u.id) === state.user)
|
||||||
|
? [{ value: state.user, label: `#${state.user}` }]
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||||
|
{state.tab !== "quota" && (
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
fieldClass({ size: "sm", pill: true }),
|
||||||
|
"flex min-w-[12rem] flex-1 items-center gap-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
||||||
|
<InnerInput
|
||||||
|
type="search"
|
||||||
|
value={state.q}
|
||||||
|
onChange={(e) => onChange({ q: e.target.value.slice(0, 200) })}
|
||||||
|
placeholder={t("activity.search")}
|
||||||
|
aria-label={t("activity.search")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<FilterChipGroup
|
||||||
|
ariaLabel={t("activity.windowLabel")}
|
||||||
|
value={state.window}
|
||||||
|
onChange={(window) => onChange({ window: window as ActivityWindow })}
|
||||||
|
options={ACTIVITY_WINDOWS.map((w) => ({ value: w, label: t(`activity.windows.${w}`) }))}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label={t("activity.account")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("activity.any") },
|
||||||
|
{ value: "none", label: t("activity.noAccount") },
|
||||||
|
...known.map((u) => ({ value: String(u.id), label: u.email })),
|
||||||
|
...kept,
|
||||||
|
]}
|
||||||
|
selected={[state.user ?? ""]}
|
||||||
|
onChange={([user]) => onChange({ user: user || null })}
|
||||||
|
testId="activity-user"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { api, type AuditQuery, type AuditRow } from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { sinceOf } from "../lib/activityUrlState";
|
||||||
|
import { auditActionLabel, auditText } from "../lib/auditText";
|
||||||
|
import { formatDateTime, formatDateTimeSec } from "../lib/format";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useHistoryList } from "../lib/useHistoryList";
|
||||||
|
import type { Shared } from "./ActivityRuns";
|
||||||
|
import { useConfirm } from "./confirmContext";
|
||||||
|
import HistoryList from "./HistoryList";
|
||||||
|
import HistoryRow from "./HistoryRow";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
|
|
||||||
|
// Activity → Admin actions: the append-only who/what/when trail, newest first, filtered on the
|
||||||
|
// server. A row opens to its before/after and to the lines its request logged. Clear stays here,
|
||||||
|
// leaving one row that says who cleared it.
|
||||||
|
|
||||||
|
const GRID =
|
||||||
|
"grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(0,1fr)]";
|
||||||
|
|
||||||
|
export default function ActivityAudit({
|
||||||
|
shared,
|
||||||
|
action,
|
||||||
|
onAction,
|
||||||
|
onRequest,
|
||||||
|
}: {
|
||||||
|
shared: Shared;
|
||||||
|
action: string | null;
|
||||||
|
onAction: (action: string | null) => void;
|
||||||
|
/** Open the server log at this request's lines. */
|
||||||
|
onRequest: (requestId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [open, setOpen] = useState<number | null>(null);
|
||||||
|
const query: AuditQuery = {
|
||||||
|
user: shared.user ?? undefined,
|
||||||
|
q: shared.q.trim() || undefined,
|
||||||
|
action: action ?? undefined,
|
||||||
|
};
|
||||||
|
const key = { ...query, window: shared.window };
|
||||||
|
const state = useHistoryList(qk.adminAudit(key), (before) =>
|
||||||
|
api.adminAudit({ ...query, since: sinceOf(shared.window) }, before),
|
||||||
|
);
|
||||||
|
const facets = useQuery({
|
||||||
|
queryKey: qk.adminAuditFacets(key),
|
||||||
|
queryFn: () => api.adminAuditFacets({ ...query, since: sinceOf(shared.window) }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
const actions = [
|
||||||
|
...(facets.data?.actions ?? []),
|
||||||
|
...(action && !facets.data?.actions.some((f) => f.value === action)
|
||||||
|
? [{ value: action, count: 0 }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: () => api.clearAudit(),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin-audit"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin-audit-facets"] });
|
||||||
|
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||||
|
},
|
||||||
|
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
||||||
|
});
|
||||||
|
const onClear = async () => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
title: t("audit.clearTitle"),
|
||||||
|
message: t("audit.clearConfirm"),
|
||||||
|
confirmLabel: t("audit.clear"),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
clear.mutate();
|
||||||
|
};
|
||||||
|
const actor = (row: AuditRow) =>
|
||||||
|
row.actor_id === null ? null : (state.accounts[String(row.actor_id)] ?? `#${row.actor_id}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<FilterSelect
|
||||||
|
label={t("activity.audit.action")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("activity.any") },
|
||||||
|
...actions.map((f) => ({
|
||||||
|
value: f.value,
|
||||||
|
label: auditActionLabel(t, f.value),
|
||||||
|
count: f.count,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
selected={[action ?? ""]}
|
||||||
|
onChange={([value]) => onAction(value || null)}
|
||||||
|
testId="audit-action"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="xs"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => void onClear()}
|
||||||
|
disabled={clear.isPending}
|
||||||
|
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
>
|
||||||
|
{t("audit.clear")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<HistoryList
|
||||||
|
state={state}
|
||||||
|
empty={t("audit.empty")}
|
||||||
|
count={t("activity.audit.count", { count: state.rows.length })}
|
||||||
|
>
|
||||||
|
{state.rows.map((row, index) => {
|
||||||
|
const { head, sub } = auditText(t, row, lang);
|
||||||
|
const who = actor(row);
|
||||||
|
return (
|
||||||
|
<HistoryRow
|
||||||
|
key={row.id}
|
||||||
|
navIndex={index}
|
||||||
|
navText={`${auditActionLabel(t, row.action)} ${head ?? ""}`}
|
||||||
|
grid={GRID}
|
||||||
|
open={open === row.id}
|
||||||
|
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||||
|
title={formatDateTimeSec(row.created_at, lang)}
|
||||||
|
cells={
|
||||||
|
<>
|
||||||
|
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
|
||||||
|
<span className="tabular-nums text-muted sm:hidden">
|
||||||
|
{formatDateTime(row.created_at, lang)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
|
||||||
|
>
|
||||||
|
{who ?? t("audit.system")}
|
||||||
|
</span>
|
||||||
|
<span className="hidden tabular-nums text-muted sm:block">
|
||||||
|
{formatDateTime(row.created_at, lang)}
|
||||||
|
</span>
|
||||||
|
<span className="col-span-2 truncate text-muted sm:col-span-1">
|
||||||
|
{[head, sub].filter(Boolean).join(" · ")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuditDetail row={row} who={who} onRequest={onRequest} />
|
||||||
|
</HistoryRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HistoryList>
|
||||||
|
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditDetail({
|
||||||
|
row,
|
||||||
|
who,
|
||||||
|
onRequest,
|
||||||
|
}: {
|
||||||
|
row: AuditRow;
|
||||||
|
who: string | null;
|
||||||
|
onRequest: (requestId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
|
const { head, sub } = auditText(t, row, lang);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
||||||
|
<dt>{t("audit.cols.actor")}</dt>
|
||||||
|
<dd>{who ?? t("audit.system")}</dd>
|
||||||
|
<dt>{t("audit.cols.when")}</dt>
|
||||||
|
<dd className="tabular-nums">{formatDateTimeSec(row.created_at, lang)}</dd>
|
||||||
|
{(head || sub) && (
|
||||||
|
<>
|
||||||
|
<dt>{t("audit.cols.details")}</dt>
|
||||||
|
<dd className="wrap-break-word">{[head, sub].filter(Boolean).join(" · ")}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.target_id && (
|
||||||
|
<>
|
||||||
|
<dt>{t("activity.audit.target")}</dt>
|
||||||
|
<dd className="font-mono">{row.target_id}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
{(row.before || row.after) && (
|
||||||
|
<pre className="overflow-x-auto whitespace-pre font-mono">
|
||||||
|
{JSON.stringify({ before: row.before, after: row.after }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{row.request_id && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
||||||
|
onClick={() => onRequest(row.request_id!)}
|
||||||
|
>
|
||||||
|
{t("activity.audit.requestLines")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={q.isPlaceholderData ? "opacity-60" : ""} aria-busy={q.isPlaceholderData}>
|
||||||
|
<p className="text-xs text-muted mb-3 leading-relaxed">
|
||||||
|
{t("stats.introBefore")}
|
||||||
|
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
||||||
|
{t("stats.introAfter")}
|
||||||
|
</p>
|
||||||
|
{q.isError && !data ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading || !data ? (
|
||||||
|
<LoadingState label={t("stats.loading")} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{q.isError && (
|
||||||
|
<StateMessage tone="error" compact onRetry={() => void q.refetch()} className="mb-3">
|
||||||
|
{t("activity.stale")}
|
||||||
|
</StateMessage>
|
||||||
|
)}
|
||||||
|
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||||
|
<div className="text-xs text-muted mb-2">
|
||||||
|
{t("activity.quota.total", {
|
||||||
|
window: t(`activity.windows.${shared.window}`),
|
||||||
|
units: formatNumber(grandTotal, lang),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{data.daily.length === 0 ? (
|
||||||
|
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-end gap-0.5 h-24">
|
||||||
|
{data.daily.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d.day}
|
||||||
|
// The day under the pointer is lit by the B3 layer over its whole column, not by
|
||||||
|
// dimming the bar: an opacity dim reads as "disabled".
|
||||||
|
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
|
||||||
|
title={t("stats.dailyTooltip", {
|
||||||
|
day: d.day,
|
||||||
|
units: formatNumber(d.total, lang),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full bg-accent rounded-t"
|
||||||
|
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{data.rows.length === 0 ? (
|
||||||
|
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
|
||||||
|
) : (
|
||||||
|
data.rows.map((r) => (
|
||||||
|
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
||||||
|
const isSystem = row.user_id === null;
|
||||||
|
return (
|
||||||
|
<div className={`${SURFACE.card} p-3`}>
|
||||||
|
<Pressable
|
||||||
|
selectable
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
||||||
|
{isSystem ? t("stats.system") : row.email}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||||
|
{formatNumber(row.total, lang)}
|
||||||
|
</span>
|
||||||
|
</Pressable>
|
||||||
|
{/* Proportion bar */}
|
||||||
|
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent rounded-full"
|
||||||
|
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{open && actions.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||||
|
{actions.map(([action, units]) => (
|
||||||
|
<div key={action} className="flex items-center justify-between gap-2">
|
||||||
|
<span>{quotaActionLabel(t, action)}</span>
|
||||||
|
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number | null>(null);
|
||||||
|
const query: RunQuery = {
|
||||||
|
user: shared.user ?? undefined,
|
||||||
|
q: shared.q.trim() || undefined,
|
||||||
|
job: job ?? undefined,
|
||||||
|
status: status ?? undefined,
|
||||||
|
};
|
||||||
|
const key = { ...query, window: shared.window };
|
||||||
|
const state = useHistoryList(qk.adminRuns(key), (before) =>
|
||||||
|
api.adminRuns({ ...query, since: sinceOf(shared.window) }, before),
|
||||||
|
);
|
||||||
|
const facets = useQuery({
|
||||||
|
queryKey: qk.adminRunFacets(key),
|
||||||
|
queryFn: () => api.adminRunFacets({ ...query, since: sinceOf(shared.window) }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
const count = (list: LogFacet[] | undefined, value: string) =>
|
||||||
|
list?.find((f) => f.value === value)?.count ?? (facets.data ? 0 : undefined);
|
||||||
|
const jobName = (id: string) => t(`scheduler.jobs.${id}`, id);
|
||||||
|
// The job in use stays offered at zero: under the other filters it may count nothing, and a menu
|
||||||
|
// without it would show no way back.
|
||||||
|
const jobs = [
|
||||||
|
...(facets.data?.jobs ?? []),
|
||||||
|
...(job && !facets.data?.jobs.some((f) => f.value === job) ? [{ value: job, count: 0 }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<FilterSelect
|
||||||
|
label={t("activity.runs.job")}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t("activity.any") },
|
||||||
|
...jobs.map((f) => ({ value: f.value, label: jobName(f.value), count: f.count })),
|
||||||
|
]}
|
||||||
|
selected={[job ?? ""]}
|
||||||
|
onChange={([value]) => onFilter({ job: value || null })}
|
||||||
|
testId="runs-job"
|
||||||
|
/>
|
||||||
|
<FilterChipGroup
|
||||||
|
ariaLabel={t("activity.runs.statusLabel")}
|
||||||
|
value={status ?? "all"}
|
||||||
|
onChange={(value) =>
|
||||||
|
onFilter({ status: value === "all" ? null : (value as RunStatusFilter) })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: t("activity.any"), count: facets.data?.total },
|
||||||
|
...RUN_STATUSES.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: t(`activity.runs.status.${s}`),
|
||||||
|
count: count(facets.data?.statuses, s),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<HistoryList
|
||||||
|
state={state}
|
||||||
|
empty={t("activity.runs.empty")}
|
||||||
|
count={t("activity.runs.count", { count: state.rows.length })}
|
||||||
|
>
|
||||||
|
{state.rows.map((run, index) => (
|
||||||
|
<HistoryRow
|
||||||
|
key={run.id}
|
||||||
|
navIndex={index}
|
||||||
|
navText={`${jobName(run.job)} ${run.target_title ?? ""}`}
|
||||||
|
grid={GRID}
|
||||||
|
open={open === run.id}
|
||||||
|
onToggle={() => setOpen(open === run.id ? null : run.id)}
|
||||||
|
title={formatDateTimeSec(run.started_at, lang)}
|
||||||
|
cells={
|
||||||
|
<RunCells
|
||||||
|
run={run}
|
||||||
|
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RunDetail
|
||||||
|
run={run}
|
||||||
|
actor={run.actor_id === null ? null : state.accounts[String(run.actor_id)]}
|
||||||
|
onLogs={() => onLogs(run.id)}
|
||||||
|
onJob={() => onFilter({ job: run.job })}
|
||||||
|
/>
|
||||||
|
</HistoryRow>
|
||||||
|
))}
|
||||||
|
</HistoryList>
|
||||||
|
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<StatusDot k={run.status} label={t(`scheduler.dot.${run.status}`)} />
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{name}
|
||||||
|
{run.target_title && <span className="font-normal text-muted"> · {run.target_title}</span>}
|
||||||
|
</span>
|
||||||
|
{/* A phone has room for the name and the time only; the row opens to the rest. */}
|
||||||
|
<span className="tabular-nums text-muted sm:hidden">
|
||||||
|
{formatDateTime(run.started_at, lang)}
|
||||||
|
</span>
|
||||||
|
<span className="hidden truncate text-muted sm:block">
|
||||||
|
{t(`scheduler.trigger.${run.trigger}`)}
|
||||||
|
{actor && ` · ${actor}`}
|
||||||
|
</span>
|
||||||
|
<span className="hidden tabular-nums text-muted sm:block">
|
||||||
|
{formatDateTime(run.started_at, lang)}
|
||||||
|
</span>
|
||||||
|
<span className="hidden tabular-nums text-muted sm:block">
|
||||||
|
{secs === null ? "—" : formatRuntime(Math.max(1, secs), lang)}
|
||||||
|
</span>
|
||||||
|
<span className="hidden tabular-nums text-muted sm:block">
|
||||||
|
{formatNumber(run.quota_units, lang)}
|
||||||
|
</span>
|
||||||
|
<span className={`col-span-3 truncate sm:col-span-1 ${outcome.tone}`}>{outcome.text}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunDetail({
|
||||||
|
run,
|
||||||
|
actor,
|
||||||
|
onLogs,
|
||||||
|
onJob,
|
||||||
|
}: {
|
||||||
|
run: RunRow;
|
||||||
|
actor: string | null | undefined;
|
||||||
|
onLogs: () => void;
|
||||||
|
onJob: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
|
const link = "underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
||||||
|
<dt>{t("activity.runs.fields.started")}</dt>
|
||||||
|
<dd>
|
||||||
|
{run.actor_id === null
|
||||||
|
? t(`scheduler.trigger.${run.trigger}`)
|
||||||
|
: `${actor ?? `#${run.actor_id}`} · ${t(`scheduler.trigger.${run.trigger}`)}`}
|
||||||
|
</dd>
|
||||||
|
<dt>{t("activity.runs.fields.when")}</dt>
|
||||||
|
<dd className="tabular-nums">
|
||||||
|
{formatDateTimeSec(run.started_at, lang)}
|
||||||
|
{run.finished_at && ` → ${formatDateTimeSec(run.finished_at, lang)}`}
|
||||||
|
</dd>
|
||||||
|
{run.target && (
|
||||||
|
<>
|
||||||
|
<dt>{t("activity.runs.fields.target")}</dt>
|
||||||
|
<dd>
|
||||||
|
{run.target_title ? `${run.target_title} · ` : ""}
|
||||||
|
<span className="font-mono">{run.target}</span>
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt>{t("activity.runs.fields.quota")}</dt>
|
||||||
|
<dd className="tabular-nums">{formatNumber(run.quota_units, lang)}</dd>
|
||||||
|
</dl>
|
||||||
|
{run.error && (
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-danger/90">{run.error}</pre>
|
||||||
|
)}
|
||||||
|
{run.result && (
|
||||||
|
<pre className="overflow-x-auto whitespace-pre font-mono">
|
||||||
|
{JSON.stringify(run.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<Button variant="link" className={link} onClick={onLogs}>
|
||||||
|
{t("activity.runs.logLines", { count: run.log_lines })}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" className={link} onClick={onJob}>
|
||||||
|
{t("activity.runs.jobRuns")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The admin log console. A request id narrows the list to that request (and says so, in the
|
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
|
||||||
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
|
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
|
||||||
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
|
// filter row and the URL), and Back returns to the view the pivot was made from; an old link to the
|
||||||
// error, not an empty log; Copy takes every loaded line, oldest first.
|
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -32,6 +34,7 @@ vi.mock("../lib/api", async (importOriginal) => {
|
|||||||
...actual.api,
|
...actual.api,
|
||||||
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
|
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
|
||||||
adminLogFacets: (query: LogQuery) => facetsMock(query),
|
adminLogFacets: (query: LogQuery) => facetsMock(query),
|
||||||
|
adminUsers: () => Promise.resolve([{ id: 1, email: "admin@example.com" }]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -44,7 +47,11 @@ vi.mock("../lib/clipboard", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import AdminLogs from "./AdminLogs";
|
import Activity from "./Activity";
|
||||||
|
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
||||||
|
|
||||||
|
// The filter bar lives in the toolbar, which portals into the shell's band: give it one.
|
||||||
|
const slot = document.body.appendChild(document.createElement("div"));
|
||||||
|
|
||||||
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
|
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
|
||||||
return {
|
return {
|
||||||
@@ -60,6 +67,7 @@ function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
|
|||||||
request_id: `req-${String(id).padStart(8, "0")}`,
|
request_id: `req-${String(id).padStart(8, "0")}`,
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
job_id: null,
|
job_id: null,
|
||||||
|
run_id: null,
|
||||||
debug_session_id: null,
|
debug_session_id: null,
|
||||||
version: "0.90.0",
|
version: "0.90.0",
|
||||||
...extra,
|
...extra,
|
||||||
@@ -79,7 +87,9 @@ function renderConsole() {
|
|||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<AdminLogs />
|
<PageToolbarSlot.Provider value={slot}>
|
||||||
|
<Activity />
|
||||||
|
</PageToolbarSlot.Provider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,7 +103,7 @@ const lines = () =>
|
|||||||
.filter((b) => b.hasAttribute("aria-expanded"));
|
.filter((b) => b.hasAttribute("aria-expanded"));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.history.replaceState(null, "", "/?page=logs");
|
window.history.replaceState(null, "", "/?page=activity&at=logs");
|
||||||
facetsMock.mockResolvedValue(FACETS);
|
facetsMock.mockResolvedValue(FACETS);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -105,7 +115,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("AdminLogs", () => {
|
describe("AdminLogs", () => {
|
||||||
it("a request id narrows the list to that request, with every other filter off", async () => {
|
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 });
|
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||||
renderConsole();
|
renderConsole();
|
||||||
|
|
||||||
@@ -121,11 +131,15 @@ describe("AdminLogs", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
|
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
|
||||||
const url = new URLSearchParams(window.location.search);
|
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 () => {
|
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;
|
const depth = window.history.length;
|
||||||
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
|
||||||
renderConsole();
|
renderConsole();
|
||||||
@@ -157,23 +171,60 @@ describe("AdminLogs", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
|
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
|
||||||
const url = new URLSearchParams(window.location.search);
|
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
|
// 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).
|
// 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"));
|
const mixed = logsMock.mock.calls.filter(([q]) => (q.request_id != null) === (q.q === "boom"));
|
||||||
expect(mixed).toEqual([]);
|
expect(mixed).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the account in use offered when the facets no longer count it", async () => {
|
it("a run id narrows the list to that run's lines", async () => {
|
||||||
window.history.replaceState(null, "", "/?page=logs&lu=7");
|
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 });
|
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
|
||||||
renderConsole();
|
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();
|
it("keeps the account in use offered when the account list does not have it", async () => {
|
||||||
expect(screen.getByTestId("logs-user-1")).toBeTruthy();
|
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 () => {
|
it("Live watches the newest page only", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import clsx from "clsx";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
@@ -7,26 +6,17 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} 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 { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { formatDateTimeSec } from "../lib/format";
|
import { formatDateTimeSec } from "../lib/format";
|
||||||
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
|
|
||||||
import { nextPageOnClick } from "../lib/nextPage";
|
import { nextPageOnClick } from "../lib/nextPage";
|
||||||
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
|
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
|
||||||
import {
|
import { sinceOf } from "../lib/activityUrlState";
|
||||||
DEFAULT_LOGS_STATE,
|
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../lib/logsUrlState";
|
||||||
logsParamsToState,
|
|
||||||
logsParamUpdates,
|
|
||||||
WINDOW_HOURS,
|
|
||||||
type LogsUrlState,
|
|
||||||
type LogWindow,
|
|
||||||
} from "../lib/logsUrlState";
|
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
|
||||||
import { urlWithParams } from "../lib/urlState";
|
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import LogLine from "./LogLine";
|
import LogLine from "./LogLine";
|
||||||
import NavGrid from "./ui/NavGrid";
|
import NavGrid from "./ui/NavGrid";
|
||||||
@@ -35,46 +25,45 @@ import FilterChipGroup from "./ui/FilterChipGroup";
|
|||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "./ui/FilterSelect";
|
||||||
import { FilterFoldButton } from "./ui/FilterFold";
|
import { FilterFoldButton } from "./ui/FilterFold";
|
||||||
import { useFilterFold } from "../lib/useFilterFold";
|
import { useFilterFold } from "../lib/useFilterFold";
|
||||||
import { useStickyTop } from "./ui/stickyTop";
|
|
||||||
import { Section } from "./ui/form";
|
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import { DismissibleChip } from "./ui/Chip";
|
import { DismissibleChip } from "./ui/Chip";
|
||||||
import { InnerInput } from "./ui/Input";
|
import type { Shared } from "./ActivityRuns";
|
||||||
import { fieldClass } from "./ui/fieldStyles";
|
|
||||||
|
|
||||||
// Admin → Szervernapló: the server's stored log (`log_events`). One list, newest first; a line
|
// 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 id / job id narrow the list to that request or
|
// opens in place to its full record, and its request / job / run id narrows the list to that
|
||||||
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
|
// 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
|
// 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
|
// 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.
|
// once) and marks the list as possibly stale, instead of re-raising the failure every few seconds.
|
||||||
|
|
||||||
const LIVE_MS = 4000;
|
const LIVE_MS = 4000;
|
||||||
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
|
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
|
||||||
const WINDOWS: readonly LogWindow[] = ["1h", "24h", "7d"];
|
function toQuery(state: LogsUrlState, shared: Shared): LogQuery {
|
||||||
// A pivot shows everything about one request or job: the other filters would only hide part of it.
|
|
||||||
const PIVOT_RESET: Omit<LogsUrlState, "requestId" | "jobId"> = {
|
|
||||||
...DEFAULT_LOGS_STATE,
|
|
||||||
window: "7d",
|
|
||||||
};
|
|
||||||
|
|
||||||
function sinceOf(window: LogWindow): string {
|
|
||||||
return new Date(Date.now() - WINDOW_HOURS[window] * 3_600_000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toQuery(state: LogsUrlState, q: string): LogQuery {
|
|
||||||
return {
|
return {
|
||||||
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
|
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
|
||||||
sources: state.sources,
|
sources: state.sources,
|
||||||
user: state.user ?? undefined,
|
user: shared.user ?? undefined,
|
||||||
event: state.event ?? undefined,
|
event: state.event ?? undefined,
|
||||||
request_id: state.requestId ?? undefined,
|
request_id: state.requestId ?? undefined,
|
||||||
job_id: state.jobId ?? undefined,
|
job_id: state.jobId ?? undefined,
|
||||||
q: q.trim() || undefined,
|
run_id: state.runId ?? undefined,
|
||||||
|
q: shared.q.trim() || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLogs() {
|
/** The ids a line can narrow the list to. */
|
||||||
|
export type LogPivot = Pick<LogsUrlState, "requestId" | "jobId" | "runId">;
|
||||||
|
|
||||||
|
export default function AdminLogs({
|
||||||
|
shared,
|
||||||
|
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 { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -82,14 +71,9 @@ export default function AdminLogs() {
|
|||||||
logsParamsToState(new URLSearchParams(window.location.search)),
|
logsParamsToState(new URLSearchParams(window.location.search)),
|
||||||
);
|
);
|
||||||
const [live, setLiveState] = useState(false);
|
const [live, setLiveState] = useState(false);
|
||||||
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
|
// The facet row folds away when the screen is short.
|
||||||
useStickyTop();
|
|
||||||
const fold = useFilterFold();
|
const fold = useFilterFold();
|
||||||
const facetFilters =
|
const facetFilters = state.sources.length + (state.event ? 1 : 0);
|
||||||
state.sources.length +
|
|
||||||
(state.user ? 1 : 0) +
|
|
||||||
(state.event ? 1 : 0) +
|
|
||||||
(state.window !== DEFAULT_LOGS_STATE.window ? 1 : 0);
|
|
||||||
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
|
// 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
|
// 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.
|
// 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);
|
setLiveState(on);
|
||||||
};
|
};
|
||||||
const [open, setOpen] = useState<number | null>(null);
|
const [open, setOpen] = useState<number | null>(null);
|
||||||
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the
|
|
||||||
// search box's text then applies at once instead of after the typing debounce.
|
|
||||||
const [jumps, setJumps] = useState(0);
|
|
||||||
const q = useDebounced(state.q, 300, jumps);
|
|
||||||
useUrlMirror(logsParamUpdates(state));
|
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<LogsUrlState>) => {
|
const update = (patch: Partial<LogsUrlState>) => {
|
||||||
setOpen(null);
|
setOpen(null);
|
||||||
setState((s) => ({ ...s, ...patch }));
|
setState((s) => ({ ...s, ...patch }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = toQuery(state, q);
|
const query = toQuery(state, shared);
|
||||||
const key = qk.adminLogs({ ...query, window: state.window });
|
const key = qk.adminLogs({ ...query, window: shared.window });
|
||||||
const logs = useInfiniteQuery({
|
const logs = useInfiniteQuery({
|
||||||
queryKey: key,
|
queryKey: key,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
api.adminLogs({ ...query, since: sinceOf(state.window) }, pageParam),
|
api.adminLogs({ ...query, since: sinceOf(shared.window) }, pageParam),
|
||||||
initialPageParam: null as string | null,
|
initialPageParam: null as string | null,
|
||||||
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
|
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
|
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
|
||||||
});
|
});
|
||||||
const facets = useQuery({
|
const facets = useQuery({
|
||||||
queryKey: qk.adminLogFacets({ ...query, window: state.window }),
|
queryKey: qk.adminLogFacets({ ...query, window: shared.window }),
|
||||||
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.window) }),
|
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(shared.window) }),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
|
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;
|
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
|
const pivot = (patch: Partial<LogPivot>) => {
|
||||||
// view it was made from. The other filters are adjustments of the view on screen and replace it.
|
|
||||||
const pivot = (patch: Pick<LogsUrlState, "requestId" | "jobId">) => {
|
|
||||||
setLive(false);
|
setLive(false);
|
||||||
const next = { ...state, ...PIVOT_RESET, ...patch };
|
onPivot({ requestId: null, jobId: null, runId: null, ...patch });
|
||||||
window.history.pushState(
|
|
||||||
{ ...rememberNavPosition(), sfScroll: nextNavId() },
|
|
||||||
"",
|
|
||||||
urlWithParams(logsParamUpdates(next)),
|
|
||||||
);
|
|
||||||
notifyNav();
|
|
||||||
setJumps((n) => n + 1);
|
|
||||||
update(next);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// "Any", then what the facet counted. The value in use stays offered even at zero — under the
|
// "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 (
|
return (
|
||||||
<div className="p-4 max-w-6xl w-full mx-auto">
|
<div data-testid="admin-logs" className="overflow-clip rounded-xl border border-border bg-bg">
|
||||||
<Section card title={t("adminLogs.title")}>
|
{/* The page's own band carries the shared filters and stays in reach; this one scrolls. */}
|
||||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("adminLogs.intro")}</p>
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||||
<div
|
<FilterChipGroup
|
||||||
data-testid="admin-logs"
|
ariaLabel={t("adminLogs.levelLabel")}
|
||||||
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
|
value={state.group}
|
||||||
// to it instead of to the page.
|
onChange={(group) => update({ group })}
|
||||||
className="overflow-clip rounded-xl border border-border bg-bg"
|
options={GROUPS.map((g) => ({
|
||||||
>
|
value: g,
|
||||||
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
|
label: t(`settings.myLog.filters.${g}`),
|
||||||
under it while the lines scroll, so a filter is in reach at line 500. */}
|
count: groupCount(g),
|
||||||
<div className="sticky top-0 z-base bg-bg fade-below">
|
}))}
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
/>
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
fieldClass({ size: "sm", pill: true }),
|
|
||||||
"flex min-w-[12rem] flex-1 items-center gap-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
|
||||||
<InnerInput
|
|
||||||
type="search"
|
|
||||||
value={state.q}
|
|
||||||
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
|
|
||||||
placeholder={t("adminLogs.search")}
|
|
||||||
aria-label={t("adminLogs.search")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<FilterChipGroup
|
|
||||||
ariaLabel={t("adminLogs.levelLabel")}
|
|
||||||
value={state.group}
|
|
||||||
onChange={(group) => update({ group })}
|
|
||||||
options={GROUPS.map((g) => ({
|
|
||||||
value: g,
|
|
||||||
label: t(`settings.myLog.filters.${g}`),
|
|
||||||
count: groupCount(g),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
|
||||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
|
|
||||||
{!fold.folded && (
|
|
||||||
<>
|
|
||||||
<FilterSelect
|
|
||||||
label={t("adminLogs.source")}
|
|
||||||
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
|
|
||||||
selected={[state.sources[0] ?? ""]}
|
|
||||||
onChange={([source]) => update({ sources: source ? [source] : [] })}
|
|
||||||
testId="logs-source"
|
|
||||||
/>
|
|
||||||
<FilterSelect
|
|
||||||
label={t("adminLogs.account")}
|
|
||||||
options={facetOptions(facets.data?.users, state.user, accountLabel)}
|
|
||||||
selected={[state.user ?? ""]}
|
|
||||||
onChange={([user]) => update({ user: user || null })}
|
|
||||||
testId="logs-user"
|
|
||||||
/>
|
|
||||||
<FilterSelect
|
|
||||||
label={t("adminLogs.event")}
|
|
||||||
options={facetOptions(facets.data?.events, state.event, (v) =>
|
|
||||||
v === "none" ? t("adminLogs.noEvent") : v,
|
|
||||||
)}
|
|
||||||
selected={[state.event ?? ""]}
|
|
||||||
onChange={([event]) => update({ event: event || null })}
|
|
||||||
testId="logs-event"
|
|
||||||
/>
|
|
||||||
<FilterSelect
|
|
||||||
label={t("adminLogs.window")}
|
|
||||||
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
|
|
||||||
selected={[state.window]}
|
|
||||||
onChange={([window]) => update({ window: window as LogWindow })}
|
|
||||||
testId="logs-window"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="ml-auto flex gap-1.5">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
icon={<Radio className="h-3.5 w-3.5" />}
|
|
||||||
pressed={polling}
|
|
||||||
onClick={toggleLive}
|
|
||||||
>
|
|
||||||
{t("adminLogs.live")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
icon={<Copy className="h-3.5 w-3.5" />}
|
|
||||||
// While a filter change loads, the rows on screen are the previous filter's.
|
|
||||||
disabled={rows.length === 0 || logs.isPlaceholderData}
|
|
||||||
onClick={() => void copy()}
|
|
||||||
>
|
|
||||||
{t("adminLogs.copy")}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(state.requestId || state.jobId !== null) && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
|
|
||||||
{state.requestId && (
|
|
||||||
<PivotChip
|
|
||||||
label={t("adminLogs.pivotRequest", { id: state.requestId })}
|
|
||||||
clearLabel={t("adminLogs.clearPivot")}
|
|
||||||
onClear={() => update({ requestId: null })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{state.jobId !== null && (
|
|
||||||
<PivotChip
|
|
||||||
label={t("adminLogs.pivotJob", { id: state.jobId })}
|
|
||||||
clearLabel={t("adminLogs.clearPivot")}
|
|
||||||
onClear={() => update({ jobId: null })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{failure && (
|
|
||||||
<StateMessage
|
|
||||||
tone="error"
|
|
||||||
compact
|
|
||||||
onRetry={retryFailed}
|
|
||||||
className="border-b border-border"
|
|
||||||
>
|
|
||||||
{t(`adminLogs.${failure}`)}
|
|
||||||
</StateMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`font-mono text-2xs ${logs.isPlaceholderData ? "opacity-60" : ""}`}
|
|
||||||
data-testid="admin-logs-lines"
|
|
||||||
aria-busy={logs.isPlaceholderData}
|
|
||||||
>
|
|
||||||
{logs.isError && !logs.data ? (
|
|
||||||
<StateMessage tone="error" onRetry={() => void logs.refetch()}>
|
|
||||||
{t("common.loadError")}
|
|
||||||
</StateMessage>
|
|
||||||
) : logs.isLoading ? (
|
|
||||||
<LoadingState shape="table" />
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
|
|
||||||
{t("adminLogs.empty")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<NavGrid>
|
|
||||||
{rows.map((row, index) => (
|
|
||||||
<LogLine
|
|
||||||
navIndex={index}
|
|
||||||
key={row.id}
|
|
||||||
ts={row.ts}
|
|
||||||
level={row.level}
|
|
||||||
event={row.event ?? "—"}
|
|
||||||
msg={row.msg}
|
|
||||||
title={formatDateTimeSec(row.ts, lang)}
|
|
||||||
open={open === row.id}
|
|
||||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
|
||||||
>
|
|
||||||
<RowDetail
|
|
||||||
row={row}
|
|
||||||
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
|
|
||||||
onRequest={(requestId) => pivot({ requestId, jobId: null })}
|
|
||||||
onJob={(jobId) => pivot({ requestId: null, jobId })}
|
|
||||||
/>
|
|
||||||
</LogLine>
|
|
||||||
))}
|
|
||||||
</NavGrid>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
|
|
||||||
<span>{t("adminLogs.count", { count: rows.length })}</span>
|
|
||||||
{olderCursor !== null && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
disabled={logs.isFetchingNextPage}
|
|
||||||
onClick={loadOlder}
|
|
||||||
>
|
|
||||||
{t("adminLogs.older")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||||
|
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
|
||||||
|
{!fold.folded && (
|
||||||
|
<>
|
||||||
|
<FilterSelect
|
||||||
|
label={t("adminLogs.source")}
|
||||||
|
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
|
||||||
|
selected={[state.sources[0] ?? ""]}
|
||||||
|
onChange={([source]) => update({ sources: source ? [source] : [] })}
|
||||||
|
testId="logs-source"
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label={t("adminLogs.event")}
|
||||||
|
options={facetOptions(facets.data?.events, state.event, (v) =>
|
||||||
|
v === "none" ? t("adminLogs.noEvent") : v,
|
||||||
|
)}
|
||||||
|
selected={[state.event ?? ""]}
|
||||||
|
onChange={([event]) => update({ event: event || null })}
|
||||||
|
testId="logs-event"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto flex gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon={<Radio className="h-3.5 w-3.5" />}
|
||||||
|
pressed={polling}
|
||||||
|
onClick={toggleLive}
|
||||||
|
>
|
||||||
|
{t("adminLogs.live")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon={<Copy className="h-3.5 w-3.5" />}
|
||||||
|
// While a filter change loads, the rows on screen are the previous filter's.
|
||||||
|
disabled={rows.length === 0 || logs.isPlaceholderData}
|
||||||
|
onClick={() => void copy()}
|
||||||
|
>
|
||||||
|
{t("adminLogs.copy")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(state.requestId || state.jobId !== null || state.runId !== null) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
|
||||||
|
{state.requestId && (
|
||||||
|
<PivotChip
|
||||||
|
label={t("adminLogs.pivotRequest", { id: state.requestId })}
|
||||||
|
clearLabel={t("adminLogs.clearPivot")}
|
||||||
|
onClear={() => update({ requestId: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state.jobId !== null && (
|
||||||
|
<PivotChip
|
||||||
|
label={t("adminLogs.pivotJob", { id: state.jobId })}
|
||||||
|
clearLabel={t("adminLogs.clearPivot")}
|
||||||
|
onClear={() => update({ jobId: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{state.runId !== null && (
|
||||||
|
<PivotChip
|
||||||
|
label={t("adminLogs.pivotRun", { id: state.runId })}
|
||||||
|
clearLabel={t("adminLogs.clearPivot")}
|
||||||
|
onClear={() => update({ runId: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failure && (
|
||||||
|
<StateMessage tone="error" compact onRetry={retryFailed} className="border-b border-border">
|
||||||
|
{t(`adminLogs.${failure}`)}
|
||||||
|
</StateMessage>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`font-mono text-2xs ${logs.isPlaceholderData ? "opacity-60" : ""}`}
|
||||||
|
data-testid="admin-logs-lines"
|
||||||
|
aria-busy={logs.isPlaceholderData}
|
||||||
|
>
|
||||||
|
{logs.isError && !logs.data ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => void logs.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : logs.isLoading ? (
|
||||||
|
<LoadingState shape="table" />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
|
||||||
|
{t("adminLogs.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<NavGrid>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<LogLine
|
||||||
|
navIndex={index}
|
||||||
|
key={row.id}
|
||||||
|
ts={row.ts}
|
||||||
|
level={row.level}
|
||||||
|
event={row.event ?? "—"}
|
||||||
|
msg={row.msg}
|
||||||
|
title={formatDateTimeSec(row.ts, lang)}
|
||||||
|
open={open === row.id}
|
||||||
|
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||||
|
>
|
||||||
|
<RowDetail
|
||||||
|
row={row}
|
||||||
|
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
|
||||||
|
onRequest={(requestId) => pivot({ requestId })}
|
||||||
|
onJob={(jobId) => pivot({ jobId })}
|
||||||
|
onRun={(runId) => pivot({ runId })}
|
||||||
|
/>
|
||||||
|
</LogLine>
|
||||||
|
))}
|
||||||
|
</NavGrid>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
|
||||||
|
<span>{t("adminLogs.count", { count: rows.length })}</span>
|
||||||
|
{olderCursor !== null && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
disabled={logs.isFetchingNextPage}
|
||||||
|
onClick={loadOlder}
|
||||||
|
>
|
||||||
|
{t("adminLogs.older")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -465,11 +386,13 @@ function RowDetail({
|
|||||||
account,
|
account,
|
||||||
onRequest,
|
onRequest,
|
||||||
onJob,
|
onJob,
|
||||||
|
onRun,
|
||||||
}: {
|
}: {
|
||||||
row: ServerLogRow;
|
row: ServerLogRow;
|
||||||
account: string | null;
|
account: string | null;
|
||||||
onRequest: (id: string) => void;
|
onRequest: (id: string) => void;
|
||||||
onJob: (id: number) => void;
|
onJob: (id: number) => void;
|
||||||
|
onRun: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
@@ -516,6 +439,21 @@ function RowDetail({
|
|||||||
</dd>
|
</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{row.run_id !== null && (
|
||||||
|
<>
|
||||||
|
<dt>{t("adminLogs.fields.run")}</dt>
|
||||||
|
<dd>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className={pivotCls}
|
||||||
|
title={t("adminLogs.showRun")}
|
||||||
|
onClick={() => onRun(row.run_id!)}
|
||||||
|
>
|
||||||
|
{row.run_id}
|
||||||
|
</Button>
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{row.version && (
|
{row.version && (
|
||||||
<>
|
<>
|
||||||
<dt>{t("adminLogs.fields.version")}</dt>
|
<dt>{t("adminLogs.fields.version")}</dt>
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="p-4 max-w-5xl w-full mx-auto">
|
|
||||||
<Section card title={t("audit.title")}>
|
|
||||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
|
||||||
{q.isError && !q.data ? (
|
|
||||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
||||||
{t("common.loadError")}
|
|
||||||
</StateMessage>
|
|
||||||
) : q.isLoading ? (
|
|
||||||
<LoadingState shape="table" />
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DataTable
|
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
rowKey={(r) => String(r.id)}
|
|
||||||
persistKey={LS.auditTable}
|
|
||||||
controlsPosition="top"
|
|
||||||
controlsInBand
|
|
||||||
controlsBandClassName="px-4 max-w-5xl mx-auto"
|
|
||||||
controlsLeading={
|
|
||||||
<Button
|
|
||||||
variant="outline-danger"
|
|
||||||
size="xs"
|
|
||||||
onClick={onClear}
|
|
||||||
disabled={clear.isPending}
|
|
||||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
||||||
>
|
|
||||||
{t("audit.clear")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
emptyText={t("audit.empty")}
|
|
||||||
/>
|
|
||||||
{truncated && (
|
|
||||||
<p className="text-2xs text-muted mt-2">
|
|
||||||
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<T>({
|
||||||
|
state,
|
||||||
|
empty,
|
||||||
|
count,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
state: HistoryListState<T>;
|
||||||
|
/** Shown when the filters match nothing. */
|
||||||
|
empty: string;
|
||||||
|
/** "N runs shown", for the footer. */
|
||||||
|
count: string;
|
||||||
|
/** The rows, drawn from `state.rows`. */
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { list, rows, hasOlder } = state;
|
||||||
|
// 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 (
|
||||||
|
<div className="overflow-clip rounded-xl border border-border bg-bg">
|
||||||
|
{failure && (
|
||||||
|
<StateMessage
|
||||||
|
tone="error"
|
||||||
|
compact
|
||||||
|
onRetry={() =>
|
||||||
|
failure === "olderFailed"
|
||||||
|
? void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage))
|
||||||
|
: void list.refetch()
|
||||||
|
}
|
||||||
|
className="border-b border-border"
|
||||||
|
>
|
||||||
|
{failure === "olderFailed" ? t("activity.olderFailed") : t("activity.stale")}
|
||||||
|
</StateMessage>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={list.isPlaceholderData ? "opacity-60" : ""}
|
||||||
|
aria-busy={list.isPlaceholderData}
|
||||||
|
>
|
||||||
|
{list.isError && !list.data ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => void list.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : list.isLoading ? (
|
||||||
|
<LoadingState shape="table" />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center text-sm text-muted">{empty}</p>
|
||||||
|
) : (
|
||||||
|
<NavGrid>{children}</NavGrid>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
|
||||||
|
<span>{count}</span>
|
||||||
|
{hasOlder && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
disabled={list.isFetchingNextPage}
|
||||||
|
onClick={() => void list.fetchNextPage(nextPageOnClick(list.isFetchingNextPage))}
|
||||||
|
>
|
||||||
|
{t("activity.older")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="border-b border-border/50 last:border-b-0"
|
||||||
|
{...navItemAttrs(navIndex, navText, { inset: true })}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
selectable
|
||||||
|
layer={!open}
|
||||||
|
data-nav-open
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={onToggle}
|
||||||
|
title={title}
|
||||||
|
className={`grid w-full items-center gap-x-2 px-3 py-1.5 text-left text-xs touch-target pointer-coarse:content-center ${grid} ${
|
||||||
|
open ? "bg-accent/10" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cells}
|
||||||
|
</Pressable>
|
||||||
|
{open && <div className="bg-accent/5 px-3 py-2 text-xs text-muted">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
|
|||||||
request_id: null,
|
request_id: null,
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
job_id: null,
|
job_id: null,
|
||||||
|
run_id: null,
|
||||||
debug_session_id: SID,
|
debug_session_id: SID,
|
||||||
version: null,
|
version: null,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -151,11 +151,12 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
|||||||
expect(result.current.page).toBe("plex");
|
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(
|
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();
|
const { result } = render();
|
||||||
act(() => result.current.setPage("plex"));
|
act(() => result.current.setPage("plex"));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useLatest } from "../lib/useLatest";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { resolvePage, urlWithParams, type Page } from "../lib/urlState";
|
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 { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
||||||
import { setAccountRaw } from "../lib/storage";
|
import { setAccountRaw } from "../lib/storage";
|
||||||
@@ -92,7 +92,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
ctab: null,
|
ctab: null,
|
||||||
csort: null,
|
csort: null,
|
||||||
cf: null,
|
cf: null,
|
||||||
...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
|
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
notifyNav();
|
notifyNav();
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
role={label ? "img" : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={label ? undefined : true}
|
||||||
|
data-dot={k}
|
||||||
|
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import Scheduler from "./Scheduler";
|
import Scheduler from "./Scheduler";
|
||||||
|
import { NavigationActionsContext } from "./navigationContext";
|
||||||
import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api";
|
import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
@@ -363,3 +364,37 @@ describe("a row's run history", () => {
|
|||||||
expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull();
|
expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a row's history", () => {
|
||||||
|
// The dots show three runs; the rest is the activity page's run list, narrowed to the row's job.
|
||||||
|
it("opens the activity page's runs of that job", async () => {
|
||||||
|
const setPage = vi.fn((_page: string, then?: () => void) => then?.());
|
||||||
|
window.history.replaceState(null, "", "/?page=scheduler");
|
||||||
|
current = status();
|
||||||
|
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<NavigationActionsContext.Provider
|
||||||
|
value={{
|
||||||
|
setPage,
|
||||||
|
openChannel: () => {},
|
||||||
|
closeChannel: () => {},
|
||||||
|
enterYtSearch: () => {},
|
||||||
|
exitYtSearch: () => {},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Scheduler />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</NavigationActionsContext.Provider>,
|
||||||
|
);
|
||||||
|
await screen.findByText("scheduler.maintenance.batchLabel");
|
||||||
|
const job = current.jobs[0]!.id;
|
||||||
|
const row = screen.getByText(`scheduler.jobs.${job}`).closest<HTMLElement>(".p-3")!;
|
||||||
|
|
||||||
|
fireEvent.click(within(row).getByRole("button", { name: "scheduler.history" }));
|
||||||
|
|
||||||
|
expect(setPage).toHaveBeenCalledWith("activity", expect.any(Function));
|
||||||
|
const url = new URLSearchParams(window.location.search);
|
||||||
|
expect([url.get("page"), url.get("rjob")]).toEqual(["activity", job]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Database,
|
Database,
|
||||||
Gauge,
|
Gauge,
|
||||||
|
History,
|
||||||
Pause,
|
Pause,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
@@ -43,6 +44,9 @@ import { ContextMenu } from "./ui/ContextMenu";
|
|||||||
import { MoreButton } from "./ui/MoreButton";
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
import { jobFailureText, jobResultText } from "../lib/jobResult";
|
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;
|
const POLL_MS = 4000;
|
||||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
// 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)}`;
|
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 (
|
|
||||||
<span
|
|
||||||
role={label ? "img" : undefined}
|
|
||||||
aria-label={label}
|
|
||||||
aria-hidden={label ? undefined : true}
|
|
||||||
data-dot={k}
|
|
||||||
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusLegend() {
|
function StatusLegend() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
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 (
|
||||||
|
<Tooltip hint={t("scheduler.history")}>
|
||||||
|
<IconButton
|
||||||
|
label={t("scheduler.history")}
|
||||||
|
noTitle
|
||||||
|
onClick={() =>
|
||||||
|
setPage("activity", () =>
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
urlWithParams({ page: "activity", rjob: job }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<History />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** A user's run on one target (a channel's full history): its runs as a job's are shown, but it has
|
/** 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. */
|
* no interval and no "run now" — it needs its target, so it is started where that is chosen. */
|
||||||
function AdHocRow({ row }: { row: SchedulerAdHoc }) {
|
function AdHocRow({ row }: { row: SchedulerAdHoc }) {
|
||||||
@@ -201,6 +203,7 @@ function AdHocRow({ row }: { row: SchedulerAdHoc }) {
|
|||||||
t("scheduler.adHocStarted")
|
t("scheduler.adHocStarted")
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<HistoryButton job={row.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,6 +385,7 @@ function JobRow({
|
|||||||
<Play />
|
<Play />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<HistoryButton job={job.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,27 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { History, Pause, Play, RefreshCw } from "lucide-react";
|
import { History, RefreshCw } from "lucide-react";
|
||||||
import { api, type AdminQuotaRow } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
|
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { useWizard } from "./wizardContext";
|
import { useWizard } from "./wizardContext";
|
||||||
import { LS, useTabPersistedState } from "../lib/storage";
|
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
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 { Section } from "./ui/form";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
import { SectionLabel } from "./ui/Headings";
|
||||||
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
|
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
|
||||||
import { useMe } from "../lib/useMe";
|
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { Pressable } from "./ui/Pressable";
|
|
||||||
import { SURFACE } from "./ui/surfaces";
|
|
||||||
|
|
||||||
const RANGES = [7, 30, 90] as const;
|
// Usage & stats page: your sync status, your own API usage and the manual sync actions (they used to
|
||||||
type StatsTab = "overview" | "system";
|
// live in Settings → Sync; they moved here so Settings stays config-only). The instance-wide quota
|
||||||
|
// view is the activity page's Quota tab.
|
||||||
// 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.
|
|
||||||
export default function Stats() {
|
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 (
|
return (
|
||||||
// A tab panel only where there are tabs: everyone but an admin sees the Overview alone.
|
<div className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
|
||||||
<div {...(isAdmin ? panel.panelProps(tab) : {})} className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
|
<Overview />
|
||||||
{/* Only admins have a second (System) tab; everyone else just sees the Overview. The tabs are
|
|
||||||
the page's band (B4), so they stay in reach on the long System tab. */}
|
|
||||||
{isAdmin && (
|
|
||||||
<PageToolbar>
|
|
||||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
|
||||||
<Tabs
|
|
||||||
idBase={panel.idBase}
|
|
||||||
active={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
|
|
||||||
id,
|
|
||||||
label: t(`stats.tabs.${id}`),
|
|
||||||
}))}
|
|
||||||
className="flex flex-wrap gap-1 pb-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PageToolbar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "system" && isAdmin ? <SystemStats /> : <Overview />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -230,165 +190,3 @@ function Overview() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
|
|
||||||
function SystemStats() {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const lang = i18n.language;
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [days, setDays] = useState<number>(30);
|
|
||||||
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
|
|
||||||
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
|
||||||
const pauseResume = useMutation({
|
|
||||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
|
||||||
onError: (e, paused) =>
|
|
||||||
notifyActionError(
|
|
||||||
e,
|
|
||||||
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const data = q.data;
|
|
||||||
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
|
||||||
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
|
|
||||||
const s = status.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{s && (
|
|
||||||
<Section title={t("stats.sync.admin")}>
|
|
||||||
<Tooltip hint={t("stats.sync.adminPauseHint")}>
|
|
||||||
<Button
|
|
||||||
variant="glass"
|
|
||||||
icon={s.paused ? <Play /> : <Pause />}
|
|
||||||
loading={pauseResume.isPending}
|
|
||||||
onClick={() => pauseResume.mutate(s.paused)}
|
|
||||||
>
|
|
||||||
{s.paused
|
|
||||||
? t("stats.sync.resumeBackgroundSync")
|
|
||||||
: t("stats.sync.pauseBackgroundSync")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
|
|
||||||
<FilterChipGroup
|
|
||||||
ariaLabel={t("stats.rangeLabel")}
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
options={RANGES.map((d) => ({
|
|
||||||
value: String(d),
|
|
||||||
label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }),
|
|
||||||
}))}
|
|
||||||
value={String(days)}
|
|
||||||
onChange={(v) => setDays(Number(v))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
|
||||||
{t("stats.introBefore")}
|
|
||||||
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
|
||||||
{t("stats.introAfter")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{q.isError && !data ? (
|
|
||||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
||||||
{t("common.loadError")}
|
|
||||||
</StateMessage>
|
|
||||||
) : q.isLoading ? (
|
|
||||||
<LoadingState label={t("stats.loading")} />
|
|
||||||
) : !data ? (
|
|
||||||
<div className="text-muted py-8">{t("stats.noData")}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Daily totals (instance-wide, Pacific days) */}
|
|
||||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
|
||||||
<div className="text-xs text-muted mb-2">
|
|
||||||
{t("stats.dailyTotal", {
|
|
||||||
d: formatUnit(data.range_days, "day", lang),
|
|
||||||
units: formatNumber(grandTotal, lang),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{data.daily.length === 0 ? (
|
|
||||||
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-end gap-0.5 h-24">
|
|
||||||
{data.daily.map((d) => (
|
|
||||||
<div
|
|
||||||
key={d.day}
|
|
||||||
// The day under the pointer is lit by the B3 layer over its whole column, not by
|
|
||||||
// dimming the bar: an opacity dim reads as "disabled".
|
|
||||||
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
|
|
||||||
title={t("stats.dailyTooltip", {
|
|
||||||
day: d.day,
|
|
||||||
units: formatNumber(d.total, lang),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full bg-accent rounded-t"
|
|
||||||
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-user breakdown */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{data.rows.length === 0 ? (
|
|
||||||
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
|
|
||||||
) : (
|
|
||||||
data.rows.map((r) => (
|
|
||||||
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const lang = i18n.language;
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
|
||||||
const isSystem = row.user_id === null;
|
|
||||||
return (
|
|
||||||
<div className={`${SURFACE.card} p-3`}>
|
|
||||||
<Pressable
|
|
||||||
selectable
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
aria-expanded={open}
|
|
||||||
className="w-full flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
|
||||||
{isSystem ? t("stats.system") : row.email}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
|
||||||
{formatNumber(row.total, lang)}
|
|
||||||
</span>
|
|
||||||
</Pressable>
|
|
||||||
{/* Proportion bar */}
|
|
||||||
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent rounded-full"
|
|
||||||
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{open && actions.length > 0 && (
|
|
||||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
|
||||||
{actions.map(([action, units]) => (
|
|
||||||
<div key={action} className="flex items-center justify-between gap-2">
|
|
||||||
<span>{quotaActionLabel(t, action)}</span>
|
|
||||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ const CARDS = [
|
|||||||
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
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. */
|
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
|
||||||
const EXEMPT: Record<string, string> = {
|
const EXEMPT: Record<string, string> = {};
|
||||||
"components/AuditLog.tsx#DataTable#1":
|
|
||||||
"an admin log read in place: its rows open nothing, so stepping onto them would only move a ring",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Tag {
|
interface Tag {
|
||||||
start: number;
|
start: number;
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ const Stats = lazyWithPreload(() => import("./Stats"));
|
|||||||
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
||||||
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
||||||
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
|
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
|
||||||
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
|
const Activity = lazyWithPreload(() => import("./Activity"));
|
||||||
const AdminLogs = lazyWithPreload(() => import("./AdminLogs"));
|
|
||||||
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
|
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
|
||||||
|
|
||||||
export type PageDef = {
|
export type PageDef = {
|
||||||
@@ -60,8 +59,7 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
|
|||||||
scheduler: { Component: Scheduler },
|
scheduler: { Component: Scheduler },
|
||||||
config: { Component: ConfigPanel },
|
config: { Component: ConfigPanel },
|
||||||
users: { Component: AdminUsers, maxWidth: "48rem" },
|
users: { Component: AdminUsers, maxWidth: "48rem" },
|
||||||
audit: { Component: AuditLog },
|
activity: { Component: Activity },
|
||||||
logs: { Component: AdminLogs },
|
|
||||||
settings: { Component: SettingsPanel },
|
settings: { Component: SettingsPanel },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
"levelLabel": "Filter by level",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"account": "Account",
|
|
||||||
"event": "Event",
|
"event": "Event",
|
||||||
"window": "Period",
|
|
||||||
"windows": {
|
|
||||||
"1h": "1 hour",
|
|
||||||
"24h": "24 hours",
|
|
||||||
"7d": "7 days"
|
|
||||||
},
|
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
"noAccount": "No account",
|
"noAccount": "No account",
|
||||||
"noEvent": "No code",
|
"noEvent": "No code",
|
||||||
@@ -38,6 +28,9 @@
|
|||||||
"account": "Account",
|
"account": "Account",
|
||||||
"request": "Request",
|
"request": "Request",
|
||||||
"job": "Job",
|
"job": "Job",
|
||||||
"version": "Version"
|
"version": "Version",
|
||||||
}
|
"run": "Run"
|
||||||
|
},
|
||||||
|
"pivotRun": "run {{id}}",
|
||||||
|
"showRun": "Only this run's lines"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"empty": "No audit entries yet.",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"clear": "Clear audit log",
|
"clear": "Clear audit log",
|
||||||
@@ -9,7 +7,6 @@
|
|||||||
"cleared_one": "Cleared {{count}} audit entry",
|
"cleared_one": "Cleared {{count}} audit entry",
|
||||||
"cleared_other": "Cleared {{count}} audit entries",
|
"cleared_other": "Cleared {{count}} audit entries",
|
||||||
"clearFailed": "Couldn't clear the audit log",
|
"clearFailed": "Couldn't clear the audit log",
|
||||||
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
|
|
||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"default": "default",
|
"default": "default",
|
||||||
@@ -18,7 +15,6 @@
|
|||||||
"cols": {
|
"cols": {
|
||||||
"when": "When",
|
"when": "When",
|
||||||
"actor": "Who",
|
"actor": "Who",
|
||||||
"action": "Action",
|
|
||||||
"details": "Details"
|
"details": "Details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,8 @@
|
|||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
"addAccount": "Add another account",
|
"addAccount": "Add another account",
|
||||||
"switchTo": "Switch to {{name}}",
|
"switchTo": "Switch to {{name}}",
|
||||||
"audit": "Audit log",
|
"googleAddress": "Google account: {{email}}",
|
||||||
"logs": "Server log",
|
"activity": "Activity"
|
||||||
"googleAddress": "Google account: {{email}}"
|
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "yours",
|
"yours": "yours",
|
||||||
|
|||||||
@@ -18,9 +18,8 @@
|
|||||||
"scheduler": "Scheduler",
|
"scheduler": "Scheduler",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"audit": "Audit",
|
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"logs": "Logs",
|
"settings": "Settings",
|
||||||
"settings": "Settings"
|
"activity": "Activity"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,5 +211,6 @@
|
|||||||
},
|
},
|
||||||
"interrupted": "interrupted: the server restarted during the run",
|
"interrupted": "interrupted: the server restarted during the run",
|
||||||
"adHocTitle": "Ad-hoc runs",
|
"adHocTitle": "Ad-hoc runs",
|
||||||
"adHocStarted": "started by a user"
|
"adHocStarted": "started by a user",
|
||||||
|
"history": "This job's runs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. ",
|
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
|
||||||
"introSystem": "System",
|
"introSystem": "System",
|
||||||
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
|
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"noData": "No data.",
|
|
||||||
"dailyTotal": "Daily total ({{d}} · {{units}} units)",
|
|
||||||
"noUsageInRange": "No usage in this range.",
|
"noUsageInRange": "No usage in this range.",
|
||||||
"dailyTooltip": "{{day}}: {{units}} units",
|
"dailyTooltip": "{{day}}: {{units}} units",
|
||||||
"system": "System (background)",
|
"system": "System (background)",
|
||||||
@@ -49,10 +40,6 @@
|
|||||||
"syncFailed": "Couldn't sync subscriptions",
|
"syncFailed": "Couldn't sync subscriptions",
|
||||||
"fullHistoryRequested_one": "Full history requested for {{count}} channel",
|
"fullHistoryRequested_one": "Full history requested for {{count}} channel",
|
||||||
"fullHistoryRequested_other": "Full history requested for {{count}} channels",
|
"fullHistoryRequested_other": "Full history requested for {{count}} channels",
|
||||||
"fullHistoryFailed": "Couldn't request full history",
|
"fullHistoryFailed": "Couldn't request full history"
|
||||||
"admin": "Admin",
|
|
||||||
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
|
|
||||||
"resumeBackgroundSync": "Resume background sync",
|
|
||||||
"pauseBackgroundSync": "Pause background sync"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
"levelLabel": "Szűrés szintre",
|
||||||
"source": "Forrás",
|
"source": "Forrás",
|
||||||
"account": "Fiók",
|
|
||||||
"event": "Esemény",
|
"event": "Esemény",
|
||||||
"window": "Időszak",
|
|
||||||
"windows": {
|
|
||||||
"1h": "1 óra",
|
|
||||||
"24h": "24 óra",
|
|
||||||
"7d": "7 nap"
|
|
||||||
},
|
|
||||||
"any": "Bármi",
|
"any": "Bármi",
|
||||||
"noAccount": "Fiók nélkül",
|
"noAccount": "Fiók nélkül",
|
||||||
"noEvent": "Kód nélkül",
|
"noEvent": "Kód nélkül",
|
||||||
@@ -38,6 +28,9 @@
|
|||||||
"account": "Fiók",
|
"account": "Fiók",
|
||||||
"request": "Kérés",
|
"request": "Kérés",
|
||||||
"job": "Feladat",
|
"job": "Feladat",
|
||||||
"version": "Verzió"
|
"version": "Verzió",
|
||||||
}
|
"run": "Futás"
|
||||||
|
},
|
||||||
|
"pivotRun": "futás: {{id}}",
|
||||||
|
"showRun": "Csak ennek a futásnak a sorai"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"empty": "Még nincs naplóbejegyzés.",
|
||||||
"system": "Rendszer",
|
"system": "Rendszer",
|
||||||
"clear": "Eseménynapló ürítése",
|
"clear": "Eseménynapló ürítése",
|
||||||
@@ -9,7 +7,6 @@
|
|||||||
"cleared_one": "{{count}} naplóbejegyzés törölve",
|
"cleared_one": "{{count}} naplóbejegyzés törölve",
|
||||||
"cleared_other": "{{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",
|
"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",
|
"on": "Be",
|
||||||
"off": "Ki",
|
"off": "Ki",
|
||||||
"default": "alapérték",
|
"default": "alapérték",
|
||||||
@@ -18,7 +15,6 @@
|
|||||||
"cols": {
|
"cols": {
|
||||||
"when": "Mikor",
|
"when": "Mikor",
|
||||||
"actor": "Ki",
|
"actor": "Ki",
|
||||||
"action": "Művelet",
|
|
||||||
"details": "Részletek"
|
"details": "Részletek"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,8 @@
|
|||||||
"signOut": "Kijelentkezés",
|
"signOut": "Kijelentkezés",
|
||||||
"addAccount": "Másik fiók hozzáadása",
|
"addAccount": "Másik fiók hozzáadása",
|
||||||
"switchTo": "Váltás erre: {{name}}",
|
"switchTo": "Váltás erre: {{name}}",
|
||||||
"audit": "Eseménynapló",
|
"googleAddress": "Google-fiók: {{email}}",
|
||||||
"logs": "Szervernapló",
|
"activity": "Napló"
|
||||||
"googleAddress": "Google-fiók: {{email}}"
|
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "tiéd",
|
"yours": "tiéd",
|
||||||
|
|||||||
@@ -18,9 +18,8 @@
|
|||||||
"scheduler": "Ütemező",
|
"scheduler": "Ütemező",
|
||||||
"config": "Konfig",
|
"config": "Konfig",
|
||||||
"users": "Felhasználók",
|
"users": "Felhasználók",
|
||||||
"audit": "Eseménynapló",
|
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"logs": "Napló",
|
"settings": "Beállítások",
|
||||||
"settings": "Beállítások"
|
"activity": "Napló"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,5 +211,6 @@
|
|||||||
},
|
},
|
||||||
"interrupted": "megszakadt: a futás közben újraindult a szerver",
|
"interrupted": "megszakadt: a futás közben újraindult a szerver",
|
||||||
"adHocTitle": "Alkalmi futások",
|
"adHocTitle": "Alkalmi futások",
|
||||||
"adHocStarted": "felhasználó indítja"
|
"adHocStarted": "felhasználó indítja",
|
||||||
|
"history": "A feladat futásai"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ",
|
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
|
||||||
"introSystem": "Rendszer",
|
"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.",
|
"introAfter": " a megosztott háttérmunka (ütemezett behúzás, dúsítás, újraszinkronizálás), amely nem köthető egy személyhez.",
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"noData": "Nincs adat.",
|
|
||||||
"dailyTotal": "Napi összesen ({{d}} · {{units}} egység)",
|
|
||||||
"noUsageInRange": "Nincs használat ebben az időszakban.",
|
"noUsageInRange": "Nincs használat ebben az időszakban.",
|
||||||
"dailyTooltip": "{{day}}: {{units}} egység",
|
"dailyTooltip": "{{day}}: {{units}} egység",
|
||||||
"system": "Rendszer (háttér)",
|
"system": "Rendszer (háttér)",
|
||||||
@@ -49,10 +40,6 @@
|
|||||||
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
|
"syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat",
|
||||||
"fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához",
|
"fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
"fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához",
|
"fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
|
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string | null>) =>
|
||||||
|
new URLSearchParams(Object.entries(updates).filter((e): e is [string, string] => e[1] !== null));
|
||||||
|
|
||||||
|
describe("activityUrlState", () => {
|
||||||
|
it("round-trips the tab and every filter", () => {
|
||||||
|
const state: ActivityUrlState = {
|
||||||
|
tab: "admin",
|
||||||
|
window: "30d",
|
||||||
|
user: "none",
|
||||||
|
q: "100% done",
|
||||||
|
job: "channel_backfill",
|
||||||
|
status: "interrupted",
|
||||||
|
action: "config_set",
|
||||||
|
};
|
||||||
|
expect(activityParamsToState(params(activityParamUpdates(state)))).toEqual(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no key for a filter at its default, and opens on the runs of the last 7 days", () => {
|
||||||
|
expect(
|
||||||
|
Object.values(activityParamUpdates(DEFAULT_ACTIVITY_STATE)).every((v) => v === null),
|
||||||
|
).toBe(true);
|
||||||
|
expect([DEFAULT_ACTIVITY_STATE.tab, DEFAULT_ACTIVITY_STATE.window]).toEqual(["runs", "7d"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a value the API would refuse instead of sending it", () => {
|
||||||
|
const state = activityParamsToState(
|
||||||
|
new URLSearchParams(
|
||||||
|
"at=everything&aw=1y&au=me&rjob=Not-A-Job&rst=finished&aact=DROP%20TABLE",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(state).toEqual(DEFAULT_ACTIVITY_STATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ActivityWindow, number> = {
|
||||||
|
"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<Record<string, ActivityTab>> = { 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 = <T extends string>(value: string | null, list: readonly T[]): T | null =>
|
||||||
|
list.includes(value as T) ? (value as T) : null;
|
||||||
|
|
||||||
|
export function activityParamsToState(params: URLSearchParams): ActivityUrlState {
|
||||||
|
return {
|
||||||
|
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<string, string | null> {
|
||||||
|
const d = DEFAULT_ACTIVITY_STATE;
|
||||||
|
return {
|
||||||
|
at: state.tab === d.tab ? null : state.tab,
|
||||||
|
aw: state.window === d.window ? null : state.window,
|
||||||
|
au: state.user,
|
||||||
|
aq: state.q.trim() ? state.q : null,
|
||||||
|
rjob: state.job,
|
||||||
|
rst: state.status,
|
||||||
|
aact: state.action,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the window starts, as the API takes it. */
|
||||||
|
export function sinceOf(window: ActivityWindow, now = Date.now()): string {
|
||||||
|
return new Date(now - WINDOW_HOURS[window] * 3_600_000).toISOString();
|
||||||
|
}
|
||||||
@@ -95,18 +95,69 @@ export interface AdminUserAccess extends AdminUserRow {
|
|||||||
export interface AuditRow {
|
export interface AuditRow {
|
||||||
id: number;
|
id: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
actor: string | null; // email of the acting admin; null = System (scheduler/background)
|
/** The acting admin (address in the page's `accounts`); null = the background. */
|
||||||
|
actor_id: number | null;
|
||||||
action: string; // canonical key, localized on the client via auditActions.<action>
|
action: string; // canonical key, localized on the client via auditActions.<action>
|
||||||
target_type: string | null;
|
target_type: string | null;
|
||||||
target_id: string | null;
|
target_id: string | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
before: Record<string, unknown> | null;
|
before: Record<string, unknown> | null;
|
||||||
after: Record<string, unknown> | null;
|
after: Record<string, unknown> | null;
|
||||||
|
/** The request the change was made in — its log lines are one pivot away. Null on older rows. */
|
||||||
|
request_id: string | null;
|
||||||
}
|
}
|
||||||
export interface AuditListResult {
|
|
||||||
items: AuditRow[];
|
/** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */
|
||||||
total: number; // total rows in the log (may exceed `items` if capped)
|
export interface HistoryPage<T> {
|
||||||
returned: number;
|
items: T[];
|
||||||
|
/** The accounts this page names, by id; an erased one is absent. */
|
||||||
|
accounts: Record<string, string>;
|
||||||
|
next_cursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The filters every activity tab shares, as the APIs take them. */
|
||||||
|
export interface SharedQuery {
|
||||||
|
/** An account id, or `none` for what no account did. */
|
||||||
|
user?: string;
|
||||||
|
q?: string;
|
||||||
|
since?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQuery extends SharedQuery {
|
||||||
|
action?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One background run (`job_runs`), as the activity page lists it. */
|
||||||
|
export interface RunRow {
|
||||||
|
id: number;
|
||||||
|
job: string;
|
||||||
|
trigger: RunBrief["trigger"];
|
||||||
|
actor_id: number | null;
|
||||||
|
/** What a user's ad-hoc run worked on (a channel id), and that channel's title. */
|
||||||
|
target: string | null;
|
||||||
|
target_title: string | null;
|
||||||
|
status: RunBrief["status"];
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
result: JobResult | null;
|
||||||
|
error: string | null;
|
||||||
|
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
|
/** 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;
|
request_id: string | null;
|
||||||
user_id: number | null;
|
user_id: number | null;
|
||||||
job_id: number | null;
|
job_id: number | null;
|
||||||
|
run_id: number | null;
|
||||||
debug_session_id: string | null;
|
debug_session_id: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
}
|
}
|
||||||
@@ -136,6 +188,7 @@ export interface LogQuery {
|
|||||||
event?: string;
|
event?: string;
|
||||||
request_id?: string;
|
request_id?: string;
|
||||||
job_id?: number;
|
job_id?: number;
|
||||||
|
run_id?: number;
|
||||||
q?: string;
|
q?: string;
|
||||||
since?: string;
|
since?: string;
|
||||||
}
|
}
|
||||||
@@ -171,6 +224,7 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
|
|||||||
event: query.event,
|
event: query.event,
|
||||||
request_id: query.request_id,
|
request_id: query.request_id,
|
||||||
job_id: query.job_id,
|
job_id: query.job_id,
|
||||||
|
run_id: query.run_id,
|
||||||
q: query.q,
|
q: query.q,
|
||||||
since: query.since,
|
since: query.since,
|
||||||
...extra,
|
...extra,
|
||||||
@@ -182,6 +236,16 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
|
|||||||
return s ? `?${s}` : "";
|
return s ? `?${s}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `?a=1&b=x` from the set values (an empty string or null is left out), or "" when none is. */
|
||||||
|
function queryString(values: object): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
|
||||||
|
}
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? `?${s}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
export type JobResult = { [key: string]: number | string | JobResult };
|
export type JobResult = { [key: string]: number | string | JobResult };
|
||||||
|
|
||||||
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
|
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
|
||||||
@@ -277,7 +341,14 @@ export const adminApi = {
|
|||||||
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
|
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
|
||||||
req("/api/admin/config/test-issues", { method: "POST" }),
|
req("/api/admin/config/test-issues", { method: "POST" }),
|
||||||
// --- users & roles ---
|
// --- users & roles ---
|
||||||
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
adminAudit: (query: AuditQuery, before: string | null): Promise<HistoryPage<AuditRow>> =>
|
||||||
|
req(`/api/admin/audit${queryString({ ...query, before })}`),
|
||||||
|
adminAuditFacets: (query: AuditQuery): Promise<{ actions: LogFacet[] }> =>
|
||||||
|
req(`/api/admin/audit/facets${queryString(query)}`),
|
||||||
|
adminRuns: (query: RunQuery, before: string | null): Promise<HistoryPage<RunRow>> =>
|
||||||
|
req(`/api/admin/runs${queryString({ ...query, before })}`),
|
||||||
|
adminRunFacets: (query: RunQuery): Promise<RunFacets> =>
|
||||||
|
req(`/api/admin/runs/facets${queryString(query)}`),
|
||||||
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||||
// --- diagnostics console ---
|
// --- diagnostics console ---
|
||||||
adminLogs: (query: LogQuery, before: string | null): Promise<LogPage> =>
|
adminLogs: (query: LogQuery, before: string | null): Promise<LogPage> =>
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ export interface AdminQuotaRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminQuota {
|
export interface AdminQuota {
|
||||||
range_days: number;
|
|
||||||
rows: AdminQuotaRow[];
|
rows: AdminQuotaRow[];
|
||||||
daily: { day: string; total: number }[];
|
daily: { day: string; total: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const quotaApi = {
|
export const quotaApi = {
|
||||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
/** Spend from `since` to now, optionally for one account (`none` = the background). */
|
||||||
|
adminQuota: (since: string, user: string | null): Promise<AdminQuota> =>
|
||||||
|
req(`/api/quota/admin?${new URLSearchParams({ since, ...(user ? { user } : {}) }).toString()}`),
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-10
@@ -4,37 +4,33 @@
|
|||||||
// min/run", "Background sync paused") reads in the reader's language, rebuilt from the row's
|
// 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.
|
// 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 { afterEach, describe, expect, it } from "vitest";
|
||||||
import { cleanup, render } from "@testing-library/react";
|
|
||||||
import i18n, { setLanguage } from "../i18n";
|
import i18n, { setLanguage } from "../i18n";
|
||||||
import type { AuditRow } from "../lib/api";
|
import type { AuditRow } from "./api";
|
||||||
import { auditColumns } from "./auditColumns";
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
|
||||||
setLanguage("en");
|
setLanguage("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = (over: Partial<AuditRow>): AuditRow => ({
|
const row = (over: Partial<AuditRow>): AuditRow => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
created_at: null,
|
created_at: null,
|
||||||
actor: null,
|
actor_id: null,
|
||||||
action: "sync_pause",
|
action: "sync_pause",
|
||||||
target_type: null,
|
target_type: null,
|
||||||
target_id: null,
|
target_id: null,
|
||||||
summary: null,
|
summary: null,
|
||||||
before: null,
|
before: null,
|
||||||
after: null,
|
after: null,
|
||||||
|
request_id: null,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
function details(r: AuditRow): string {
|
function details(r: AuditRow): string {
|
||||||
setLanguage("hu");
|
setLanguage("hu");
|
||||||
const t = i18n.getFixedT("hu");
|
const t = i18n.getFixedT("hu");
|
||||||
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
const { head, sub } = auditText(t, r, "hu");
|
||||||
const { container } = render(<>{col.render!(r)}</>);
|
return [head, sub].filter(Boolean).join(" | ");
|
||||||
return [...container.querySelectorAll(".wrap-break-word")].map((e) => e.textContent).join(" | ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("an audit row's details", () => {
|
describe("an audit row's details", () => {
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { AuditRow } from "../lib/api";
|
import type { AuditRow, JobResult } from "./api";
|
||||||
import { formatNumber } from "../lib/format";
|
import { formatNumber } from "./format";
|
||||||
import type { JobResult } from "../lib/api";
|
import { jobResultText } from "./jobResult";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(v: unknown): string {
|
function fmt(v: unknown): string {
|
||||||
return v == null || v === "" ? "—" : String(v);
|
return v == null || v === "" ? "—" : String(v);
|
||||||
@@ -90,64 +82,18 @@ function changeText(row: AuditRow): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
|
/** An audit row in the reader's language: `head` names what changed (a config field, a job; else
|
||||||
* the data (built by the page) so the Action column's select filter only offers real values. */
|
* the server's summary), `sub` the change itself (before → after, a count). Either may be null. */
|
||||||
export function auditColumns(
|
export function auditText(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
|
row: AuditRow,
|
||||||
lang: string,
|
lang: string,
|
||||||
actionOptions: { value: string; label: string }[],
|
): { head: string | null; sub: string | null } {
|
||||||
): Column<AuditRow>[] {
|
const local = localDetails(t, row, lang);
|
||||||
return [
|
return local ?? { head: row.summary, sub: changeText(row) };
|
||||||
{
|
}
|
||||||
key: "when",
|
|
||||||
header: t("audit.cols.when"),
|
export function auditActionLabel(t: TFunction, action: string): string {
|
||||||
nowrap: true,
|
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||||
sortable: true,
|
return t(`auditActions.${action}`, action);
|
||||||
sortValue: (r) => r.created_at ?? "",
|
|
||||||
cardPrimary: true,
|
|
||||||
render: (r) => (
|
|
||||||
<RelativeTime at={r.created_at} seconds empty="—" className="text-muted tabular-nums" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actor",
|
|
||||||
header: t("audit.cols.actor"),
|
|
||||||
sortable: true,
|
|
||||||
sortValue: (r) => r.actor ?? "",
|
|
||||||
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
|
|
||||||
render: (r) =>
|
|
||||||
r.actor ? (
|
|
||||||
<span className="truncate">{r.actor}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted italic">{t("audit.system")}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "action",
|
|
||||||
header: t("audit.cols.action"),
|
|
||||||
nowrap: true,
|
|
||||||
sortable: true,
|
|
||||||
sortValue: (r) => actionLabel(t, r.action),
|
|
||||||
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
|
|
||||||
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "details",
|
|
||||||
header: t("audit.cols.details"),
|
|
||||||
render: (r) => {
|
|
||||||
const local = localDetails(t, r, lang);
|
|
||||||
const summary = local ? local.head : r.summary;
|
|
||||||
const change = local ? local.sub : changeText(r);
|
|
||||||
return (
|
|
||||||
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
|
||||||
// sizes to its longest row, so one long summary/change stretched the whole table past the
|
|
||||||
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
|
||||||
<div className="min-w-0">
|
|
||||||
{summary && <div className="wrap-break-word">{summary}</div>}
|
|
||||||
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
|
|||||||
request_id: null,
|
request_id: null,
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
job_id: null,
|
job_id: null,
|
||||||
|
run_id: null,
|
||||||
debug_session_id: "0123456789abcdef",
|
debug_session_id: "0123456789abcdef",
|
||||||
version: null,
|
version: null,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -17,12 +17,10 @@ describe("logsUrlState", () => {
|
|||||||
const state: LogsUrlState = {
|
const state: LogsUrlState = {
|
||||||
group: "error",
|
group: "error",
|
||||||
sources: ["worker"],
|
sources: ["worker"],
|
||||||
user: "none",
|
|
||||||
event: "download.job.failed",
|
event: "download.job.failed",
|
||||||
requestId: "req-abcdef12",
|
requestId: "req-abcdef12",
|
||||||
jobId: 42,
|
jobId: 42,
|
||||||
q: "100% done",
|
runId: 7,
|
||||||
window: "7d",
|
|
||||||
};
|
};
|
||||||
expect(logsParamsToState(params(logsParamUpdates(state)))).toEqual(state);
|
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", () => {
|
it("drops a value the API would refuse instead of sending it", () => {
|
||||||
const state = logsParamsToState(
|
const state = logsParamsToState(
|
||||||
new URLSearchParams(
|
new URLSearchParams("lgrp=loud&lsrc=api,WORKER,x y&lev=Not.A.Code&lreq=short&ljob=0&lrun=-1"),
|
||||||
"lgrp=loud&lsrc=api,WORKER,x y&lu=me&lev=Not.A.Code&lreq=short&ljob=0&lwin=1y",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] });
|
expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,67 +1,56 @@
|
|||||||
// The admin diagnostics console's filters in the address bar (`?page=logs&lgrp=error&lreq=…`), so a
|
// The server-log tab's own filters in the address bar (`?page=activity&at=logs&lgrp=error&lreq=…`),
|
||||||
// filtered view — a request's lines, say — is a link. Keys are namespaced (`l…`) like the Plex and
|
// so a filtered view — a request's lines, say — is a link. The filters every activity tab shares
|
||||||
// Channels ones; setPage clears them (LOGS_KEYS) and the console re-mirrors them while it is open.
|
// (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";
|
import type { LevelGroup } from "./log";
|
||||||
|
|
||||||
export type LogWindow = "1h" | "24h" | "7d";
|
|
||||||
|
|
||||||
export interface LogsUrlState {
|
export interface LogsUrlState {
|
||||||
group: LevelGroup;
|
group: LevelGroup;
|
||||||
sources: string[];
|
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. */
|
/** An event code, or `none` for the rows without one. */
|
||||||
event: string | null;
|
event: string | null;
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
jobId: number | null;
|
jobId: number | null;
|
||||||
q: string;
|
/** A background run's lines (`job_runs.id`). */
|
||||||
window: LogWindow;
|
runId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_LOGS_STATE: LogsUrlState = {
|
export const DEFAULT_LOGS_STATE: LogsUrlState = {
|
||||||
group: "all",
|
group: "all",
|
||||||
sources: [],
|
sources: [],
|
||||||
user: null,
|
|
||||||
event: null,
|
event: null,
|
||||||
requestId: null,
|
requestId: null,
|
||||||
jobId: null,
|
jobId: null,
|
||||||
q: "",
|
runId: null,
|
||||||
window: "24h",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LOGS_KEYS = ["lgrp", "lsrc", "lu", "lev", "lreq", "ljob", "lq", "lwin"] as const;
|
export const LOGS_KEYS = ["lgrp", "lsrc", "lev", "lreq", "ljob", "lrun"] as const;
|
||||||
|
|
||||||
export const WINDOW_HOURS: Record<LogWindow, number> = { "1h": 1, "24h": 24, "7d": 24 * 7 };
|
|
||||||
|
|
||||||
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
|
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
|
// 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.
|
// 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 EVENT_RE = /^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$/;
|
||||||
const REQUEST_RE = /^[A-Za-z0-9._-]{8,64}$/;
|
const REQUEST_RE = /^[A-Za-z0-9._-]{8,64}$/;
|
||||||
const SOURCE_RE = /^[a-z]{1,16}$/;
|
const SOURCE_RE = /^[a-z]{1,16}$/;
|
||||||
const SOURCES_MAX = 8;
|
const SOURCES_MAX = 8;
|
||||||
const Q_MAX = 200;
|
|
||||||
|
|
||||||
const pick = (value: string | null, re: RegExp, max = 64) =>
|
const pick = (value: string | null, re: RegExp, max = 64) =>
|
||||||
value && value.length <= max && re.test(value) ? value : null;
|
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 {
|
export function logsParamsToState(params: URLSearchParams): LogsUrlState {
|
||||||
const group = params.get("lgrp");
|
const group = params.get("lgrp");
|
||||||
const window = params.get("lwin");
|
|
||||||
const job = params.get("ljob");
|
|
||||||
return {
|
return {
|
||||||
group: GROUPS.includes(group as LevelGroup) ? (group as LevelGroup) : DEFAULT_LOGS_STATE.group,
|
group: GROUPS.includes(group as LevelGroup) ? (group as LevelGroup) : DEFAULT_LOGS_STATE.group,
|
||||||
sources: (params.get("lsrc") ?? "")
|
sources: (params.get("lsrc") ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.filter((s) => SOURCE_RE.test(s))
|
.filter((s) => SOURCE_RE.test(s))
|
||||||
.slice(0, SOURCES_MAX),
|
.slice(0, SOURCES_MAX),
|
||||||
user: pick(params.get("lu"), USER_RE),
|
|
||||||
event: pick(params.get("lev"), EVENT_RE),
|
event: pick(params.get("lev"), EVENT_RE),
|
||||||
requestId: pick(params.get("lreq"), REQUEST_RE),
|
requestId: pick(params.get("lreq"), REQUEST_RE),
|
||||||
jobId: job && /^\d{1,10}$/.test(job) && Number(job) > 0 ? Number(job) : null,
|
jobId: positive(params.get("ljob")),
|
||||||
q: (params.get("lq") ?? "").slice(0, Q_MAX),
|
runId: positive(params.get("lrun")),
|
||||||
window: window && window in WINDOW_HOURS ? (window as LogWindow) : DEFAULT_LOGS_STATE.window,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +59,9 @@ export function logsParamUpdates(state: LogsUrlState): Record<string, string | n
|
|||||||
return {
|
return {
|
||||||
lgrp: state.group === DEFAULT_LOGS_STATE.group ? null : state.group,
|
lgrp: state.group === DEFAULT_LOGS_STATE.group ? null : state.group,
|
||||||
lsrc: state.sources.length ? state.sources.join(",") : null,
|
lsrc: state.sources.length ? state.sources.join(",") : null,
|
||||||
lu: state.user,
|
|
||||||
lev: state.event,
|
lev: state.event,
|
||||||
lreq: state.requestId,
|
lreq: state.requestId,
|
||||||
ljob: state.jobId === null ? null : String(state.jobId),
|
ljob: state.jobId === null ? null : String(state.jobId),
|
||||||
lq: state.q.trim() ? state.q : null,
|
lrun: state.runId === null ? null : String(state.runId),
|
||||||
lwin: state.window === DEFAULT_LOGS_STATE.window ? null : state.window,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
FileSearch,
|
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
@@ -129,23 +128,14 @@ const MODULES: readonly ModuleDef[] = [
|
|||||||
gate: isAdmin,
|
gate: isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "audit",
|
id: "activity",
|
||||||
labelKey: "header.account.audit",
|
labelKey: "header.account.activity",
|
||||||
railKey: "nav.rail.audit",
|
railKey: "nav.rail.activity",
|
||||||
titleKey: "audit.title",
|
titleKey: "activity.title",
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
system: true,
|
system: true,
|
||||||
gate: isAdmin,
|
gate: isAdmin,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "logs",
|
|
||||||
labelKey: "header.account.logs",
|
|
||||||
railKey: "nav.rail.logs",
|
|
||||||
titleKey: "adminLogs.title",
|
|
||||||
icon: FileSearch,
|
|
||||||
system: true,
|
|
||||||
gate: isAdmin,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
|
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ const EXPECTED: Record<Page, string> = {
|
|||||||
scheduler: "header.scheduler",
|
scheduler: "header.scheduler",
|
||||||
config: "header.configuration",
|
config: "header.configuration",
|
||||||
users: "header.users",
|
users: "header.users",
|
||||||
audit: "audit.title",
|
activity: "activity.title",
|
||||||
logs: "adminLogs.title",
|
|
||||||
settings: "settings.title",
|
settings: "settings.title",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("qk — single-id and scalar keys", () => {
|
|||||||
expect(qk.downloadLinks(5)).toEqual(["download-links", 5]);
|
expect(qk.downloadLinks(5)).toEqual(["download-links", 5]);
|
||||||
expect(qk.dlQuota(3)).toEqual(["dl-quota", 3]);
|
expect(qk.dlQuota(3)).toEqual(["dl-quota", 3]);
|
||||||
expect(qk.storyboard(5)).toEqual(["storyboard", 5]);
|
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"]]);
|
expect(qk.plexPeopleCards("both", ["Actor"])).toEqual(["plex-people-cards", "both", ["Actor"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ export const qk = {
|
|||||||
adminInvites: () => ["admin-invites"] as const,
|
adminInvites: () => ["admin-invites"] as const,
|
||||||
adminPendingAccess: () => ["admin-pending-access"] as const,
|
adminPendingAccess: () => ["admin-pending-access"] as const,
|
||||||
adminConfig: () => ["admin-config"] as const,
|
adminConfig: () => ["admin-config"] as const,
|
||||||
adminAudit: () => ["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
|
// The console's filters are part of the key; `since` is not (it moves with the clock) — the
|
||||||
// window it came from is.
|
// window it came from is.
|
||||||
adminLogs: (filters: object) => ["admin-logs", filters] as const,
|
adminLogs: (filters: object) => ["admin-logs", filters] as const,
|
||||||
@@ -105,7 +108,7 @@ export const qk = {
|
|||||||
debugSessionPreview: (id: string, text: string) =>
|
debugSessionPreview: (id: string, text: string) =>
|
||||||
["debug-sessions", id, "preview", text] as const,
|
["debug-sessions", id, "preview", text] as const,
|
||||||
adminDownloads: () => ["admin-downloads"] as const,
|
adminDownloads: () => ["admin-downloads"] as const,
|
||||||
adminQuota: (days: number) => ["admin-quota", days] as const,
|
adminQuota: (filters: object) => ["admin-quota", filters] as const,
|
||||||
|
|
||||||
// --- plex ---
|
// --- plex ---
|
||||||
// The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys
|
// The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys
|
||||||
|
|||||||
@@ -64,12 +64,10 @@ export const LS = {
|
|||||||
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
||||||
channelNames: "siftlode.channelNames",
|
channelNames: "siftlode.channelNames",
|
||||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
|
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab
|
||||||
// statsTab below.
|
// below.
|
||||||
settingsTab: "siftlode.settingsTab",
|
settingsTab: "siftlode.settingsTab",
|
||||||
configTab: "siftlode.configTab",
|
configTab: "siftlode.configTab",
|
||||||
auditTable: "siftlode.auditTable",
|
|
||||||
statsTab: "siftlode.statsTab",
|
|
||||||
adminUsersTab: "siftlode.adminUsersTab",
|
adminUsersTab: "siftlode.adminUsersTab",
|
||||||
filterCollapsed: "siftlode.filterCollapsed",
|
filterCollapsed: "siftlode.filterCollapsed",
|
||||||
playlist: "siftlode.playlist",
|
playlist: "siftlode.playlist",
|
||||||
|
|||||||
@@ -128,8 +128,7 @@ const PAGES = [
|
|||||||
"scheduler",
|
"scheduler",
|
||||||
"config",
|
"config",
|
||||||
"users",
|
"users",
|
||||||
"audit",
|
"activity",
|
||||||
"logs",
|
|
||||||
"notifications",
|
"notifications",
|
||||||
"files",
|
"files",
|
||||||
"plex",
|
"plex",
|
||||||
@@ -148,6 +147,10 @@ function isPage(p: string | null | undefined): p is Page {
|
|||||||
const RETIRED: Readonly<Record<string, Page>> = {
|
const RETIRED: Readonly<Record<string, Page>> = {
|
||||||
// The download queue became the tray; what it downloaded lives in Files.
|
// The download queue became the tray; what it downloaded lives in Files.
|
||||||
downloads: "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. */
|
/** A page id as stored or linked: a live page, a retired page's successor, else null. */
|
||||||
|
|||||||
@@ -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<T>(
|
||||||
|
queryKey: readonly unknown[],
|
||||||
|
fetchPage: (before: string | null) => Promise<HistoryPage<T>>,
|
||||||
|
) {
|
||||||
|
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<T> = ReturnType<typeof useHistoryList<T>>;
|
||||||
Reference in New Issue
Block a user