release: 0.90.0 (feature/e22_6-diagnostics-console)
gate / frontend (push) Failing after 4m16s
gate / backend (push) Successful in 5m19s

This commit is contained in:
2026-09-19 03:57:44 +02:00
36 changed files with 1975 additions and 103 deletions
+21
View File
@@ -7,6 +7,27 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.90.0 — 2026-09-19
The server's log gets a screen of its own: an admin can now read what the API, the downloader and the browsers wrote down, filter it, and follow one request or job from start to end.
### Features
- Admin → Server log: the stored log, newest first, with a search, level chips, and source, account, event and period filters. A line opens in place to its full record; its request id or job id narrows the list to everything that request or job wrote.
- Filters live in the address bar, so a filtered view is a link, and Back brings the previous view back.
- Live follows the newest lines every few seconds; Copy puts the loaded lines on the clipboard, oldest first. A failed read says so instead of quietly freezing the list.
- Both log viewers (Server log and My log) can be walked with j/k and a line opened with Enter.
### Fixes
- New videos no longer arrive late from channels whose YouTube feed answers 404 or 500 now and then: a feed is tried again before the channel counts as failed.
- A % or _ typed into a search is searched for as itself, not as a wildcard.
### Internal
- The console's own reads are logged at debug level, so watching the log does not fill it.
- Two adversarial review rounds: 19 findings raised, 12 survived, all fixed, and every regression test was proven to fail with its own fix removed.
## 0.89.0 — 2026-09-19
A release for the log rather than for the screen: what the server writes down when something fails is now complete, said once, and free of what you downloaded or searched for.
+1 -1
View File
@@ -1 +1 @@
0.89.0
0.90.0
+4 -3
View File
@@ -30,7 +30,7 @@ from app.models import (
Video,
VideoState,
)
from app.utils import iso, to_tsquery_str
from app.utils import iso, like_escape, to_tsquery_str
def _channel_url(channel_id: str, handle: str | None) -> str:
@@ -272,8 +272,9 @@ def filtered_query(
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone.
like = func.unaccent(f"%{q}%")
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone. The query
# is a literal inside the pattern: `_` in `my_channel` is not a wildcard.
like = func.unaccent(f"%{like_escape(q)}%")
matching_channel_ids = (
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
.scalars()
+5 -3
View File
@@ -638,9 +638,11 @@ def current_source() -> str:
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
# (5.7k lines/day saying nothing) and hashed static assets are served by the hundred per page load.
# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
# (5.7k lines/day saying nothing), hashed static assets are served by the hundred per page load, and
# the admin log console reads the stored log — its live tail every few seconds, so at INFO the log
# would fill with the console watching itself. A FAILURE on any of them still logs at WARNING/ERROR
# — this lowers the floor, it does not hide.
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/", "/api/admin/logs")
# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is
# what an operator pins a level on when they want request lines and nothing else. The module it
+2
View File
@@ -32,6 +32,7 @@ from app.plex import stream as plex_stream
from app.plex import subtitles as plex_subtitles
from app.routes import (
admin,
admin_logs as admin_logs_routes,
audit as audit_routes,
auth as auth_routes,
channels,
@@ -285,6 +286,7 @@ app.include_router(admin.router)
app.include_router(config_routes.router)
app.include_router(scheduler_routes.router)
app.include_router(audit_routes.router)
app.include_router(admin_logs_routes.router)
app.include_router(quota.router)
app.include_router(version.router)
app.include_router(setup_routes.router)
-4
View File
@@ -298,10 +298,6 @@ class LibraryFilters:
}
def like_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
#
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
+234
View File
@@ -0,0 +1,234 @@
"""The admin diagnostics console's read side (E22.6): query `log_events`, the server's stored log.
Admin-only and read-only — rows leave the table through the retention job (`log_retention_days`),
never through here. Reading is not audited: it changes nothing, and the console's live tail polls,
so a row per read would bury the audit log's real entries.
What a row holds was redacted when it was written (`logs.record_fields`, and the ingest route for a
browser's rows), so this serves the columns as they are; a value the redactor took out reads as
`[redacted]` / `[email]`, which is the point — the console shows that something was there.
Paging is a keyset over `(ts desc, id desc)`: a browser row's `ts` is clamped into its consent
window, so `ts` alone is not unique, and an offset would skip or repeat rows while the writer adds
new ones at the top. The live tail re-reads the first page rather than asking for "ids after N":
several processes write this table, and a batch's ids are taken when it inserts, not when it
commits, so a slower batch can land below an id the tail already passed — and would never show.
"""
import base64
import binascii
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Text, and_, cast, func, or_, select
from sqlalchemy.orm import Session
from app.auth import admin_user
from app.db import get_db
from app.models import LogEvent, User
from app.utils import iso, like_escape
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
PAGE_MAX = 200
# A facet list is a chip row, not a report: past this many distinct events the rest are reachable
# through the free-text search, and the response stays small whatever a self-host logs.
FACET_EVENTS_MAX = 100
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# The filters a facet can leave out of its own count (`LogFilters.conditions`).
Facet = Literal["levels", "sources", "user", "event"]
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
# request) — a real value to filter on, not the absence of a filter.
NONE = "none"
def _utc(value: datetime | None) -> datetime | None:
"""A bound without a zone is UTC — the column is `timestamptz`, and a naive value would be read
in the database session's zone instead."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _encode_cursor(row: LogEvent) -> str:
return base64.urlsafe_b64encode(json.dumps([row.ts.isoformat(), row.id]).encode()).decode()
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
return _utc(datetime.fromisoformat(ts)), int(row_id)
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
def _serialize(row: LogEvent) -> dict:
return {
"id": row.id,
"ts": iso(row.ts),
"level": row.level,
"source": row.source,
"logger": row.logger,
"event": row.event,
"msg": row.msg,
"attrs": row.attrs,
"exc": row.exc,
"request_id": row.request_id,
"user_id": row.user_id,
"job_id": row.job_id,
"debug_session_id": row.debug_session_id,
"version": row.version,
}
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
"""The accounts a page names, by id → address. An id with no account left (erased after the
line was written) is simply absent, and the client shows the bare id."""
if not user_ids:
return {}
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
return {str(uid): email for uid, email in rows}
@dataclass(frozen=True)
class LogFilters:
"""The console's filters, shared by the page and its facet counts."""
levels: list[str]
sources: list[str]
user: str | None
event: str | None
request_id: str | None
job_id: int | None
q: str | None
since: datetime | None
until: datetime | None
def conditions(self, skip: Facet | None = None) -> list:
"""The WHERE conditions, ANDed by the caller. `skip` leaves one filter out: a facet counts
its own values under every OTHER filter, so picking a level does not zero the other
levels' counts (the way back), while a request pivot does narrow them (honest counts)."""
out = []
if self.levels and skip != "levels":
out.append(LogEvent.level.in_(self.levels))
if self.sources and skip != "sources":
out.append(LogEvent.source.in_(self.sources))
if self.user is not None and skip != "user":
out.append(
LogEvent.user_id.is_(None)
if self.user == NONE
else LogEvent.user_id == int(self.user)
)
if self.event is not None and skip != "event":
out.append(
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
)
if self.request_id is not None:
out.append(LogEvent.request_id == self.request_id)
if self.job_id is not None:
out.append(LogEvent.job_id == self.job_id)
if self.since is not None:
out.append(LogEvent.ts >= _utc(self.since))
if self.until is not None:
out.append(LogEvent.ts < _utc(self.until))
if self.q and self.q.strip():
like = f"%{like_escape(self.q.strip())}%"
out.append(
or_(
LogEvent.msg.ilike(like),
LogEvent.event.ilike(like),
LogEvent.request_id.ilike(like),
LogEvent.exc.ilike(like),
cast(LogEvent.attrs, Text).ilike(like),
)
)
return out
def log_filters(
levels: list[Level] = Query(default=[]),
sources: list[str] = Query(default=[], max_length=8),
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
event: str | None = Query(
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
),
request_id: str | None = Query(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$"),
job_id: int | None = Query(default=None, ge=1),
q: str | None = Query(default=None, max_length=200),
since: datetime | None = None,
until: datetime | None = None,
) -> LogFilters:
return LogFilters(list(levels), list(sources), user, event, request_id, job_id, q, since, until)
@router.get("")
def list_logs(
filters: LogFilters = Depends(log_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 rows, newest first, matching every filter given (they AND together); `before`
continues from a previous page's `next_cursor`."""
query = select(LogEvent).where(*filters.conditions())
if before is not None:
ts, row_id = _decode_cursor(before)
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
# `ts` index scan instead of filtering every newer row first.
query = query.where(
and_(
LogEvent.ts <= ts,
or_(LogEvent.ts < ts, and_(LogEvent.ts == ts, LogEvent.id < row_id)),
)
)
# One more than asked, to know whether there is a next page without a second count query.
rows = (
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
.scalars()
.all()
)
more = len(rows) > limit
rows = rows[:limit]
return {
"items": [_serialize(r) for r in rows],
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
"next_cursor": _encode_cursor(rows[-1]) if more else None,
}
@router.get("/facets")
def log_facets(
filters: LogFilters = Depends(log_filters),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""What the filter chips offer, with counts: the levels, sources and accounts present, and the
most frequent events — each counted under every filter but its own (`LogFilters.conditions`).
`total` is the "all levels" count: every filter but the level one."""
def counts(col, skip: Facet, cap: int | None = None) -> list[dict]:
n = func.count().label("n")
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
if cap is not None:
stmt = stmt.limit(cap)
return [
{"value": NONE if value is None else value, "count": count}
for value, count in db.execute(stmt).all()
]
users = counts(LogEvent.user_id, "user")
total = select(func.count()).select_from(LogEvent).where(*filters.conditions("levels"))
return {
"levels": counts(LogEvent.level, "levels"),
"sources": counts(LogEvent.source, "sources"),
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
"users": [{**u, "value": str(u["value"])} for u in users],
"accounts": _accounts(db, {u["value"] for u in users if u["value"] != NONE}),
"total": db.scalar(total) or 0,
}
+2 -1
View File
@@ -24,7 +24,8 @@ from app.plex import sync as plex_sync
from app.plex.cards import movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import content_libs, like_escape, runtime_by_key, top_art_keys
from app.plex.query import content_libs, runtime_by_key, top_art_keys
from app.utils import like_escape
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it).
+21 -10
View File
@@ -38,6 +38,7 @@ from app.models import (
VideoState,
)
from app.feedquery import feed_columns, serialize
from app.utils import like_escape
from app.sync.subscriptions import apply_channel_details
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
from app.youtube import search_scrape
@@ -224,6 +225,25 @@ def _new_first(db: Session, user: User, ids: list[str]) -> list[str]:
return [i for i in ids if i not in have] + [i for i in ids if i in have]
def _fold_term(db: Session, video_ids: list[str], term: str) -> None:
"""Append `term` to each video's space-joined `search_terms`, unless it is already one of them.
"Already one of them" is a whole-term match, as a literal: a substring test skipped `cat` on a
video already found for `category`, and an unescaped one read the `_` in `my_channel` as a
wildcard — either way the term was never folded in, and local search lost that relevance."""
padded = func.concat(" ", func.coalesce(Video.search_terms, ""), " ")
db.execute(
update(Video)
.where(Video.id.in_(video_ids))
.where(padded.notilike(f"% {like_escape(term)} %"))
.values(
search_terms=func.trim(
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
)
)
)
@router.get("/youtube")
def search_youtube(
q: str | None = None,
@@ -364,16 +384,7 @@ def search_youtube(
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
)
db.execute(
update(Video)
.where(Video.id.in_(ordered))
.where(func.coalesce(Video.search_terms, "").notilike(f"%{term}%"))
.values(
search_terms=func.trim(
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
)
)
)
_fold_term(db, ordered, term)
db.commit()
ordered = _new_first(db, user, ordered)
+8
View File
@@ -73,6 +73,14 @@ def to_tsquery_str(q: str) -> str | None:
return " & ".join(tokens)
def like_escape(s: str) -> str:
"""`s` as a LITERAL inside a LIKE/ILIKE pattern: `%` and `_` are wildcards there, and `\\` is
Postgres's default escape character, so all three are escaped. Every pattern built from text a
person typed goes through this — unescaped, a search for `100%` or `my_channel` matches rows it
should not."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def normalize_description(text: str | None) -> str | None:
"""Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel
descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage,
+38 -10
View File
@@ -1,6 +1,7 @@
"""Free per-channel RSS feed reader (no API quota). Returns the channel's most recent
~15 uploads as lightweight stubs for fast fresh-video detection."""
import time
from datetime import datetime, timezone
import feedparser
@@ -8,6 +9,18 @@ import httpx
RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
# The waits before a second and a third try. YouTube's feed endpoint answers the SAME channel with
# 404, 500 and 200 in turn (measured 2026-09-19: 15 of 24 channels failed a first request, 16 of them
# had succeeded by the fifth, one second apart), so one failure is not an answer. A feed that is
# really gone costs two more requests per poll. A TIMEOUT is not tried again: the flaky endpoint
# answers fast, and a request that hung for `TIMEOUT` says the way out is hanging — three of them
# per channel would stretch a poll over a dead egress to three times its interval.
RETRY_DELAYS = (1.0, 2.0)
TIMEOUT = 20.0
# A 404 is in the set because that is how the flaky endpoint fails most often; a 403/410 is not,
# and neither is anything else that says the request itself is wrong.
_RETRYABLE_STATUS = frozenset({404, 429, 500, 502, 503, 504})
class RssError(Exception):
"""The feed could not be read (transport failure or a non-200 answer).
@@ -24,21 +37,36 @@ class RssError(Exception):
self.reason = reason
def fetch_channel_feed(channel_id: str) -> list[dict]:
"""The channel's recent uploads as stubs. `[]` means the feed really is empty; a failure
raises RssError so the caller can leave the channel un-stamped and log it."""
def _get_feed(channel_id: str) -> bytes:
"""The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised."""
url = RSS_URL.format(channel_id=channel_id)
try:
resp = httpx.get(url, timeout=20.0, headers={"User-Agent": "Siftlode/1.0"})
except httpx.HTTPError as exc:
raise RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport") from exc
if resp.status_code != 200:
raise RssError(
for attempt in range(len(RETRY_DELAYS) + 1):
if attempt:
time.sleep(RETRY_DELAYS[attempt - 1])
try:
resp = httpx.get(url, timeout=TIMEOUT, headers={"User-Agent": "Siftlode/1.0"})
# silent-ok: not silent — the last failure is raised after the final try.
except httpx.HTTPError as exc:
error = RssError(f"RSS fetch failed for {channel_id}: {exc}", "transport")
error.__cause__ = exc
if isinstance(exc, httpx.TimeoutException):
break
continue
if resp.status_code == 200:
return resp.content
error = RssError(
f"RSS fetch for {channel_id} returned HTTP {resp.status_code}",
f"http_{resp.status_code}",
)
if resp.status_code not in _RETRYABLE_STATUS:
break
raise error
parsed = feedparser.parse(resp.content)
def fetch_channel_feed(channel_id: str) -> list[dict]:
"""The channel's recent uploads as stubs. `[]` means the feed really is empty; a failure
(after the retries) raises RssError so the caller can leave the channel un-stamped and log it."""
parsed = feedparser.parse(_get_feed(channel_id))
out: list[dict] = []
for entry in parsed.entries:
video_id = entry.get("yt_videoid")
+191
View File
@@ -0,0 +1,191 @@
"""The admin diagnostics console's query API (E22.6 S1), DB-lane."""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import insert
from app.models import LogEvent
T0 = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
def _row(**kw) -> dict:
row = {
"ts": T0,
"level": "INFO",
"sev": 9,
"source": "api",
"logger": "siftlode.http",
"event": "http.request",
"msg": "GET /api/feed 200",
"attrs": None,
"exc": None,
"request_id": None,
"user_id": None,
"job_id": None,
"version": "0.90.0",
}
row.update(kw)
return row
def _add(db, *rows: dict) -> None:
db.execute(insert(LogEvent), list(rows))
db.commit()
@pytest.fixture
def admin(db, make_user, as_user):
return as_user(make_user(email="admin@example.com", role="admin"))
def _msgs(resp) -> list[str]:
assert resp.status_code == 200, resp.text
return [it["msg"] for it in resp.json()["items"]]
def test_only_an_admin_reads_the_log(db, make_user, as_user, client):
_add(db, _row())
assert as_user(make_user()).get("/api/admin/logs").status_code == 403
assert as_user(make_user()).get("/api/admin/logs/facets").status_code == 403
assert client.get("/api/admin/logs").status_code == 401
def test_newest_first_and_every_row_once_across_pages_even_on_equal_timestamps(db, admin):
# A browser row's `ts` is clamped, so several rows share one: the id breaks the tie.
_add(db, *[_row(msg=f"same {i}") for i in range(5)], _row(ts=T0 - timedelta(1), msg="older"))
seen, cursor = [], None
while True:
resp = admin.get(
"/api/admin/logs", params={"limit": 2, "before": cursor} if cursor else {"limit": 2}
)
seen += _msgs(resp)
cursor = resp.json()["next_cursor"]
if cursor is None:
break
assert seen == ["same 4", "same 3", "same 2", "same 1", "same 0", "older"]
@pytest.mark.parametrize(
("params", "expected"),
[
({"levels": ["ERROR", "WARNING"]}, {"err", "warn"}),
({"sources": ["worker"]}, {"err"}),
({"user": "1"}, {"mine"}),
({"user": "none"}, {"err", "warn", "plain"}),
({"event": "download.job.failed"}, {"err"}),
({"event": "none"}, {"warn"}),
({"request_id": "req-abcdef12"}, {"mine"}),
({"job_id": 42}, {"err"}),
({"q": "FORBIDDEN"}, {"err"}), # in attrs, any case
({"q": "KeyError"}, {"warn"}), # in the stack
({"q": "100%"}, {"plain"}), # a literal, not a wildcard
({"since": (T0 - timedelta(hours=1)).isoformat()}, {"mine", "err", "warn"}),
({"until": (T0 - timedelta(hours=1)).isoformat()}, {"plain", "decoy"}),
({"levels": ["INFO"], "user": "none"}, {"plain"}),
],
)
def test_each_filter_selects_exactly_its_rows(db, admin, make_user, params, expected):
# user 1 is the admin the fixture made
_add(
db,
_row(msg="mine", user_id=1, request_id="req-abcdef12"),
_row(
msg="err",
level="ERROR",
sev=17,
source="worker",
event="download.job.failed",
job_id=42,
attrs={"code": "forbidden"},
),
_row(msg="warn", level="WARNING", sev=13, event=None, exc="Traceback …\nKeyError: 'x'"),
_row(msg="plain", ts=T0 - timedelta(hours=2), attrs={"note": "100% done"}),
# Matched by no filter above but `until`: another account, and `100%` read as a pattern.
_row(msg="decoy", ts=T0 - timedelta(hours=3), attrs={"note": "1000 done"}, user_id=99),
)
assert set(_msgs(admin.get("/api/admin/logs", params=params))) == expected
def test_a_page_names_its_accounts_and_leaves_an_erased_one_bare(db, admin):
_add(db, _row(user_id=1), _row(user_id=77))
assert admin.get("/api/admin/logs").json()["accounts"] == {"1": "admin@example.com"}
@pytest.mark.parametrize(
"params",
[
{"before": "not-a-cursor"},
{"before": "WyJ4IiwgMV0="}, # ["x", 1]: decodes, not a timestamp
],
)
def test_a_bad_cursor_is_a_400_with_a_code(db, admin, params):
resp = admin.get("/api/admin/logs", params=params)
assert resp.status_code == 400
assert resp.json()["detail"]["code"] == "bad_cursor"
def test_facets_count_the_window_and_name_the_rows_without_a_value(db, admin):
_add(
db,
_row(user_id=1),
_row(user_id=1, level="ERROR", sev=17, event="sync.rss.failed"),
_row(event=None),
_row(ts=T0 - timedelta(days=2), source="worker"),
)
body = admin.get(
"/api/admin/logs/facets", params={"since": (T0 - timedelta(days=1)).isoformat()}
).json()
assert body["total"] == 3
assert body["levels"] == [{"value": "INFO", "count": 2}, {"value": "ERROR", "count": 1}]
assert body["sources"] == [{"value": "api", "count": 3}]
assert body["events"] == [
{"value": "http.request", "count": 1},
{"value": "sync.rss.failed", "count": 1},
{"value": "none", "count": 1},
]
assert body["users"] == [{"value": "1", "count": 2}, {"value": "none", "count": 1}]
assert body["accounts"] == {"1": "admin@example.com"}
def test_a_facet_counts_under_every_other_filter_but_not_its_own(db, admin):
_add(
db,
_row(request_id="req-aaaaaaaa"),
_row(request_id="req-aaaaaaaa", level="ERROR", sev=17, source="worker"),
_row(request_id="req-bbbbbbbb", level="ERROR", sev=17),
_row(request_id="req-bbbbbbbb"),
)
body = admin.get(
"/api/admin/logs/facets", params={"request_id": "req-aaaaaaaa", "levels": ["ERROR"]}
).json()
# The pivot narrows every count; the picked level does not zero its sibling (the way back).
assert body["levels"] == [{"value": "ERROR", "count": 1}, {"value": "INFO", "count": 1}]
assert body["total"] == 2
# Every other facet counts under the level filter too.
assert body["sources"] == [{"value": "worker", "count": 1}]
@pytest.mark.parametrize(
("params", "facet", "expected"),
[
({"sources": ["worker"]}, "sources", {"api", "worker"}),
({"user": "1"}, "users", {"1", "none"}),
({"event": "sync.rss.failed"}, "events", {"http.request", "sync.rss.failed"}),
],
)
def test_every_facet_keeps_its_siblings_under_its_own_filter(db, admin, params, facet, expected):
# The way back from a pick: the picked facet still offers the values it was picked from.
_add(db, _row(user_id=1), _row(source="worker", event="sync.rss.failed"))
body = admin.get("/api/admin/logs/facets", params=params).json()
assert {f["value"] for f in body[facet]} == expected
+81
View File
@@ -0,0 +1,81 @@
"""What a person types is a literal inside a LIKE pattern, never a wildcard (DB-lane).
`%` and `_` are LIKE wildcards. The feed's channel-name match and the live search's term fold both
built their pattern from the typed text as it was, so `my_channel` also matched `myXchannel`, and a
term already present as part of a longer word was never folded in.
"""
from datetime import datetime, timezone
from sqlalchemy import select
from app.models import Channel, Subscription, Video
from app.routes.search import _fold_term
def _video(vid: str, ch: str, title: str, search_terms: str | None = None) -> Video:
return Video(
id=vid,
channel_id=ch,
published_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
title=title,
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
search_terms=search_terms,
)
def test_an_underscore_in_a_feed_search_matches_only_itself(db, as_user, make_user):
user = make_user()
db.add_all(
[
Channel(id="UCunder", title="build_log daily", subscriber_count=1),
Channel(id="UClookalike", title="buildXlog daily", subscriber_count=1),
]
)
db.add_all(
[
Subscription(user_id=user.id, channel_id=ch, priority=0, hidden=False)
for ch in ("UCunder", "UClookalike")
]
)
db.add_all(
[
_video("vidUnder", "UCunder", "Nothing in common"),
_video("vidLook", "UClookalike", "Nothing in common either"),
]
)
db.commit()
resp = as_user(user).get("/api/feed?q=build_log&show=all&limit=50")
assert resp.status_code == 200, resp.text
assert {it["id"] for it in resp.json()["items"]} == {"vidUnder"}
def test_a_term_is_folded_in_unless_it_is_already_a_whole_term(db):
db.add(Channel(id="UCfold", title="Fold", subscriber_count=1))
db.add_all(
[
_video("vidWord", "UCfold", "a", search_terms="category"),
_video("vidWild", "UCfold", "b", search_terms="catXdog"),
_video("vidHas", "UCfold", "c", search_terms="old cat_dog new"),
_video("vidNone", "UCfold", "d"),
]
)
db.commit()
_fold_term(db, ["vidWord", "vidWild", "vidHas", "vidNone"], "cat")
_fold_term(db, ["vidWild", "vidHas"], "cat_dog")
db.commit()
db.expire_all()
terms = dict(db.execute(select(Video.id, Video.search_terms)).all())
assert terms == {
"vidWord": "category cat",
"vidWild": "catXdog cat cat_dog",
"vidHas": "old cat_dog new cat",
"vidNone": "cat",
}
+17 -1
View File
@@ -14,7 +14,7 @@ import logging
from pathlib import Path
import pytest
from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from app import logs
@@ -213,6 +213,12 @@ def _client() -> TestClient:
def _health():
return {"ok": True}
@app.get("/api/admin/logs/facets")
def _log_console(fail: bool = False):
if fail:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"})
return {"ok": True}
@app.get("/boom")
def _boom():
raise RuntimeError("kaboom")
@@ -307,6 +313,16 @@ def test_the_healthcheck_is_logged_at_debug_so_it_cannot_drown_the_log(sink):
assert len(sink.lines) == before
def test_the_log_consoles_own_reads_are_debug_but_its_failures_are_not(sink):
http = logging.getLogger("siftlode.http")
http.setLevel(logs.TRACE)
client = _client()
client.get("/api/admin/logs/facets")
assert sink.objects()[-1]["level"] == "DEBUG"
client.get("/api/admin/logs/facets?fail=1")
assert sink.objects()[-1]["level"] == "WARNING"
def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
client = _client()
client.get("/api/videos/missing/deeper") # 404 from the router
+55
View File
@@ -246,6 +246,61 @@ class TestRssErrorVsEmpty:
)
monkeypatch.setattr(rss.httpx, "get", fake_get)
monkeypatch.setattr(rss.time, "sleep", lambda s: None)
def _answers(self, monkeypatch, *answers):
"""The endpoint answers each try with the next of `answers` (a status or an exception);
returns the list of tries made and the waits between them."""
tries, waits = [], []
queue = list(answers)
def fake_get(url, **_):
answer = queue.pop(0)
tries.append(answer)
if isinstance(answer, Exception):
raise answer
body = self.FEED if answer == 200 else b""
return httpx.Response(answer, content=body, request=httpx.Request("GET", url))
monkeypatch.setattr(rss.httpx, "get", fake_get)
monkeypatch.setattr(rss.time, "sleep", waits.append)
return tries, waits
FEED = b"""<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:yt="http://www.youtube.com/xml/schemas/2015">
<entry><yt:videoId>abc123</yt:videoId><title>Hello</title></entry>
</feed>"""
def test_a_feed_that_fails_and_then_answers_is_read(self, monkeypatch):
# The endpoint's measured behaviour: the same channel 404s, 500s, then answers.
tries, waits = self._answers(monkeypatch, 404, 500, 200)
assert [e["id"] for e in rss.fetch_channel_feed("UC_flaky")] == ["abc123"]
assert (len(tries), waits) == (3, list(rss.RETRY_DELAYS))
def test_a_dropped_connection_is_tried_again_too(self, monkeypatch):
tries, _ = self._answers(monkeypatch, httpx.ConnectError("reset"), 200)
assert rss.fetch_channel_feed("UC_blip")
assert len(tries) == 2
def test_a_timeout_is_not_tried_again(self, monkeypatch):
# A hung request already cost the full timeout; retrying it would triple a poll over a
# dead egress.
tries, waits = self._answers(monkeypatch, httpx.ReadTimeout("hung"), 200)
with pytest.raises(rss.RssError) as err:
rss.fetch_channel_feed("UC_hung")
assert (len(tries), waits, err.value.reason) == (1, [], "transport")
def test_a_feed_that_keeps_failing_raises_its_last_failure(self, monkeypatch):
tries, _ = self._answers(monkeypatch, 500, 404, 404)
with pytest.raises(rss.RssError) as err:
rss.fetch_channel_feed("UC_gone")
assert (len(tries), err.value.reason) == (3, "http_404")
def test_a_refusal_is_not_tried_again(self, monkeypatch):
tries, waits = self._answers(monkeypatch, 403)
with pytest.raises(rss.RssError) as err:
rss.fetch_channel_feed("UC_forbidden")
assert (len(tries), waits, err.value.reason) == (1, [], "http_403")
def test_non_200_raises(self, monkeypatch):
self._patch(monkeypatch, status=404)
@@ -0,0 +1,277 @@
// @vitest-environment jsdom
//
// E22.6: the admin log console. A request id narrows the list to that request (and says so, in the
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
// error, not an empty log; Copy takes every loaded line, oldest first.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { LogFacets, LogPage, LogQuery, ServerLogRow } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars?.id != null ? `${key}:${String(vars.id)}` : key,
i18n: { language: "en" },
}),
};
});
const logsMock = vi.fn<(query: LogQuery, before: string | null) => Promise<LogPage>>();
const facetsMock = vi.fn<(query: LogQuery) => Promise<LogFacets>>();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
adminLogFacets: (query: LogQuery) => facetsMock(query),
},
};
});
const copied: string[] = [];
vi.mock("../lib/clipboard", () => ({
copyText: (text: string) => {
copied.push(text);
return Promise.resolve(true);
},
}));
import AdminLogs from "./AdminLogs";
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
return {
id,
ts: new Date(Date.UTC(2026, 8, 19, 12, 0, id)).toISOString(),
level: "INFO",
source: "api",
logger: "siftlode.http",
event: "http.request",
msg: `GET /api/thing/${id} 200`,
attrs: null,
exc: null,
request_id: `req-${String(id).padStart(8, "0")}`,
user_id: 1,
job_id: null,
debug_session_id: null,
version: "0.90.0",
...extra,
};
}
const FACETS: LogFacets = {
levels: [{ value: "INFO", count: 3 }],
sources: [{ value: "api", count: 3 }],
events: [{ value: "http.request", count: 3 }],
users: [{ value: "1", count: 3 }],
accounts: { "1": "admin@example.com" },
total: 3,
};
function renderConsole() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<AdminLogs />
</QueryClientProvider>,
);
}
// react-query hands its updates to React on a (real) timer tick, after the promise that caused them.
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 20)));
const lines = () =>
within(screen.getByTestId("admin-logs-lines"))
.queryAllByRole("button")
.filter((b) => b.hasAttribute("aria-expanded"));
beforeEach(() => {
window.history.replaceState(null, "", "/?page=logs");
facetsMock.mockResolvedValue(FACETS);
});
afterEach(() => {
vi.useRealTimers();
cleanup();
logsMock.mockReset();
facetsMock.mockReset();
});
describe("AdminLogs", () => {
it("a request id narrows the list to that request, with every other filter off", async () => {
window.history.replaceState(null, "", "/?page=logs&lgrp=error&lq=boom");
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
fireEvent.click(lines()[0]!);
fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ request_id: "req-00000001", levels: undefined, q: undefined }),
null,
),
);
expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
const url = new URLSearchParams(window.location.search);
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual(["req-00000001", null, null]);
});
it("keeps the account in use offered when the facets no longer count it", async () => {
window.history.replaceState(null, "", "/?page=logs&lu=7");
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(facetsMock).toHaveBeenCalled());
fireEvent.click(screen.getByTestId("logs-user"));
expect(await screen.findByTestId("logs-user-7")).toBeTruthy();
expect(screen.getByTestId("logs-user-1")).toBeTruthy();
});
it("Live watches the newest page only", async () => {
logsMock.mockImplementation(async (_q, before) =>
before === null
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
: { items: [row(1)], accounts: {}, next_cursor: null },
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(lines()).toHaveLength(3));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await waitFor(() => expect(lines()).toHaveLength(2));
expect(
screen.getByRole("button", { name: "adminLogs.live" }).getAttribute("aria-pressed"),
).toBe("true");
});
it("a page that lands after Live went on is dropped at the next tick", async () => {
let landOlder!: (page: LogPage) => void;
logsMock.mockImplementation((_q, before) =>
before === null
? Promise.resolve({ items: [row(3), row(2)], accounts: {}, next_cursor: "c1" })
: new Promise<LogPage>((resolve) => (landOlder = resolve)),
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(landOlder).toBeTypeOf("function"));
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await act(async () => landOlder({ items: [row(1)], accounts: {}, next_cursor: null }));
await settle();
expect(lines()).toHaveLength(3);
const olderReads = logsMock.mock.calls.filter(([, before]) => before !== null).length;
await act(async () => vi.advanceTimersByTimeAsync(4000));
await settle();
expect(lines()).toHaveLength(2);
expect(logsMock.mock.calls.filter(([, before]) => before !== null)).toHaveLength(olderReads);
});
it("a failed live read stops the polling and says the list may be old", async () => {
logsMock.mockResolvedValueOnce({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
logsMock.mockRejectedValue(new Error("500"));
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await act(async () => vi.advanceTimersByTimeAsync(4000));
await settle();
expect(screen.getByRole("alert").textContent).toContain("adminLogs.stale");
expect(lines()).toHaveLength(1);
const reads = logsMock.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(12000));
expect(logsMock.mock.calls.length).toBe(reads);
// The button says polling stopped, and pressing it reads again.
const liveButton = screen.getByRole("button", { name: "adminLogs.live" });
expect(liveButton.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(liveButton);
await settle();
expect(logsMock.mock.calls.length).toBe(reads + 1);
});
it("a failed older page says so, not that the list is out of date", async () => {
logsMock.mockImplementation(async (_q, before) => {
if (before !== null) throw new Error("500");
return { items: [row(2)], accounts: {}, next_cursor: "c1" };
});
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed");
});
it("offers no older page while a filter change loads", async () => {
let landErrors!: (page: LogPage) => void;
logsMock.mockImplementation((q) =>
q.levels
? new Promise<LogPage>((resolve) => (landErrors = resolve))
: Promise.resolve({ items: [row(2)], accounts: {}, next_cursor: "c1" }),
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.error/ }));
await waitFor(() => expect(landErrors).toBeTypeOf("function"));
// The rows on screen are the previous filter's: their cursor is not this list's.
expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeNull();
await act(async () => landErrors({ items: [row(1)], accounts: {}, next_cursor: null }));
});
it("copies every loaded line, oldest first", async () => {
logsMock.mockImplementation(async (_q, before) =>
before === null
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
: { items: [row(1)], accounts: {}, next_cursor: null },
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(lines()).toHaveLength(3));
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "adminLogs.copy" }));
});
const ids = copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).id);
expect(ids).toEqual([1, 2, 3]);
});
it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
const items = lines().map((b) => b.parentElement!);
expect(items.map((el) => el.getAttribute("data-nav-index"))).toEqual(["0", "1"]);
expect(lines().every((b) => b.hasAttribute("data-nav-open"))).toBe(true);
expect(screen.getByTestId("admin-logs-lines").querySelector("[data-nav-list]")).toBeTruthy();
});
it("a failed read is an error with a retry, not an empty log", async () => {
logsMock.mockRejectedValue(new Error("502"));
renderConsole();
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.queryByText("adminLogs.empty")).toBeNull();
});
});
+483
View File
@@ -0,0 +1,483 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio, Search, X } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
DEFAULT_LOGS_STATE,
logsParamsToState,
logsParamUpdates,
WINDOW_HOURS,
type LogsUrlState,
type LogWindow,
} from "../lib/logsUrlState";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { useUrlMirror } from "../lib/useUrlMirror";
import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
// into older rows turns it off. A failed read stops the polling (the request already reported it
// once) and marks the list as possibly stale, instead of re-raising the failure every few seconds.
const LIVE_MS = 4000;
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
const WINDOWS: readonly LogWindow[] = ["1h", "24h", "7d"];
// A pivot shows everything about one request or job: the other filters would only hide part of it.
const PIVOT_RESET: Omit<LogsUrlState, "requestId" | "jobId"> = {
...DEFAULT_LOGS_STATE,
window: "7d",
};
function sinceOf(window: LogWindow): string {
return new Date(Date.now() - WINDOW_HOURS[window] * 3_600_000).toISOString();
}
function toQuery(state: LogsUrlState, q: string): LogQuery {
return {
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
sources: state.sources,
user: state.user ?? undefined,
event: state.event ?? undefined,
request_id: state.requestId ?? undefined,
job_id: state.jobId ?? undefined,
q: q.trim() || undefined,
};
}
export default function AdminLogs() {
const { t } = useTranslation();
const qc = useQueryClient();
const [state, setState] = useState<LogsUrlState>(() =>
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLiveState] = useState(false);
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
// after Live was switched on, or a filter set revisited with older pages still cached.
const liveRef = useRef(false);
const setLive = (on: boolean) => {
liveRef.current = on;
setLiveState(on);
};
const [open, setOpen] = useState<number | null>(null);
const q = useDebounced(state.q, 300);
useUrlMirror(logsParamUpdates(state));
const update = (patch: Partial<LogsUrlState>) => {
setOpen(null);
setState((s) => ({ ...s, ...patch }));
};
const query = toQuery(state, q);
const key = qk.adminLogs({ ...query, window: state.window });
const logs = useInfiniteQuery({
queryKey: key,
queryFn: ({ pageParam }) =>
api.adminLogs({ ...query, since: sinceOf(state.window) }, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
placeholderData: keepPreviousData,
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
});
const facets = useQuery({
queryKey: qk.adminLogFacets({ ...query, window: state.window }),
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.window) }),
placeholderData: keepPreviousData,
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
});
// A failed read stops the polling (the intervals above); the button shows that, and pressing it
// again reads now.
const polling = live && !logs.isError;
// What "Load older" continues from — read off the data, since `hasNextPage` is off while live; not
// off placeholder rows, which are the previous filter's.
const olderCursor = logs.isPlaceholderData
? null
: (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null);
// A read that failed while lines were on screen: they are kept, and the message says what the
// failure means for them. (With no lines, the list's own error state says it.)
const failure = !logs.data
? null
: logs.isFetchNextPageError
? "olderFailed"
: logs.isError || (facets.isError && facets.data)
? "stale"
: facets.isError
? "countsFailed"
: null;
const retryFailed = () => {
if (logs.isFetchNextPageError) void logs.fetchNextPage();
else if (logs.isError) void logs.refetch();
if (facets.isError) void facets.refetch();
};
const rows = useMemo(() => logs.data?.pages.flatMap((p) => p.items) ?? [], [logs.data]);
const accounts = useMemo(
() =>
Object.assign(
{},
facets.data?.accounts,
...(logs.data?.pages.map((p) => p.accounts) ?? []),
) as Record<string, string>,
[facets.data, logs.data],
);
const accountLabel = (id: string) =>
id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`);
const toggleLive = () => {
if (polling) {
setLive(false);
return;
}
// Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page.
qc.setQueryData<typeof logs.data>(key, (d) =>
d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d,
);
setLive(true);
if (logs.isError) void logs.refetch();
if (facets.isError) void facets.refetch();
};
const loadOlder = () => {
setLive(false);
void logs.fetchNextPage();
};
const copy = async () => {
// Oldest first, as a log reads.
const text = [...rows]
.reverse()
.map((r) => JSON.stringify(r))
.join("\n");
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("adminLogs.copied", { count: rows.length }) }
: { level: "error", message: t("adminLogs.copyFailed") },
);
};
const groupCount = (group: LevelGroup) => {
const levels = facets.data?.levels ?? [];
const sum = (list: readonly LogFacet[]) => list.reduce((n, f) => n + f.count, 0);
if (group === "all") return facets.data?.total;
const inGroup: readonly string[] = LEVEL_GROUPS[group];
return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined;
};
const pivot = (patch: Pick<LogsUrlState, "requestId" | "jobId">) => {
setLive(false);
update({ ...PIVOT_RESET, ...patch });
};
// "Any", then what the facet counted. The value in use stays offered even at zero — under the
// other filters it may count nothing, and a menu without it would show no way back.
const facetOptions = (
list: readonly LogFacet[] | undefined,
selected: string | null,
label: (v: string) => string,
) => {
const facets = list ?? [];
const kept =
selected && !facets.some((f) => f.value === selected) ? [{ value: selected, count: 0 }] : [];
return [
{ value: "", label: t("adminLogs.any") },
...[...facets, ...kept].map((f) => ({
value: f.value,
label: label(f.value),
count: f.count,
})),
];
};
return (
<div className="p-4 max-w-6xl w-full mx-auto">
<Section card title={t("adminLogs.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("adminLogs.intro")}</p>
<div
data-testid="admin-logs"
className="overflow-hidden rounded-xl border border-border bg-bg"
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[12rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</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">
<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={polling ? "outline-accent" : "outline"}
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
aria-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>
)}
{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 />
) : 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={formatDateTime(row.ts)}
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>
</Section>
</div>
);
}
function PivotChip({
label,
clearLabel,
onClear,
}: {
label: string;
clearLabel: string;
onClear: () => void;
}) {
return (
<span className="chip py-0.5 pl-2.5 pr-1">
<span className="font-mono">{label}</span>
<button
type="button"
onClick={onClear}
aria-label={clearLabel}
title={clearLabel}
className="rounded-full p-0.5 hover:bg-accent/20"
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
);
}
function RowDetail({
row,
account,
onRequest,
onJob,
}: {
row: ServerLogRow;
account: string | null;
onRequest: (id: string) => void;
onJob: (id: number) => void;
}) {
const { t } = useTranslation();
const pivotCls =
"font-mono text-accent 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 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{formatDateTime(row.ts)}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
</dd>
<dt>{t("adminLogs.fields.account")}</dt>
<dd>{account ?? "—"}</dd>
{row.request_id && (
<>
<dt>{t("adminLogs.fields.request")}</dt>
<dd>
<button
type="button"
className={pivotCls}
title={t("adminLogs.showRequest")}
onClick={() => onRequest(row.request_id!)}
>
{row.request_id}
</button>
</dd>
</>
)}
{row.job_id !== null && (
<>
<dt>{t("adminLogs.fields.job")}</dt>
<dd>
<button
type="button"
className={pivotCls}
title={t("adminLogs.showJob")}
onClick={() => onJob(row.job_id!)}
>
{row.job_id}
</button>
</dd>
</>
)}
{row.version && (
<>
<dt>{t("adminLogs.fields.version")}</dt>
<dd className="font-mono">{row.version}</dd>
</>
)}
</dl>
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{row.msg}</pre>
{row.attrs && (
<pre className="overflow-x-auto whitespace-pre">{JSON.stringify(row.attrs, null, 2)}</pre>
)}
{row.exc && <pre className="overflow-x-auto whitespace-pre text-danger/90">{row.exc}</pre>}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
// in place to whatever detail its screen has. One component, so a level reads the same on both.
// Inside a NavGrid the keyboard walks the lines (`j`/`k`) and Enter opens one, as a click does.
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
function logClock(ts: string): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
export default function LogLine({
ts,
level,
event,
msg,
open,
onToggle,
title,
navIndex,
children,
}: {
ts: string;
level: Level;
event: string;
msg: string;
open: boolean;
onToggle: () => void;
/** The row button's tooltip — the admin console puts the full date there. */
title?: string;
/** The line's place in its NavGrid. */
navIndex: number;
/** The detail shown while the line is open. */
children: ReactNode;
}) {
const tag = LEVEL_TAG[level];
return (
<div
className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, `${event} ${msg}`)}
>
<button
type="button"
data-nav-open
aria-expanded={open}
onClick={onToggle}
title={title}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 text-left hover:bg-card ${
open ? "bg-accent/10" : ""
}`}
>
<span className="tabular-nums text-muted">{logClock(ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
<span className="truncate">{event}</span>
{/* A phone has no room for a fourth column: the message takes a line of its own under the
code, full width — sharing the code's line left it a few characters. */}
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
</button>
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
</div>
);
}
+41 -68
View File
@@ -3,33 +3,24 @@ import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
import { copyText } from "../lib/clipboard";
import { clear, events, subscribe, type Level, type LogEvent } from "../lib/log";
import {
clear,
events,
LEVEL_GROUPS,
subscribe,
type LevelGroup as Filter,
type LogEvent,
} from "../lib/log";
import { notify } from "../lib/notifications";
import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
type Filter = "all" | "error" | "warning" | "info" | "detail";
const FILTER_LEVELS: Record<Exclude<Filter, "all">, readonly Level[]> = {
error: ["ERROR", "CRITICAL"],
warning: ["WARNING"],
info: ["INFO"],
detail: ["DEBUG", "TRACE"],
};
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
// would make the settings page the slowest thing in the app while it is watching the app.
const ROWS_SHOWN = 500;
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** The ring, live: re-read at most once per animation frame however fast events arrive. */
function useLogEvents(): LogEvent[] {
const [list, setList] = useState(events);
@@ -73,12 +64,6 @@ const matches = (event: LogEvent, needle: string) =>
.toLowerCase()
.includes(needle);
const clock = (ts: string) => {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
};
// "Saját napló" — this tab's own client log (lib/log.ts), exactly what it would send while a raised
// diagnostics level is on: newest first, filtered by level group and free text, a row opens to its
// correlation id and attributes, and "Copy" puts the FILTERED events on the clipboard as JSON lines
@@ -99,7 +84,7 @@ export default function MyLogViewer() {
detail: 0,
};
for (const e of all) {
for (const [group, levels] of Object.entries(FILTER_LEVELS)) {
for (const [group, levels] of Object.entries(LEVEL_GROUPS)) {
if (levels.includes(e.level)) out[group as Filter] += 1;
}
}
@@ -108,7 +93,7 @@ export default function MyLogViewer() {
const shown = useMemo(() => {
const needle = query.trim().toLowerCase();
const levels = filter === "all" ? null : FILTER_LEVELS[filter];
const levels = filter === "all" ? null : LEVEL_GROUPS[filter];
const out: LogEvent[] = [];
for (let i = all.length - 1; i >= 0; i--) {
const e = all[i]!;
@@ -166,47 +151,35 @@ export default function MyLogViewer() {
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
</p>
) : (
shown.slice(0, ROWS_SHOWN).map((e) => {
const tag = LEVEL_TAG[e.level];
const isOpen = open === e;
return (
<div key={rowKey(e)} className="border-b border-border/50 last:border-b-0">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setOpen(isOpen ? null : e)}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-2 px-3 py-1 text-left hover:bg-card ${
isOpen ? "bg-accent/10" : ""
}`}
>
<span className="tabular-nums text-muted">{clock(e.ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
{/* A phone has no room for a fourth column: the code leads the message there. */}
<span className="hidden truncate sm:block">{e.event}</span>
<span className="truncate">
<span className="sm:hidden">{e.event} · </span>
{e.msg}
</span>
</button>
{isOpen && (
<pre className="overflow-x-auto whitespace-pre bg-accent/5 px-3 py-2 text-muted">
{JSON.stringify(
{
event: e.event,
msg: e.msg,
request_id: e.request_id,
ts: e.ts,
tab_id: e.tab_id,
attrs: e.attrs,
},
null,
2,
)}
</pre>
)}
</div>
);
})
<NavGrid>
{shown.slice(0, ROWS_SHOWN).map((e, index) => (
<LogLine
navIndex={index}
key={rowKey(e)}
ts={e.ts}
level={e.level}
event={e.event}
msg={e.msg}
open={open === e}
onToggle={() => setOpen(open === e ? null : e)}
>
<pre className="overflow-x-auto whitespace-pre">
{JSON.stringify(
{
event: e.event,
msg: e.msg,
request_id: e.request_id,
ts: e.ts,
tab_id: e.tab_id,
attrs: e.attrs,
},
null,
2,
)}
</pre>
</LogLine>
))}
</NavGrid>
)}
</div>
@@ -74,6 +74,17 @@ describe("NavigationProvider · setPage's same-page guard", () => {
expect(result.current.page).toBe("plex");
});
it("leaves every page's own URL keys behind, the log console's included", () => {
window.history.replaceState(
{ sfPage: "logs" },
"",
"/?page=logs&lgrp=error&lreq=req-abcdef12&pscope=movie&ctab=hidden&keep=1",
);
const { result } = render();
act(() => result.current.setPage("plex"));
expect(window.location.search).toBe("?page=plex&keep=1");
});
it("pushes nothing when it is already that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { urlWithParams, type Page } from "../lib/urlState";
import { LOGS_KEYS } from "../lib/logsUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
@@ -89,6 +90,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
}),
);
notifyNav();
@@ -32,6 +32,7 @@ const Scheduler = lazyWithPreload(() => import("./Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
const AdminLogs = lazyWithPreload(() => import("./AdminLogs"));
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
export type PageDef = {
@@ -63,6 +64,7 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
config: { Component: ConfigPanel },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog, fadeTop: false },
logs: { Component: AdminLogs },
settings: { Component: SettingsPanel },
};
@@ -0,0 +1,43 @@
{
"title": "Server log",
"intro": "The server's stored log — lines from the API, the downloader and browsers, kept until the retention period ends. A line opens in place; click its request or job id to keep only that request's or job's lines.",
"search": "Search the message, code, stack…",
"levelLabel": "Filter by level",
"source": "Source",
"account": "Account",
"event": "Event",
"window": "Period",
"windows": {
"1h": "1 hour",
"24h": "24 hours",
"7d": "7 days"
},
"any": "Any",
"noAccount": "No account",
"noEvent": "No code",
"live": "Live",
"copy": "Copy",
"copied_one": "Copied {{count}} line",
"copied_other": "Copied {{count}} lines",
"copyFailed": "Couldn't copy to the clipboard",
"pivotRequest": "request {{id}}",
"pivotJob": "job {{id}}",
"clearPivot": "Clear filter",
"showRequest": "Only this request's lines",
"showJob": "Only this job's lines",
"empty": "No lines match the filters.",
"stale": "The last refresh failed; what you see may be out of date.",
"olderFailed": "Couldn't load the older lines.",
"countsFailed": "The filter counts didn't load.",
"count_one": "{{count}} line loaded · newest first",
"count_other": "{{count}} lines loaded · newest first",
"older": "Load older",
"fields": {
"when": "Time",
"source": "Source",
"account": "Account",
"request": "Request",
"job": "Job",
"version": "Version"
}
}
+1
View File
@@ -35,6 +35,7 @@
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}",
"audit": "Audit log",
"logs": "Server log",
"googleAddress": "Google account: {{email}}"
},
"sync": {
@@ -0,0 +1,43 @@
{
"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 job-azonosítójára kattintva csak az ahhoz tartozó sorok maradnak.",
"search": "Keresés az üzenetben, a kódban, a hívási veremben…",
"levelLabel": "Szűrés szintre",
"source": "Forrás",
"account": "Fiók",
"event": "Esemény",
"window": "Időszak",
"windows": {
"1h": "1 óra",
"24h": "24 óra",
"7d": "7 nap"
},
"any": "Bármi",
"noAccount": "Fiók nélkül",
"noEvent": "Kód nélkül",
"live": "Élő",
"copy": "Másolás",
"copied_one": "Kimásolva: {{count}} sor",
"copied_other": "Kimásolva: {{count}} sor",
"copyFailed": "Nem sikerült a vágólapra másolni",
"pivotRequest": "kérés {{id}}",
"pivotJob": "job {{id}}",
"clearPivot": "Szűrő törlése",
"showRequest": "Csak ennek a kérésnek a sorai",
"showJob": "Csak ennek a jobnak a sorai",
"empty": "Nincs a szűrésnek megfelelő sor.",
"stale": "Az utolsó frissítés nem sikerült, amit látsz, elavult lehet.",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat.",
"countsFailed": "A szűrők számai nem töltődtek be.",
"count_one": "{{count}} sor betöltve · a legújabb felül",
"count_other": "{{count}} sor betöltve · a legújabb felül",
"older": "Régebbiek betöltése",
"fields": {
"when": "Időpont",
"source": "Forrás",
"account": "Fiók",
"request": "Kérés",
"job": "Job",
"version": "Verzió"
}
}
+1
View File
@@ -35,6 +35,7 @@
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}",
"audit": "Auditnapló",
"logs": "Szervernapló",
"googleAddress": "Google-fiók: {{email}}"
},
"sync": {
+82 -2
View File
@@ -1,6 +1,8 @@
// The admin surface: discovery purge, system configuration, users & roles, the audit log, the
// scheduler + maintenance controls, the demo-account whitelist, and access invites. Only touches
// core's req. (Grouped by sub-area below; kept in one module as the cohesive "admin" domain.)
// diagnostics console's server log, the scheduler + maintenance controls, the demo-account
// whitelist, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
// module as the cohesive "admin" domain.)
import type { Level } from "../log";
import { req } from "./core";
export interface DemoWhitelistEntry {
@@ -115,6 +117,79 @@ export interface AuditListResult {
returned: number;
}
/** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's
* redactor took out arrive as `[redacted]` / `[email]`. */
export interface ServerLogRow {
id: number;
ts: string;
level: Level;
source: string;
logger: string;
event: string | null;
msg: string;
attrs: Record<string, unknown> | null;
exc: string | null;
request_id: string | null;
user_id: number | null;
job_id: number | null;
debug_session_id: string | null;
version: string | null;
}
/** The console's filters, as the API takes them. `none` picks the rows without an account / code. */
export interface LogQuery {
levels?: readonly Level[];
sources?: readonly string[];
user?: string;
event?: string;
request_id?: string;
job_id?: number;
q?: string;
since?: string;
}
export interface LogPage {
items: ServerLogRow[];
/** The accounts this page names, by id; an erased one is absent. */
accounts: Record<string, string>;
next_cursor: string | null;
}
export interface LogFacet {
value: string;
count: number;
}
/** Each facet is counted under every filter but its own; `total` is the all-levels count. */
export interface LogFacets {
levels: LogFacet[];
sources: LogFacet[];
events: LogFacet[];
users: LogFacet[];
accounts: Record<string, string>;
total: number;
}
function logParams(query: LogQuery, extra: Record<string, string | number | null> = {}): string {
const p = new URLSearchParams();
for (const level of query.levels ?? []) p.append("levels", level);
for (const source of query.sources ?? []) p.append("sources", source);
const single: Record<string, string | number | null | undefined> = {
user: query.user,
event: query.event,
request_id: query.request_id,
job_id: query.job_id,
q: query.q,
since: query.since,
...extra,
};
for (const [key, value] of Object.entries(single)) {
if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
}
const s = p.toString();
return s ? `?${s}` : "";
}
export interface SchedulerJob {
id: string;
interval_minutes: number;
@@ -172,6 +247,11 @@ export const adminApi = {
// --- users & roles ---
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
// --- diagnostics console ---
adminLogs: (query: LogQuery, before: string | null): Promise<LogPage> =>
req(`/api/admin/logs${logParams(query, { before })}`),
adminLogFacets: (query: LogQuery): Promise<LogFacets> =>
req(`/api/admin/logs/facets${logParams(query)}`),
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
//
// The log console's query strings. The console's DOM tests mock this slice, so nothing else in the
// suite looks at the URL that goes out — and a multi-level group is the case that breaks quietly:
// `levels` must repeat, or FastAPI's list reads only the last one.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "./admin";
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({}),
text: async () => "{}",
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
const lastQuery = (): URLSearchParams => {
const calls = fetchMock.mock.calls;
return new URL(String(calls[calls.length - 1]?.[0]), "http://x").searchParams;
};
describe("adminApi log query strings", () => {
it("repeats the list filters and sends every single one it is given", async () => {
await adminApi.adminLogs(
{
levels: ["ERROR", "CRITICAL"],
sources: ["api", "worker"],
user: "none",
event: "sync.rss.failed",
request_id: "req-abcdef12",
job_id: 42,
q: "100% done",
since: "2026-09-19T11:00:00.000Z",
},
"cursor-1",
);
const p = lastQuery();
expect(p.getAll("levels")).toEqual(["ERROR", "CRITICAL"]);
expect(p.getAll("sources")).toEqual(["api", "worker"]);
expect(Object.fromEntries([...p].filter(([k]) => k !== "levels" && k !== "sources"))).toEqual({
user: "none",
event: "sync.rss.failed",
request_id: "req-abcdef12",
job_id: "42",
q: "100% done",
since: "2026-09-19T11:00:00.000Z",
before: "cursor-1",
});
});
it("leaves out what is not set, and the facets carry no cursor", async () => {
await adminApi.adminLogs({ q: "", user: undefined }, null);
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/admin/logs");
await adminApi.adminLogFacets({ levels: ["WARNING"], since: "2026-09-19T11:00:00.000Z" });
expect([...lastQuery().keys()]).toEqual(["levels", "since"]);
});
});
+11
View File
@@ -31,6 +31,17 @@ export const SEVERITY: Record<Level, number> = {
CRITICAL: 21,
};
/** How a log viewer groups the six levels into its filter chips — "Saját napló" and the admin
* console both read a log through these, so a level lands in the same chip on either screen. */
export type LevelGroup = "all" | "error" | "warning" | "info" | "detail";
export const LEVEL_GROUPS: Record<Exclude<LevelGroup, "all">, readonly Level[]> = {
error: ["ERROR", "CRITICAL"],
warning: ["WARNING"],
info: ["INFO"],
detail: ["DEBUG", "TRACE"],
};
export interface LogEvent {
ts: string;
level: Level;
+42
View File
@@ -0,0 +1,42 @@
// The admin log console's filters in the address bar: a link carries them, a hand-edited value the
// API would refuse degrades to "no filter", and a filter at its default leaves no key behind.
import { describe, expect, it } from "vitest";
import {
DEFAULT_LOGS_STATE,
logsParamsToState,
logsParamUpdates,
type LogsUrlState,
} from "./logsUrlState";
const params = (updates: Record<string, string | null>) =>
new URLSearchParams(Object.entries(updates).filter((e): e is [string, string] => e[1] !== null));
describe("logsUrlState", () => {
it("round-trips every filter", () => {
const state: LogsUrlState = {
group: "error",
sources: ["worker"],
user: "none",
event: "download.job.failed",
requestId: "req-abcdef12",
jobId: 42,
q: "100% done",
window: "7d",
};
expect(logsParamsToState(params(logsParamUpdates(state)))).toEqual(state);
});
it("leaves no key for a filter at its default", () => {
expect(Object.values(logsParamUpdates(DEFAULT_LOGS_STATE)).every((v) => v === null)).toBe(true);
});
it("drops a value the API would refuse instead of sending it", () => {
const state = logsParamsToState(
new URLSearchParams(
"lgrp=loud&lsrc=api,WORKER,x y&lu=me&lev=Not.A.Code&lreq=short&ljob=0&lwin=1y",
),
);
expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] });
});
});
+80
View File
@@ -0,0 +1,80 @@
// The admin diagnostics console's filters in the address bar (`?page=logs&lgrp=error&lreq=…`), so a
// filtered view — a request's lines, say — is a link. Keys are namespaced (`l…`) like the Plex and
// Channels ones; setPage clears them (LOGS_KEYS) and the console re-mirrors them while it is open.
import type { LevelGroup } from "./log";
export type LogWindow = "1h" | "24h" | "7d";
export interface LogsUrlState {
group: LevelGroup;
sources: string[];
/** An account id, or `none` for the rows without one. */
user: string | null;
/** An event code, or `none` for the rows without one. */
event: string | null;
requestId: string | null;
jobId: number | null;
q: string;
window: LogWindow;
}
export const DEFAULT_LOGS_STATE: LogsUrlState = {
group: "all",
sources: [],
user: null,
event: null,
requestId: null,
jobId: null,
q: "",
window: "24h",
};
export const LOGS_KEYS = ["lgrp", "lsrc", "lu", "lev", "lreq", "ljob", "lq", "lwin"] 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"];
// The same shapes the API validates: a link someone edited by hand degrades to "no filter" here
// instead of to a 422 on every request the console makes.
const USER_RE = /^(none|\d{1,10})$/;
const EVENT_RE = /^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$/;
const REQUEST_RE = /^[A-Za-z0-9._-]{8,64}$/;
const SOURCE_RE = /^[a-z]{1,16}$/;
const SOURCES_MAX = 8;
const Q_MAX = 200;
const pick = (value: string | null, re: RegExp, max = 64) =>
value && value.length <= max && re.test(value) ? value : null;
export function logsParamsToState(params: URLSearchParams): LogsUrlState {
const group = params.get("lgrp");
const window = params.get("lwin");
const job = params.get("ljob");
return {
group: GROUPS.includes(group as LevelGroup) ? (group as LevelGroup) : DEFAULT_LOGS_STATE.group,
sources: (params.get("lsrc") ?? "")
.split(",")
.filter((s) => SOURCE_RE.test(s))
.slice(0, SOURCES_MAX),
user: pick(params.get("lu"), USER_RE),
event: pick(params.get("lev"), EVENT_RE),
requestId: pick(params.get("lreq"), REQUEST_RE),
jobId: job && /^\d{1,10}$/.test(job) && Number(job) > 0 ? Number(job) : null,
q: (params.get("lq") ?? "").slice(0, Q_MAX),
window: window && window in WINDOW_HOURS ? (window as LogWindow) : DEFAULT_LOGS_STATE.window,
};
}
/** The URL updates for a state: a filter at its default clears its key. */
export function logsParamUpdates(state: LogsUrlState): Record<string, string | null> {
return {
lgrp: state.group === DEFAULT_LOGS_STATE.group ? null : state.group,
lsrc: state.sources.length ? state.sources.join(",") : null,
lu: state.user,
lev: state.event,
lreq: state.requestId,
ljob: state.jobId === null ? null : String(state.jobId),
lq: state.q.trim() ? state.q : null,
lwin: state.window === DEFAULT_LOGS_STATE.window ? null : state.window,
};
}
+9
View File
@@ -8,6 +8,7 @@ import {
ListVideo,
MessageSquare,
ScrollText,
FileSearch,
Settings,
SlidersHorizontal,
Tv,
@@ -108,6 +109,14 @@ const MODULES: readonly ModuleDef[] = [
system: true,
gate: isAdmin,
},
{
id: "logs",
labelKey: "header.account.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
+1
View File
@@ -19,6 +19,7 @@ const EXPECTED: Record<Page, string> = {
config: "header.configuration",
users: "header.users",
audit: "audit.title",
logs: "adminLogs.title",
settings: "settings.title",
};
+4
View File
@@ -90,6 +90,10 @@ export const qk = {
adminPendingAccess: () => ["admin-pending-access"] as const,
adminConfig: () => ["admin-config"] as const,
adminAudit: () => ["admin-audit"] as const,
// The console's filters are part of the key; `since` is not (it moves with the clock) — the
// window it came from is.
adminLogs: (filters: object) => ["admin-logs", filters] as const,
adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const,
adminStorage: () => ["admin-storage"] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
+20
View File
@@ -14,6 +14,26 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.90.0",
date: "2026-09-19",
summary:
"The server's log gets a screen of its own: an admin can now read what the API, the downloader and the browsers wrote down, filter it, and follow one request or job from start to end.",
features: [
"Admin → Server log: the stored log, newest first, with a search, level chips, and source, account, event and period filters. A line opens in place to its full record; its request id or job id narrows the list to everything that request or job wrote.",
"Filters live in the address bar, so a filtered view is a link, and Back brings the previous view back.",
"Live follows the newest lines every few seconds; Copy puts the loaded lines on the clipboard, oldest first. A failed read says so instead of quietly freezing the list.",
"Both log viewers (Server log and My log) can be walked with j/k and a line opened with Enter.",
],
fixes: [
"New videos no longer arrive late from channels whose YouTube feed answers 404 or 500 now and then: a feed is tried again before the channel counts as failed.",
"A % or _ typed into a search is searched for as itself, not as a wildcard.",
],
chores: [
"The console's own reads are logged at debug level, so watching the log does not fill it.",
"Two adversarial review rounds: 19 findings raised, 12 survived, all fixed, and every regression test was proven to fail with its own fix removed.",
],
},
{
version: "0.89.0",
date: "2026-09-19",
+1
View File
@@ -129,6 +129,7 @@ const PAGES = [
"config",
"users",
"audit",
"logs",
"notifications",
"messages",
"downloads",