Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cfb19bdc7 | ||
|
|
90c30d596f | ||
|
|
7ed6ccf171 | ||
|
|
ce30b99327 | ||
|
|
a7891d03c5 | ||
|
|
038da5374f | ||
|
|
92979fa920 | ||
|
|
2d5bd275fa | ||
|
|
49fe56b6e7 | ||
|
|
1fa6a0bea8 | ||
|
|
582f20fab6 | ||
|
|
6cb8231a9f | ||
|
|
6bbf532b70 | ||
|
|
2fa5c8c394 | ||
|
|
373415a2eb | ||
|
|
582c033e1c | ||
|
|
ef652ee5d8 | ||
|
|
250e3a9f90 | ||
|
|
6096dd6927 | ||
|
|
51e98bfc38 | ||
|
|
3ee00fb051 | ||
|
|
29094f5f51 | ||
|
|
16a44dec79 | ||
|
|
4e3e68d6e6 | ||
|
|
eb29f22eb7 | ||
|
|
8f6f89832f | ||
|
|
1c49870a7a | ||
|
|
142593083f | ||
|
|
8cfab498cf | ||
|
|
b5996a0e56 | ||
|
|
5e208353e3 | ||
|
|
66dbd0af81 |
@@ -7,6 +7,53 @@ 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.91.0 — 2026-09-19
|
||||
|
||||
What a test run on the live instance turned up, fixed: new videos keep arriving while YouTube's feeds are down, an open tab survives a release, and Back returns to what you left.
|
||||
|
||||
### Features
|
||||
|
||||
- A card's menu (right-click or long press) now offers the card's own actions — watched, save, hide, reset — beside the comfort-shelf pin.
|
||||
- Settings → Account shows your role and your last sign-in. 0.88 announced the last sign-in in your account panel; it is actually there now.
|
||||
|
||||
### Fixes
|
||||
|
||||
- When YouTube's feed for a channel stays dark for hours, as it did for 393 of 396 channels on 18–19 September, the channel's newest uploads are read through the YouTube API instead, at most once every 2 hours per channel (adjustable by an administrator, 0 turns it off).
|
||||
- A tab left open across a release no longer falls to the crash screen when it opens something it had not loaded yet: it reloads onto the new version once.
|
||||
- Back from a request's lines in the Server log returns to the view the pivot was made from, search text included, as 0.90 promised.
|
||||
- Back to Home keeps the question you asked (the time budget and the hero you rerolled to), and the keyboard lands on the card you opened, even when the rail's cards moved or several share a name.
|
||||
- Browser tabs are named after the page again: signed out, and on the privacy and terms pages.
|
||||
- On a phone, the feed and Plex toolbars wrap instead of running past the edge, and so do notifications.
|
||||
- The countdown of a raised diagnostics level counts from the moment it was raised, not from when the page was opened.
|
||||
- The download box's placeholder names every site it takes, Facebook included.
|
||||
|
||||
### Internal
|
||||
|
||||
- The screens' own polling reads (the unread badges, the download list, the scheduler) are logged at debug level in the server's log and in the browser's copy of it, where they made up about 95% of the lines.
|
||||
- An account with no YouTube channel no longer writes a warning every six hours, and a scheduler job that fails carries an event code.
|
||||
- Two adversarial review rounds: 15 findings raised, 11 survived, all fixed, and every regression test was proven to fail with its own fix removed.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add channels.feed_fallback_at — when a channel's uploads were last read through the API because
|
||||
its RSS feed would not answer
|
||||
|
||||
Revision ID: 0088_channels_feed_fallback_at
|
||||
Revises: 0087_users_diagnostics_level
|
||||
Create Date: 2026-09-19
|
||||
|
||||
YouTube's RSS feeds go dark for hours at a time, per channel (2026-09-18/19: 393 of 396 answered 404
|
||||
for four hours; the same channel answers 404 to every retry). The RSS poll then reads the newest
|
||||
uploads through the Data API instead, at a quota cost, and at most once per `rss_fallback_hours` per
|
||||
channel: this column is that clock. NULL = never needed it.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0088_channels_feed_fallback_at"
|
||||
down_revision: str | None = "0087_users_diagnostics_level"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"channels", sa.Column("feed_fallback_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("channels", "feed_fallback_at")
|
||||
@@ -119,6 +119,9 @@ class Settings(BaseSettings):
|
||||
# Recent-first backfill: how far back to fetch on the first pass per channel.
|
||||
backfill_recent_max_videos: int = 100
|
||||
backfill_recent_max_days: int = 365
|
||||
# A channel whose RSS feed has not answered for this many hours gets its newest uploads read
|
||||
# through the Data API instead (1 unit), at most once per this many hours. 0 = never.
|
||||
rss_fallback_hours: int = 2
|
||||
|
||||
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
||||
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
||||
|
||||
@@ -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()
|
||||
|
||||
+33
-4
@@ -638,9 +638,32 @@ 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")
|
||||
|
||||
# The reads the frontend POLLS (a `useLiveQuery`, the `me` heartbeat, the sync status, the subtitle
|
||||
# progress), by route template, GET only — logged at DEBUG like the prefixes above. One open tab asked
|
||||
# for the two unread counts, `/api/me` and the download index every 30 s, and at INFO those were ~95%
|
||||
# of the stored log (measured on prod 2026-09-19): 11k lines a day per tab burying every line someone
|
||||
# caused. The same route answered with a failure still logs at WARNING/ERROR.
|
||||
_POLLED_ROUTES = frozenset(
|
||||
{
|
||||
"/api/me",
|
||||
"/api/me/notifications/unread_count",
|
||||
"/api/messages/unread_count",
|
||||
"/api/downloads",
|
||||
"/api/downloads/index",
|
||||
"/api/downloads/usage",
|
||||
"/api/sync/my-status",
|
||||
"/api/admin/scheduler",
|
||||
"/api/admin/downloads",
|
||||
"/api/admin/downloads/storage",
|
||||
"/api/plex/subtitle/{rating_key}/{sub_ord}/status",
|
||||
}
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -687,7 +710,9 @@ async def log_requests(request, call_next):
|
||||
raise
|
||||
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||
status = response.status_code
|
||||
quiet = request.url.path.startswith(_QUIET_PREFIXES)
|
||||
quiet = request.url.path.startswith(_QUIET_PREFIXES) or (
|
||||
request.method == "GET" and _route_template(request) in _POLLED_ROUTES
|
||||
)
|
||||
if status >= 500:
|
||||
level = logging.ERROR
|
||||
elif status >= 400:
|
||||
@@ -713,6 +738,10 @@ async def log_requests(request, call_next):
|
||||
extra={**event("http.request", **attrs), **_request_owner(request, level)},
|
||||
)
|
||||
response.headers["X-Request-Id"] = rid
|
||||
# The browser logs its own copy of this line (frontend lib/http.ts) and must pick the same level:
|
||||
# it learns "this one is quiet" here instead of keeping a second copy of the polled-route list.
|
||||
if level == logging.DEBUG:
|
||||
response.headers["X-Log-Quiet"] = "1"
|
||||
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
|
||||
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
|
||||
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -241,6 +241,9 @@ class Channel(Base, TimestampMixin):
|
||||
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# When the newest uploads were last read through the Data API because the RSS feed would not
|
||||
# answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock.
|
||||
feed_fallback_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
||||
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# True when this channel only entered the catalog because a live YouTube search
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ class QuotaAction:
|
||||
PLAYLISTS_DELETE = "playlists_delete"
|
||||
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
|
||||
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
|
||||
VIDEOS_FEED_FALLBACK = "videos_feed_fallback"
|
||||
VIDEOS_ENRICH = "videos_enrich"
|
||||
VIDEOS_LOOKUP = "videos_lookup"
|
||||
VIDEOS_SEARCH = "videos_search"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -23,6 +23,7 @@ from app import logs
|
||||
from app import sysconfig
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.utils import iso
|
||||
|
||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||
|
||||
@@ -138,6 +139,9 @@ def get_me(
|
||||
user.google_email if user.google_email and user.google_email != user.email else None
|
||||
),
|
||||
"has_password": user.password_hash is not None,
|
||||
# The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the
|
||||
# user does not recognise is something they can see (null: none since 0.88, or the demo).
|
||||
"last_login_at": iso(user.last_login_at),
|
||||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||
"google_enabled": google_enabled(db),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -208,7 +208,7 @@ def _job(name: str, fn) -> None:
|
||||
_audit_run(db, actor_id, name, "ok", summary, result)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
log.exception("job %s failed", name)
|
||||
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
|
||||
err = str(exc) or exc.__class__.__name__
|
||||
_record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None)
|
||||
if actor_id is not None:
|
||||
|
||||
@@ -670,11 +670,19 @@ def sync_all_playlists(db: Session) -> dict:
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
total += sync_user_playlists(db, user).get("synced", 0)
|
||||
except YouTubeError:
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
log.warning("Playlist sync failed for user %s", user.id)
|
||||
log.warning(
|
||||
"Playlist sync failed for user %s",
|
||||
user.id,
|
||||
extra=logs.event("sync.playlists.failed", user_id=user.id, reason=exc.reason),
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("Playlist sync crashed for user %s", user.id)
|
||||
log.exception(
|
||||
"Playlist sync crashed for user %s",
|
||||
user.id,
|
||||
extra=logs.event("sync.playlists.crashed", user_id=user.id),
|
||||
)
|
||||
progress.report(i, len(users), "playlist_sync")
|
||||
return {"users": len(users), "playlists_synced": total}
|
||||
|
||||
+126
-5
@@ -9,8 +9,9 @@ import logging
|
||||
import threading
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, progress, quota, sysconfig
|
||||
@@ -25,11 +26,13 @@ from app.sync.videos import (
|
||||
backfill_channel_deep,
|
||||
backfill_channel_recent,
|
||||
enrich_pending,
|
||||
insert_stubs,
|
||||
reconcile_full_history,
|
||||
refresh_live,
|
||||
run_shorts_classification,
|
||||
stub_from_playlist_item,
|
||||
)
|
||||
from app.youtube.client import YouTubeClient
|
||||
from app.youtube.client import YouTubeClient, YouTubeError, is_quota_refusal
|
||||
from app.youtube.rss import RssError, fetch_channel_feed
|
||||
|
||||
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
|
||||
@@ -85,6 +88,9 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
|
||||
reasons: Counter[str] = Counter()
|
||||
examples: list[str] = []
|
||||
# Channels whose FEED would not answer (not those whose rows failed to apply: the API would
|
||||
# hand the same rows to the same broken write) — the feed fallback's candidates.
|
||||
unread: list[str] = []
|
||||
|
||||
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
|
||||
"""Count a channel that did not get polled. `unexpected` is the message for a failure that
|
||||
@@ -120,6 +126,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
except RssError as exc:
|
||||
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
|
||||
fail(exc.reason, f"{channel.id}: {exc}")
|
||||
unread.append(channel.id)
|
||||
# silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the
|
||||
# traceback, once per reason per poll, which is the flood control this loop needs.
|
||||
except Exception as exc: # noqa: BLE001 — one channel must not stop the poll
|
||||
@@ -143,17 +150,24 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
# One place that advances the counter, whatever happened above.
|
||||
done += 1
|
||||
progress.report(done, total, "rss_poll")
|
||||
fallback = run_feed_fallback(db, unread)
|
||||
if failed:
|
||||
# The fallback's count rides on this line because this is the one the stored log keeps (an
|
||||
# INFO line outside a request never reaches `log_events`): the console should show whether
|
||||
# the uploads came in another way.
|
||||
log.warning(
|
||||
"RSS poll: %d/%d channels could not be polled",
|
||||
"RSS poll: %d/%d channels could not be polled; %d read through the API instead",
|
||||
failed,
|
||||
total,
|
||||
fallback["read"],
|
||||
extra=logs.event(
|
||||
"sync.rss.failed",
|
||||
failed=failed,
|
||||
total=total,
|
||||
reasons=dict(reasons),
|
||||
examples=examples,
|
||||
fallback_read=fallback["read"],
|
||||
fallback_new=fallback["new"],
|
||||
),
|
||||
)
|
||||
if total and failed == total:
|
||||
@@ -164,8 +178,115 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
|
||||
# subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red
|
||||
# card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says
|
||||
# "fix this subscription"; `Connection refused` says "the egress is down".
|
||||
raise RuntimeError(f"RSS poll failed for all {total} channels — first: {first_error}")
|
||||
return {"new": new, "failed": failed}
|
||||
# The fallback's count rides along: a red card during an RSS outage should still say
|
||||
# whether the uploads are arriving another way.
|
||||
raise RuntimeError(
|
||||
f"RSS poll failed for all {total} channels — first: {first_error}"
|
||||
f" (read through the API instead: {fallback['read']})"
|
||||
)
|
||||
return {"new": new + fallback["new"], "failed": failed, "fallback": fallback["read"]}
|
||||
|
||||
|
||||
def run_feed_fallback(db: Session, channel_ids: list[str]) -> dict[str, int]:
|
||||
"""Read the newest uploads through the Data API for the channels among `channel_ids` whose feed
|
||||
has not answered for `rss_fallback_hours` — and that were not read this way within as long.
|
||||
|
||||
YouTube's RSS goes dark per channel for hours at a time (2026-09-18/19: 393 of 396 feeds answered
|
||||
404 for four hours, and a retry of the same channel gets the same 404), and nothing else reads a
|
||||
followed channel's new uploads: recent backfill runs once per channel, ever. One page of the
|
||||
uploads playlist is 1 unit, so a whole day of total outage costs 12 units per channel at the
|
||||
default 2 hours; it yields to the backfill reserve like every other background read. The clock is
|
||||
`last_rss_at` or `feed_fallback_at`, whichever is later, so a channel is read at most once per
|
||||
window whether its feed came back in between or not. A read the API REFUSED stamps the clock
|
||||
too: a terminated channel's uploads playlist answers 404 for good, and without the stamp it
|
||||
would be charged again on every 20-minute poll instead of once per window. A failure that is not
|
||||
about the channel (no connection, no token, the quota spent) stamps nothing and ends the round."""
|
||||
hours = sysconfig.get_int(db, "rss_fallback_hours")
|
||||
if hours <= 0 or not channel_ids:
|
||||
return {"read": 0, "new": 0, "failed": 0}
|
||||
now = datetime.now(timezone.utc)
|
||||
last_read = func.greatest(Channel.last_rss_at, Channel.feed_fallback_at)
|
||||
due = (
|
||||
db.execute(
|
||||
select(Channel)
|
||||
.where(
|
||||
Channel.id.in_(channel_ids),
|
||||
Channel.uploads_playlist_id.is_not(None),
|
||||
or_(last_read.is_(None), last_read < now - timedelta(hours=hours)),
|
||||
)
|
||||
.order_by(Channel.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
user = get_service_user(db) if due else None
|
||||
if user is None:
|
||||
return {"read": 0, "new": 0, "failed": 0}
|
||||
read = new = failed = 0
|
||||
with (
|
||||
quota.attribute(None, quota.QuotaAction.VIDEOS_FEED_FALLBACK),
|
||||
YouTubeClient(db, user) as yt,
|
||||
):
|
||||
for channel in due:
|
||||
if quota.backfill_reserve_reached():
|
||||
break
|
||||
try:
|
||||
data = yt.get_playlist_items(channel.uploads_playlist_id, None)
|
||||
# silent-ok: counted, and the one summary line below names the count — the client
|
||||
# already logged the call's own failure (`youtube.api.error`).
|
||||
except YouTubeError as exc:
|
||||
failed += 1
|
||||
# Not an answer about THIS channel — no connection, no token, no quota: every
|
||||
# further channel would fail the same way, and none of them has used its window.
|
||||
if exc.path != "playlistItems" or is_quota_refusal(exc):
|
||||
break
|
||||
_stamp_fallback_attempt(db, channel.id)
|
||||
continue
|
||||
rows = [
|
||||
s
|
||||
for s in (stub_from_playlist_item(it, channel.id) for it in data.get("items", []))
|
||||
if s is not None
|
||||
]
|
||||
try:
|
||||
new += insert_stubs(db, rows)
|
||||
channel.feed_fallback_at = datetime.now(timezone.utc)
|
||||
db.add(channel)
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — one channel must not stop the rest
|
||||
db.rollback()
|
||||
log.exception(
|
||||
"Feed fallback could not store channel %s",
|
||||
channel.id,
|
||||
extra=logs.event("sync.rss.fallback_failed"),
|
||||
)
|
||||
failed += 1
|
||||
_stamp_fallback_attempt(db, channel.id)
|
||||
continue
|
||||
read += 1
|
||||
log.info(
|
||||
"Feed fallback: read %d of %d channels through the API, %d new videos",
|
||||
read,
|
||||
len(due),
|
||||
new,
|
||||
extra=logs.event("sync.rss.fallback", read=read, due=len(due), new=new, failed=failed),
|
||||
)
|
||||
return {"read": read, "new": new, "failed": failed}
|
||||
|
||||
|
||||
def _stamp_fallback_attempt(db: Session, channel_id: str) -> None:
|
||||
"""Start a channel's fallback window after a read that did not land, so it waits the window out
|
||||
like a read that did. Its own commit: the failure already rolled the channel's work back."""
|
||||
try:
|
||||
db.execute(
|
||||
update(Channel)
|
||||
.where(Channel.id == channel_id)
|
||||
.values(feed_fallback_at=datetime.now(timezone.utc))
|
||||
)
|
||||
db.commit()
|
||||
# silent-ok: the failure that brought us here was already logged and counted; an unstamped
|
||||
# channel is only read again at the next poll.
|
||||
except Exception: # noqa: BLE001
|
||||
db.rollback()
|
||||
|
||||
|
||||
def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
||||
|
||||
@@ -91,6 +91,7 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
ConfigSpec(
|
||||
"backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500
|
||||
),
|
||||
ConfigSpec("rss_fallback_hours", "int", "backfill", "rss_fallback_hours", min=0, max=168),
|
||||
# --- Shorts probe ---
|
||||
ConfigSpec(
|
||||
"shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ so they don't depend on a specific user's token; subscriptions.list?mine=true al
|
||||
uses OAuth.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -25,11 +26,15 @@ class YouTubeError(Exception):
|
||||
|
||||
`reason` is the API's own error reason code (`quotaExceeded`, `subscriptionDuplicate`,
|
||||
`videoNotFound`, …) when the body carried one. Branch on THAT, never on the message: the
|
||||
message is prose, it changes under us, and it is deliberately the poorer of the two here."""
|
||||
message is prose, it changes under us, and it is deliberately the poorer of the two here.
|
||||
|
||||
def __init__(self, message: str, *, reason: str = ""):
|
||||
`path` is the API endpoint that ANSWERED with the failure (`playlistItems`); empty when no
|
||||
endpoint did — the request never got through (`transport`) or the token step refused first."""
|
||||
|
||||
def __init__(self, message: str, *, reason: str = "", path: str = ""):
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
self.path = path
|
||||
|
||||
|
||||
def _error_reason(resp: httpx.Response) -> str:
|
||||
@@ -50,7 +55,9 @@ def _error_reason(resp: httpx.Response) -> str:
|
||||
return reasons or "<none>"
|
||||
|
||||
|
||||
def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeError:
|
||||
def _upstream_failure(
|
||||
method: str, path: str, resp: httpx.Response, expected: frozenset[str] = frozenset()
|
||||
) -> YouTubeError:
|
||||
"""Report one upstream failure and build the error to raise. Every YouTube response that is
|
||||
not a success goes through here — the Data API's reads, writes and deletes and the OAuth
|
||||
token refresh — so what may appear in a log line cannot drift apart between them, and the
|
||||
@@ -59,7 +66,11 @@ def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeEr
|
||||
response at all is `_send`'s, with the reason `transport`.) The body is logged only under
|
||||
the requesting user's own Teljes consent: it is the request quoted back."""
|
||||
reason = _error_reason(resp)
|
||||
log.warning(
|
||||
# A reason the caller named as an answer rather than a failure (`channelNotFound` on a `mine`
|
||||
# read: the account simply has no channel) is still raised, but it is not a warning — it came
|
||||
# back every six hours for an account that will never have one.
|
||||
log.log(
|
||||
logging.DEBUG if reason in expected else logging.WARNING,
|
||||
"YouTube %s %s -> %s (%s)",
|
||||
method,
|
||||
path,
|
||||
@@ -80,7 +91,21 @@ def _upstream_failure(method: str, path: str, resp: httpx.Response) -> YouTubeEr
|
||||
# log-pii-ok: only under this user's own Teljes consent, which is what it is for
|
||||
extra=logs.event("youtube.api.body", body=resp.text[:500]),
|
||||
)
|
||||
return YouTubeError(f"{method} {path} -> {resp.status_code}: {reason}", reason=reason)
|
||||
return YouTubeError(
|
||||
f"{method} {path} -> {resp.status_code}: {reason}", reason=reason, path=path
|
||||
)
|
||||
|
||||
|
||||
# The `mine` reads' answer for a Google account that has no YouTube channel.
|
||||
_NO_CHANNEL = frozenset({"channelNotFound"})
|
||||
|
||||
# The reasons that mean the project's quota is spent: every further call today gets the same answer.
|
||||
_QUOTA_REASONS = frozenset({"quotaExceeded", "dailyLimitExceeded"})
|
||||
|
||||
|
||||
def is_quota_refusal(exc: YouTubeError) -> bool:
|
||||
"""Whether the API refused for want of quota. `reason` can list several codes, comma-joined."""
|
||||
return any(r in _QUOTA_REASONS for r in exc.reason.split(","))
|
||||
|
||||
|
||||
def _chunks(items: list, size: int) -> Iterator[list]:
|
||||
@@ -167,7 +192,14 @@ class YouTubeClient:
|
||||
return access
|
||||
|
||||
# --- core request ---
|
||||
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict:
|
||||
def _get(
|
||||
self,
|
||||
path: str,
|
||||
params: dict,
|
||||
cost: int = 1,
|
||||
allow_key: bool = True,
|
||||
expected: frozenset[str] = frozenset(),
|
||||
) -> dict:
|
||||
p = dict(params)
|
||||
headers = {}
|
||||
api_key = sysconfig.get_str(self.db, "youtube_api_key")
|
||||
@@ -178,7 +210,7 @@ class YouTubeClient:
|
||||
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
|
||||
quota.record_usage(cost)
|
||||
if resp.status_code != 200:
|
||||
raise _upstream_failure("GET", path, resp)
|
||||
raise _upstream_failure("GET", path, resp, expected)
|
||||
return resp.json()
|
||||
|
||||
# --- endpoints ---
|
||||
@@ -218,7 +250,14 @@ class YouTubeClient:
|
||||
params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
data = self._get("playlists", params, cost=1, allow_key=False)
|
||||
try:
|
||||
data = self._get("playlists", params, cost=1, allow_key=False, expected=_NO_CHANNEL)
|
||||
except YouTubeError as exc:
|
||||
# A Google account without a YouTube channel has no playlists, and YouTube says
|
||||
# so as a 404. That is an empty list, not a failed sync.
|
||||
if exc.reason in _NO_CHANNEL:
|
||||
return
|
||||
raise
|
||||
for item in data.get("items", []):
|
||||
sn = item.get("snippet", {})
|
||||
yield {
|
||||
|
||||
+38
-10
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,150 @@
|
||||
"""The RSS poll's API fallback (`sync/runner.run_feed_fallback`), DB-lane: which channels are read
|
||||
through the Data API when their feed stays dark, and how often."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app import sysconfig
|
||||
from app.models import Channel, Video
|
||||
from app.sync import runner
|
||||
from app.youtube.client import YouTubeError
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
_PUB = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class _FakeYt:
|
||||
# playlist id -> the reason its read fails with (the tests put entries here)
|
||||
failing: dict[str, str] = {}
|
||||
|
||||
def __init__(self, calls):
|
||||
self.calls = calls
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def get_playlist_items(self, playlist_id, page_token=None):
|
||||
self.calls.append(playlist_id)
|
||||
if playlist_id in self.failing:
|
||||
reason = self.failing[playlist_id]
|
||||
# A request that never got through has no endpoint that answered.
|
||||
path = "" if reason == "transport" else "playlistItems"
|
||||
raise YouTubeError("refused", reason=reason, path=path)
|
||||
cid = "UC" + playlist_id[2:]
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"contentDetails": {"videoId": f"{cid}-v1", "videoPublishedAt": NOW.isoformat()},
|
||||
"snippet": {"title": "new upload"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calls(monkeypatch):
|
||||
made: list[str] = []
|
||||
monkeypatch.setattr(runner, "get_service_user", lambda db: object())
|
||||
monkeypatch.setattr(runner, "YouTubeClient", lambda db, user: _FakeYt(made))
|
||||
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: False)
|
||||
monkeypatch.setattr(_FakeYt, "failing", {})
|
||||
return made
|
||||
|
||||
|
||||
def _channel(db, cid, *, rss=None, fallback=None, uploads=True):
|
||||
ch = Channel(
|
||||
id=cid,
|
||||
title=cid,
|
||||
published_at=_PUB,
|
||||
uploads_playlist_id=("UU" + cid[2:]) if uploads else None,
|
||||
last_rss_at=rss,
|
||||
feed_fallback_at=fallback,
|
||||
)
|
||||
db.add(ch)
|
||||
db.commit()
|
||||
return ch
|
||||
|
||||
|
||||
def test_a_feed_dark_past_the_window_is_read_through_the_api(db, calls):
|
||||
_channel(db, "UCdark", rss=NOW - timedelta(hours=3))
|
||||
|
||||
out = runner.run_feed_fallback(db, ["UCdark"])
|
||||
|
||||
assert out == {"read": 1, "new": 1, "failed": 0}
|
||||
assert calls == ["UUdark"]
|
||||
assert db.get(Video, "UCdark-v1") is not None
|
||||
assert db.get(Channel, "UCdark").feed_fallback_at is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rss", "fallback"),
|
||||
[
|
||||
(NOW - timedelta(minutes=30), None), # the feed answered recently: a blip, not an outage
|
||||
(NOW - timedelta(hours=5), NOW - timedelta(minutes=30)), # already read this window
|
||||
],
|
||||
)
|
||||
def test_a_channel_read_within_the_window_is_left_alone(db, calls, rss, fallback):
|
||||
_channel(db, "UCrecent", rss=rss, fallback=fallback)
|
||||
|
||||
assert runner.run_feed_fallback(db, ["UCrecent"])["read"] == 0
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_the_window_is_the_admins_and_zero_turns_it_off(db, calls):
|
||||
_channel(db, "UCdark", rss=NOW - timedelta(hours=3))
|
||||
|
||||
sysconfig.set_value(db, "rss_fallback_hours", 4)
|
||||
db.commit()
|
||||
assert runner.run_feed_fallback(db, ["UCdark"])["read"] == 0
|
||||
|
||||
sysconfig.set_value(db, "rss_fallback_hours", 0)
|
||||
db.commit()
|
||||
_channel(db, "UCnever")
|
||||
assert runner.run_feed_fallback(db, ["UCdark", "UCnever"])["read"] == 0
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_only_the_named_channels_and_only_those_with_an_uploads_playlist(db, calls):
|
||||
_channel(db, "UCdark")
|
||||
_channel(db, "UCother")
|
||||
_channel(db, "UCnolist", uploads=False)
|
||||
|
||||
assert runner.run_feed_fallback(db, ["UCdark", "UCnolist"])["read"] == 1
|
||||
assert calls == ["UUdark"]
|
||||
|
||||
|
||||
def test_it_yields_to_the_backfill_reserve(db, calls, monkeypatch):
|
||||
_channel(db, "UCdark")
|
||||
monkeypatch.setattr(runner.quota, "backfill_reserve_reached", lambda: True)
|
||||
|
||||
assert runner.run_feed_fallback(db, ["UCdark"])["read"] == 0
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_a_read_that_fails_waits_the_window_out_too(db, calls):
|
||||
# A terminated channel: its feed 404s and so does its uploads playlist, for good. Without a
|
||||
# stamp it was charged on every 20-minute poll instead of once per window.
|
||||
_channel(db, "UCgone", rss=NOW - timedelta(hours=3))
|
||||
_FakeYt.failing["UUgone"] = "playlistNotFound"
|
||||
|
||||
assert runner.run_feed_fallback(db, ["UCgone"]) == {"read": 0, "new": 0, "failed": 1}
|
||||
assert db.get(Channel, "UCgone").feed_fallback_at is not None
|
||||
assert runner.run_feed_fallback(db, ["UCgone"])["failed"] == 0
|
||||
assert calls == ["UUgone"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", ["quotaExceeded", "transport"])
|
||||
def test_a_failure_not_about_the_channel_ends_the_round_and_costs_no_window(db, calls, reason):
|
||||
# Out of quota, or no connection: every further channel would fail the same way, and a channel
|
||||
# that was never answered about keeps its turn for the next poll.
|
||||
_channel(db, "UCa")
|
||||
_channel(db, "UCb")
|
||||
_FakeYt.failing["UUa"] = reason
|
||||
|
||||
assert runner.run_feed_fallback(db, ["UCa", "UCb"])["failed"] == 1
|
||||
assert calls == ["UUa"]
|
||||
assert db.get(Channel, "UCa").feed_fallback_at is None
|
||||
@@ -27,7 +27,7 @@ def test_the_rss_poll_skips_channels_nobody_follows(db, make_user, monkeypatch):
|
||||
polled: list[str] = []
|
||||
monkeypatch.setattr(runner, "fetch_channel_feed", lambda cid: polled.append(cid) or [])
|
||||
monkeypatch.setattr(runner, "apply_rss_feed", lambda db, ch, entries: 0)
|
||||
assert runner.run_rss_poll(db) == {"new": 0, "failed": 0}
|
||||
assert runner.run_rss_poll(db) == {"new": 0, "failed": 0, "fallback": 0}
|
||||
assert sorted(polled) == ["UC_explored", "UC_sub"]
|
||||
|
||||
|
||||
|
||||
@@ -95,3 +95,13 @@ def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
|
||||
|
||||
assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
|
||||
assert rows[never.email]["last_login_at"] is None
|
||||
|
||||
|
||||
def test_the_account_sees_its_own_last_sign_in(client, db, make_user):
|
||||
# Settings → Account shows it (prod UAT 2026-09-19: only the admin's user list did).
|
||||
make_user(email="me@example.com", password=_PW)
|
||||
client.post("/auth/password-login", json={"email": "me@example.com", "password": _PW})
|
||||
|
||||
stamp = client.get("/api/me").json()["last_login_at"]
|
||||
|
||||
assert _recent(datetime.fromisoformat(stamp))
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,20 @@ 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("/api/messages/unread_count")
|
||||
def _unread():
|
||||
return {"count": 0}
|
||||
|
||||
@app.post("/api/messages/unread_count")
|
||||
def _unread_write():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/boom")
|
||||
def _boom():
|
||||
raise RuntimeError("kaboom")
|
||||
@@ -307,6 +321,43 @@ 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_polled_read_is_debug_a_write_to_the_same_route_is_not(sink):
|
||||
# One open tab polled the unread counts, /api/me and the download index every 30 s: at INFO
|
||||
# that was ~95% of the stored log on prod.
|
||||
http = logging.getLogger("siftlode.http")
|
||||
http.setLevel(logs.TRACE)
|
||||
client = _client()
|
||||
r = client.get("/api/messages/unread_count")
|
||||
assert sink.objects()[-1]["level"] == "DEBUG"
|
||||
# ...and says so, for the browser's copy of the line (frontend lib/http.ts).
|
||||
assert r.headers["X-Log-Quiet"] == "1"
|
||||
r = client.post("/api/messages/unread_count")
|
||||
assert sink.objects()[-1]["level"] == "INFO"
|
||||
assert "X-Log-Quiet" not in r.headers
|
||||
client.get("/api/videos/abc")
|
||||
assert sink.objects()[-1]["level"] == "INFO"
|
||||
|
||||
|
||||
def test_every_polled_route_is_a_route_the_app_has():
|
||||
# The list names route TEMPLATES; one renamed on the router side would silently fall back to
|
||||
# INFO, so the list is held to the app's own routes.
|
||||
from app.main import app
|
||||
|
||||
# The schema, not `app.routes`: FastAPI 0.141 keeps an included router behind a wrapper there.
|
||||
gets = {path for path, ops in app.openapi()["paths"].items() if "get" in ops}
|
||||
assert logs._POLLED_ROUTES - gets == set()
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -112,11 +112,15 @@ class TestRssPollResult:
|
||||
def _run(self, monkeypatch, *, fetch, apply_, channels=("UC1", "UC2")):
|
||||
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
||||
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
||||
# The API fallback has its own DB-lane tests (test_feed_fallback.py); here it reads nothing.
|
||||
monkeypatch.setattr(
|
||||
runner, "run_feed_fallback", lambda db, ids: {"read": 0, "new": 0, "failed": 0}
|
||||
)
|
||||
return runner.run_rss_poll(_StubDb(), [_StubChannel(c) for c in channels])
|
||||
|
||||
def test_a_healthy_run_reports_no_failures(self, monkeypatch):
|
||||
out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=lambda db, ch, e: 1)
|
||||
assert out == {"new": 2, "failed": 0}
|
||||
assert out == {"new": 2, "failed": 0, "fallback": 0}
|
||||
|
||||
def test_unreadable_feeds_are_counted(self, monkeypatch):
|
||||
# One dead channel among healthy ones: counted, but the run itself is still a run.
|
||||
@@ -128,7 +132,7 @@ class TestRssPollResult:
|
||||
out = self._run(
|
||||
monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=("UC1", "UC_dead")
|
||||
)
|
||||
assert out == {"new": 1, "failed": 1}
|
||||
assert out == {"new": 1, "failed": 1, "fallback": 0}
|
||||
|
||||
def test_apply_failures_are_counted_too(self, monkeypatch):
|
||||
# A per-row write failure (a constraint clash on one channel) while the rest store fine.
|
||||
@@ -143,7 +147,9 @@ class TestRssPollResult:
|
||||
apply_=apply_,
|
||||
channels=("UC1", "UC_bad"),
|
||||
)
|
||||
assert out == {"new": 1, "failed": 1}, "an unstored channel must not read as ok"
|
||||
assert out == {"new": 1, "failed": 1, "fallback": 0}, (
|
||||
"an unstored channel must not read as ok"
|
||||
)
|
||||
|
||||
def test_many_failures_write_one_warning_with_counts_not_one_per_channel(
|
||||
self, monkeypatch, caplog
|
||||
@@ -163,7 +169,7 @@ class TestRssPollResult:
|
||||
|
||||
caplog.set_level(logging.DEBUG, logger="siftlode.sync")
|
||||
out = self._run(monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=channels)
|
||||
assert out == {"new": 5, "failed": 35}
|
||||
assert out == {"new": 5, "failed": 35, "fallback": 0}
|
||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
assert warnings[0].event == "sync.rss.failed"
|
||||
@@ -195,7 +201,11 @@ class TestRssPollResult:
|
||||
|
||||
def test_a_quiet_hour_is_distinguishable_from_an_outage(self, monkeypatch):
|
||||
quiet = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0)
|
||||
assert quiet == {"new": 0, "failed": 0} # same "new", different "failed" — that's the point
|
||||
assert quiet == {
|
||||
"new": 0,
|
||||
"failed": 0,
|
||||
"fallback": 0,
|
||||
} # same "new", different "failed" — that's the point
|
||||
|
||||
def test_a_total_outage_raises_instead_of_returning_ok(self, monkeypatch):
|
||||
# Every feed unreachable (the tinyproxy-lost-the-boot-race incident). Returning a result
|
||||
@@ -225,10 +235,43 @@ class TestRssPollResult:
|
||||
with pytest.raises(RuntimeError, match="read-only transaction"):
|
||||
self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=boom)
|
||||
|
||||
def test_only_the_unreadable_feeds_go_to_the_api_and_what_it_reads_counts(
|
||||
self, monkeypatch, caplog
|
||||
):
|
||||
# A feed that answered 404 is the fallback's; a channel whose rows failed to STORE is not —
|
||||
# the API would hand the same rows to the same broken write.
|
||||
seen = []
|
||||
|
||||
def fetch(cid):
|
||||
if cid == "UC_dark":
|
||||
raise rss.RssError("404", "http_404")
|
||||
return [{"id": "v"}]
|
||||
|
||||
def apply_(db, ch, entries):
|
||||
if ch.id == "UC_badrow":
|
||||
raise RuntimeError("constraint")
|
||||
return 1
|
||||
|
||||
def fallback(db, ids):
|
||||
seen.extend(ids)
|
||||
return {"read": 1, "new": 3, "failed": 0}
|
||||
|
||||
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
||||
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
||||
monkeypatch.setattr(runner, "run_feed_fallback", fallback)
|
||||
out = runner.run_rss_poll(
|
||||
_StubDb(), [_StubChannel(c) for c in ("UC_ok", "UC_dark", "UC_badrow")]
|
||||
)
|
||||
assert seen == ["UC_dark"]
|
||||
assert out == {"new": 4, "failed": 2, "fallback": 1}
|
||||
# ...and the one line the stored log keeps says so (an INFO line outside a request is not).
|
||||
(warning,) = [r for r in caplog.records if getattr(r, "event", None) == "sync.rss.failed"]
|
||||
assert warning.attrs["fallback_read"] == 1 and warning.attrs["fallback_new"] == 3
|
||||
|
||||
def test_no_channels_at_all_is_not_an_outage(self, monkeypatch):
|
||||
# A fresh instance with nothing subscribed must not report an error every poll.
|
||||
out = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0, channels=())
|
||||
assert out == {"new": 0, "failed": 0}
|
||||
assert out == {"new": 0, "failed": 0, "fallback": 0}
|
||||
|
||||
|
||||
class TestRssErrorVsEmpty:
|
||||
@@ -246,6 +289,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,38 @@
|
||||
"""A Google account without a YouTube channel: its `mine` playlists read answers 404
|
||||
`channelNotFound`. That is an empty list, not a failed sync — it came back as a warning every six
|
||||
hours on prod for an account that will never have a channel."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.youtube import client as yt
|
||||
|
||||
|
||||
def _client(monkeypatch, status: int, reason: str) -> yt.YouTubeClient:
|
||||
resp = httpx.Response(
|
||||
status,
|
||||
json={"error": {"code": status, "errors": [{"reason": reason, "message": "prose"}]}},
|
||||
request=httpx.Request("GET", f"{yt.API_BASE}/playlists"),
|
||||
)
|
||||
c = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub
|
||||
c.db = None
|
||||
monkeypatch.setattr(yt.sysconfig, "get_str", lambda db, key: "")
|
||||
monkeypatch.setattr(yt.quota, "record_usage", lambda cost: None)
|
||||
monkeypatch.setattr(c, "_access_token", lambda: "t")
|
||||
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
|
||||
return c
|
||||
|
||||
|
||||
def test_no_channel_is_an_empty_playlist_list_and_no_warning(monkeypatch, caplog):
|
||||
caplog.set_level(logging.DEBUG, logger="siftlode")
|
||||
assert list(_client(monkeypatch, 404, "channelNotFound").iter_my_playlists()) == []
|
||||
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
|
||||
|
||||
def test_any_other_failure_still_raises_and_warns(monkeypatch, caplog):
|
||||
caplog.set_level(logging.DEBUG, logger="siftlode")
|
||||
with pytest.raises(yt.YouTubeError):
|
||||
list(_client(monkeypatch, 403, "quotaExceeded").iter_my_playlists())
|
||||
assert [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
@@ -5,6 +5,7 @@ import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } f
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { displayName } from "../lib/api/account";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
@@ -101,12 +102,7 @@ export default function AccountPanel({
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={user.display_name ?? user.email.split("@")[0]}
|
||||
onClose={onClose}
|
||||
placement="drawer"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<Modal title={displayName(user)} onClose={onClose} placement="drawer" maxWidth="max-w-md">
|
||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
||||
|
||||
{q.isError && !a ? (
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
// @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("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");
|
||||
const depth = window.history.length;
|
||||
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(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy(),
|
||||
);
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((resolve) =>
|
||||
window.addEventListener("popstate", resolve, { once: true }),
|
||||
);
|
||||
window.history.back();
|
||||
await landed;
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
request_id: undefined,
|
||||
levels: ["ERROR", "CRITICAL"],
|
||||
q: "boom",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull();
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual([null, "error", "boom"]);
|
||||
// Neither jump waited out the typing debounce: no read ever mixed one view's filters with the
|
||||
// other's search text (the request with "boom", or the restored view without it).
|
||||
const mixed = logsMock.mock.calls.filter(([q]) => (q.request_id != null) === (q.q === "boom"));
|
||||
expect(mixed).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the account in use offered when the facets no longer count it", async () => {
|
||||
window.history.replaceState(null, "", "/?page=logs&lu=7");
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useEffect, 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 { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
|
||||
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 { urlWithParams } from "../lib/urlState";
|
||||
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);
|
||||
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the
|
||||
// search box's text then applies at once instead of after the typing debounce.
|
||||
const [jumps, setJumps] = useState(0);
|
||||
const q = useDebounced(state.q, 300, jumps);
|
||||
useUrlMirror(logsParamUpdates(state));
|
||||
// Back/Forward lands on another view of the console (a pivot pushed one, below): read it back.
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
setOpen(null);
|
||||
setJumps((n) => n + 1);
|
||||
setState(logsParamsToState(new URLSearchParams(window.location.search)));
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
const update = (patch: Partial<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;
|
||||
};
|
||||
|
||||
// A pivot follows a link to another view, so it is its own history entry and Back returns to the
|
||||
// view it was made from. The other filters are adjustments of the view on screen and replace it.
|
||||
const pivot = (patch: Pick<LogsUrlState, "requestId" | "jobId">) => {
|
||||
setLive(false);
|
||||
const next = { ...state, ...PIVOT_RESET, ...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
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type Me,
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { displayName } from "../lib/api/account";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { formatDateTime, relativeTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
@@ -234,7 +235,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{u.display_name ?? u.email.split("@")[0]}
|
||||
{displayName(u)}
|
||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||
</div>
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
|
||||
|
||||
import { type PlexCard, type Video } from "../lib/api";
|
||||
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
|
||||
import { isResettable } from "../lib/api/feed";
|
||||
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { type ContextMenuController } from "./ui/useContextMenu";
|
||||
|
||||
@@ -44,7 +47,63 @@ export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> })
|
||||
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
|
||||
}
|
||||
|
||||
/** The YouTube video lists (the feed grid, the Home rails). */
|
||||
export function VideoPinMenu({ menu }: { menu: ContextMenuController<Video> }) {
|
||||
return <ComfortPinMenu menu={menu} target={(v) => pinVideo(v.id)} title={(v) => v.title ?? ""} />;
|
||||
/** The feed's menu (the grid and a channel page): the card's own state actions — the hover row's
|
||||
* buttons, which a keyboard or a touch user otherwise reaches only by Tabbing through every card —
|
||||
* then the pin. Playlists and downloads stay on the card: each opens its own anchored dialog. */
|
||||
export function FeedVideoMenu({
|
||||
menu,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onResetState,
|
||||
}: {
|
||||
menu: ContextMenuController<Video>;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onResetState: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pin = useComfortPin(menu.open);
|
||||
const v = menu.target;
|
||||
const icon = "w-4 h-4";
|
||||
const items: ActionMenuItem[] = v
|
||||
? [
|
||||
v.status === "watched"
|
||||
? {
|
||||
id: "watched",
|
||||
label: t("card.menu.unmarkWatched"),
|
||||
icon: <Undo2 className={icon} />,
|
||||
onSelect: () => onState(v.id, "new"),
|
||||
}
|
||||
: {
|
||||
id: "watched",
|
||||
label: t("card.markWatched"),
|
||||
icon: <Check className={icon} />,
|
||||
onSelect: () => onState(v.id, "watched"),
|
||||
},
|
||||
{
|
||||
id: "save",
|
||||
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
|
||||
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
||||
onSelect: () => onToggleSave(v.id, !v.saved),
|
||||
},
|
||||
{
|
||||
id: "hide",
|
||||
label: v.status === "hidden" ? t("card.unhide") : t("card.hide"),
|
||||
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
||||
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"),
|
||||
},
|
||||
...(isResettable(v)
|
||||
? [
|
||||
{
|
||||
id: "reset",
|
||||
label: t("card.resetState"),
|
||||
icon: <RotateCcw className={icon} />,
|
||||
onSelect: () => onResetState(v.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...pin.item(pinVideo(v.id), v.title ?? ""),
|
||||
]
|
||||
: [];
|
||||
return <ContextMenu menu={menu} ariaLabel={t("card.menu.label")} items={items} />;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,14 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { count?: number }) =>
|
||||
opts?.count != null ? `${key}:${opts.count}` : key,
|
||||
t: (key: string, opts?: { count?: number; h?: number; m?: number; time?: string }) =>
|
||||
opts?.count != null
|
||||
? `${key}:${opts.count}`
|
||||
: opts?.m != null
|
||||
? `${key}:${opts.h ?? 0}h${opts.m}m`
|
||||
: opts?.time != null
|
||||
? `${key}:${opts.time}`
|
||||
: key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
@@ -106,6 +112,23 @@ describe("DiagnosticsSettings", () => {
|
||||
expect(setLevel).toHaveBeenLastCalledWith("INFO");
|
||||
});
|
||||
|
||||
it("counts the 24 hours from the moment the level was raised, not from when the page opened", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-09-19T02:54:00Z"));
|
||||
mount({});
|
||||
vi.setSystemTime(new Date("2026-09-19T02:56:30Z")); // the page sat open for 2.5 minutes
|
||||
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
|
||||
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
|
||||
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
|
||||
expect(screen.getByTestId("diagnostics-status").textContent).toContain(
|
||||
"settings.diagnostics.hoursMinutes:24h0m",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("going back to the base level needs no consent", async () => {
|
||||
const until = new Date(Date.now() + 3_600_000).toISOString();
|
||||
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
|
||||
|
||||
@@ -15,14 +15,17 @@ const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
|
||||
|
||||
/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
|
||||
function useRemaining(until: string | null): { h: number; m: number } | null {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
// The clock is read at render, and the interval only re-renders: a `now` held in state was the
|
||||
// moment the page MOUNTED, so a level raised two minutes after opening Settings read "24 h 2 m"
|
||||
// until the first tick (prod UAT 2026-09-19).
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!until) return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
const id = window.setInterval(() => tick((n) => n + 1), 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [until]);
|
||||
if (!until) return null;
|
||||
const minutes = Math.max(0, Math.ceil((Date.parse(until) - now) / 60_000));
|
||||
const minutes = Math.max(0, Math.ceil((Date.parse(until) - Date.now()) / 60_000));
|
||||
return { h: Math.floor(minutes / 60), m: minutes % 60 };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A tab reloading onto a newer build (lib/staleBuild) renders the chunk it could not load as a render
|
||||
// error: the boundary must neither report it as a crash (a fatal notice outlives the reload) nor flash
|
||||
// the crash screen before the reload lands. Its own file: staleBuild's "reloading" flag is module state
|
||||
// and never turns back off.
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
|
||||
const notify = vi.fn();
|
||||
vi.mock("../lib/notifications", () => ({ notify: (...args: unknown[]) => notify(...args) }));
|
||||
|
||||
import { clear, events } from "../lib/log";
|
||||
import { installStaleBuildReload } from "../lib/staleBuild";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("a missing chunk while reloading for a new build is no crash: nothing reported, nothing shown", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the throw itself too
|
||||
sessionStorage.clear();
|
||||
const reload = vi.fn();
|
||||
const uninstall = installStaleBuildReload(reload);
|
||||
window.dispatchEvent(new Event("vite:preloadError", { cancelable: true }));
|
||||
uninstall();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
|
||||
const Missing = () => {
|
||||
throw new Error("Failed to fetch dynamically imported module");
|
||||
};
|
||||
const { container } = render(
|
||||
<ErrorBoundary>
|
||||
<Missing />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(events().filter((e) => e.event === "ui.crash")).toEqual([]);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import i18n from "../i18n";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -20,6 +21,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
|
||||
// load, not a crash — no fatal notice, which would outlive the reload.
|
||||
if (reloadingForNewBuild()) return;
|
||||
log.fatal("ui.crash", "the app crashed while rendering", {
|
||||
...errorAttrs(error),
|
||||
...(info.componentStack ? { component_stack: info.componentStack } : {}),
|
||||
@@ -34,6 +38,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
render() {
|
||||
if (this.state.crashed) {
|
||||
if (reloadingForNewBuild()) return null;
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
|
||||
<div>
|
||||
|
||||
@@ -716,7 +716,9 @@ export default function Feed({
|
||||
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
|
||||
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
|
||||
applied"), but still get the right-pinned Sort/View. */}
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
|
||||
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{!channelScoped && (
|
||||
<>
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The feed card's right-click menu (prod UAT 2026-09-19): it carried only the comfort-shelf pin, so
|
||||
// the card's own state actions — watched, saved, hidden, reset — were reachable only by hovering or
|
||||
// by Tabbing through every card. Each row fires the same handler the card's button does.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { Video } 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" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, comfortPins: () => Promise.resolve({ items: [] }) } };
|
||||
});
|
||||
|
||||
import { FeedVideoMenu } from "./ComfortPinMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
|
||||
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const video = (extra: Partial<Video>): Video =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "A video",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
...extra,
|
||||
}) as Video;
|
||||
|
||||
type Handlers = {
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onResetState: (id: string) => void;
|
||||
};
|
||||
|
||||
function Feed({ v, handlers }: { v: Video; handlers: Handlers }) {
|
||||
const menu = useContextMenu<Video>();
|
||||
return (
|
||||
<>
|
||||
<div {...menu.triggerProps(v)}>card</div>
|
||||
<FeedVideoMenu
|
||||
menu={menu}
|
||||
onState={handlers.onState}
|
||||
onToggleSave={handlers.onToggleSave}
|
||||
onResetState={handlers.onResetState}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function openMenu(v: Video) {
|
||||
const handlers = { onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() };
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FloatingTree>
|
||||
<Feed v={v} handlers={handlers} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText("card"));
|
||||
return { handlers };
|
||||
}
|
||||
|
||||
describe("the feed card's menu", () => {
|
||||
it("offers the card's own actions on a fresh video, and each fires the card's handler", async () => {
|
||||
const { handlers } = openMenu(video({}));
|
||||
expect(labels()).toEqual([
|
||||
"card.markWatched",
|
||||
"card.saveForLater",
|
||||
"card.hide",
|
||||
"home.pin.add",
|
||||
]);
|
||||
fireEvent.click(item("card.markWatched"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched"));
|
||||
});
|
||||
|
||||
it("offers the way back on a watched, saved one — and the reset", async () => {
|
||||
const { handlers } = openMenu(video({ status: "watched", saved: true }));
|
||||
expect(labels()).toEqual([
|
||||
"card.menu.unmarkWatched",
|
||||
"card.menu.unsave",
|
||||
"card.hide",
|
||||
"card.resetState",
|
||||
"home.pin.add",
|
||||
]);
|
||||
fireEvent.click(item("card.menu.unsave"));
|
||||
await vi.waitFor(() => expect(handlers.onToggleSave).toHaveBeenLastCalledWith("v1", false));
|
||||
});
|
||||
|
||||
it("unhides a hidden one", async () => {
|
||||
const { handlers } = openMenu(video({ status: "hidden", position_seconds: 30 }));
|
||||
fireEvent.click(item("card.unhide"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
|
||||
});
|
||||
|
||||
it("clears the progress of a half-watched one", async () => {
|
||||
const { handlers } = openMenu(video({ position_seconds: 30 }));
|
||||
fireEvent.click(item("card.resetState"));
|
||||
await vi.waitFor(() => expect(handlers.onResetState).toHaveBeenLastCalledWith("v1"));
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,8 @@ function mountWithClient() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
|
||||
window.history.replaceState(null, "");
|
||||
discover.mockReset();
|
||||
discoverPick.mockReset();
|
||||
setPage.mockReset();
|
||||
@@ -553,11 +555,40 @@ describe("Home", () => {
|
||||
|
||||
first.unmount();
|
||||
discoverPick.mockClear();
|
||||
// A later visit is a new history entry (setPage pushes a fresh one); the same entry would bring
|
||||
// the rerolled question back on purpose (see the Back test below).
|
||||
window.history.pushState(null, "");
|
||||
mount();
|
||||
await screen.findByText("Hero v1");
|
||||
expect(discoverPick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Prod UAT 2026-09-19: a card opened from Home leaves the page, and Back came back to "Doesn't
|
||||
// matter" and a different hero. The question is kept on the entry, so Back asks it again.
|
||||
it("Back to the same entry brings back the budget and the rerolled hero", async () => {
|
||||
discoverPick.mockResolvedValue({
|
||||
budget: "short",
|
||||
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
|
||||
});
|
||||
const first = mount();
|
||||
await screen.findByText("Hero v1");
|
||||
fireEvent.click(screen.getByText("home.budget.short"));
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||
const asked = discoverPick.mock.lastCall;
|
||||
first.unmount();
|
||||
|
||||
discover.mockClear();
|
||||
discoverPick.mockClear();
|
||||
mount();
|
||||
|
||||
expect(await screen.findByText("Rerolled")).toBeTruthy();
|
||||
expect(discover).toHaveBeenLastCalledWith("short", 0);
|
||||
// The very same question — seed, the cards already seen and the press count all came back, not
|
||||
// just the budget: the server answers each of them differently.
|
||||
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked));
|
||||
});
|
||||
|
||||
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
|
||||
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
|
||||
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
|
||||
|
||||
@@ -149,6 +149,8 @@ const item = (label: string) => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
|
||||
window.history.replaceState(null, "");
|
||||
discover.mockReset().mockResolvedValue(payload());
|
||||
comfortPins.mockReset().mockResolvedValue({ items: shelf });
|
||||
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
|
||||
|
||||
@@ -348,26 +348,58 @@ function Hero({
|
||||
);
|
||||
}
|
||||
|
||||
const SAVED_KEY = "sfHome";
|
||||
|
||||
interface SavedQuestion {
|
||||
budget: DiscoverBudget;
|
||||
seed: number;
|
||||
press: number;
|
||||
seen: string[];
|
||||
}
|
||||
|
||||
/** The question this history entry was left with, if it was left from Home. Read from
|
||||
* `history.state`, which only this app writes — still checked field by field. */
|
||||
function readSavedQuestion(): SavedQuestion | null {
|
||||
const raw = (window.history.state as Record<string, unknown> | null)?.[SAVED_KEY] as
|
||||
| Partial<SavedQuestion>
|
||||
| undefined;
|
||||
if (!raw || !DISCOVER_BUDGETS.includes(raw.budget as DiscoverBudget)) return null;
|
||||
return {
|
||||
budget: raw.budget as DiscoverBudget,
|
||||
seed: Number.isInteger(raw.seed) ? (raw.seed as number) : 0,
|
||||
press: Number.isInteger(raw.press) ? (raw.press as number) : 0,
|
||||
seen: Array.isArray(raw.seen) ? raw.seen.filter((k) => typeof k === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { setPage } = useNavigationActions();
|
||||
const { setJumpTo } = usePlexActions();
|
||||
|
||||
const [budget, setBudget] = useState<DiscoverBudget>("any");
|
||||
// The question on screen lives on this page's history ENTRY as well: opening a card leaves Home
|
||||
// (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card
|
||||
// then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19).
|
||||
const [saved] = useState(readSavedQuestion);
|
||||
const [budget, setBudget] = useState<DiscoverBudget>(saved?.budget ?? "any");
|
||||
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
|
||||
// pressing the button does not refetch the rails under the user's cursor.
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [seed, setSeed] = useState(saved?.seed ?? 0);
|
||||
// Which press this is. It rotates the tier the server leads with, so "Mást kérek" offers a
|
||||
// different KIND of answer each time (where you left off → your shelf → a surprise) rather than
|
||||
// the next row of the same rail. Reset with the budget: a new question starts the rotation over.
|
||||
const [press, setPress] = useState(0);
|
||||
const [press, setPress] = useState(saved?.press ?? 0);
|
||||
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
|
||||
// the whole history: an unbounded list would eventually exclude everything and leave the button
|
||||
// with nothing to give, whereas a bounded one cannot. Its size is `SEEN_WINDOW` — see there for
|
||||
// why it is 32 and not the 8 this comment used to quote. Kept here (not in the URL or a pref)
|
||||
// because it is a property of this sitting.
|
||||
const [seen, setSeen] = useState<string[]>([]);
|
||||
const [seen, setSeen] = useState<string[]>(saved?.seen ?? []);
|
||||
useEffect(() => {
|
||||
const question: SavedQuestion = { budget, seed, press, seen };
|
||||
window.history.replaceState({ ...window.history.state, [SAVED_KEY]: question }, "");
|
||||
}, [budget, seed, press, seen]);
|
||||
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { displayName, roleKey } from "../lib/api/account";
|
||||
import { pinOnNextPage } from "../lib/activeAccount";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
@@ -156,15 +157,9 @@ export default function NavSidebar({
|
||||
|
||||
const rowBase =
|
||||
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
||||
const name = me.display_name ?? me.email.split("@")[0];
|
||||
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
|
||||
// real role (it's a flag on a `user` row), so surface it first.
|
||||
const roleKey: "admin" | "user" | "demo" = me.is_demo
|
||||
? "demo"
|
||||
: me.role === "admin"
|
||||
? "admin"
|
||||
: "user";
|
||||
const roleLabel = t(`nav.role.${roleKey}`);
|
||||
const name = displayName(me);
|
||||
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
|
||||
const roleLabel = t(`nav.role.${roleKey(me)}`);
|
||||
|
||||
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
|
||||
const active = page === p;
|
||||
@@ -323,7 +318,7 @@ export default function NavSidebar({
|
||||
/>
|
||||
<span className="min-w-0 text-left">
|
||||
<span className="block text-[0.8125rem] text-fg truncate">
|
||||
{a.display_name ?? a.email.split("@")[0]}
|
||||
{displayName(a)}
|
||||
</span>
|
||||
<span className="block text-2xs text-muted truncate">{a.email}</span>
|
||||
</span>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// bookkeeping.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the
|
||||
@@ -28,8 +29,14 @@ import { NavigationProvider } from "./NavigationProvider";
|
||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
// The provider reads the signed-in account (the tab title) from the `me` cache.
|
||||
let qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const render = () =>
|
||||
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||
@@ -54,6 +61,8 @@ async function openOverlay() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 1 });
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||
@@ -65,6 +74,22 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("NavigationProvider · the tab title", () => {
|
||||
// Prod UAT 2026-09-19: signed out, the tab read the Home page's question over the sign-in form.
|
||||
it("is the brand alone while nobody is signed in, the page's name once someone is", async () => {
|
||||
window.history.replaceState({ sfPage: "home" }, "", "/?page=home");
|
||||
qc.setQueryData(qk.me(), null);
|
||||
render();
|
||||
await settle();
|
||||
expect(document.title).toBe("Siftlode");
|
||||
|
||||
act(() => qc.setQueryData(qk.me(), { id: 1 }));
|
||||
await settle();
|
||||
expect(document.title).not.toBe("Siftlode");
|
||||
expect(document.title.endsWith(" · Siftlode")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
it("pushes an entry for a real page change", () => {
|
||||
const { result } = render();
|
||||
@@ -74,6 +99,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,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMe } from "../lib/useMe";
|
||||
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 +91,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
@@ -152,16 +155,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, [page]);
|
||||
|
||||
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
|
||||
// shows the channel name.
|
||||
// shows the channel name. Signed out, the page on screen is the public landing whatever `page`
|
||||
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
|
||||
const signedIn = useMe() != null;
|
||||
useEffect(() => {
|
||||
const brand = "Siftlode";
|
||||
const label = channelView
|
||||
? channelView.name || t("header.channelManager")
|
||||
: page === "feed"
|
||||
? null
|
||||
: t(pageTitleKey(page));
|
||||
const label = !signedIn
|
||||
? null
|
||||
: channelView
|
||||
? channelView.name || t("header.channelManager")
|
||||
: page === "feed"
|
||||
? null
|
||||
: t(pageTitleKey(page));
|
||||
document.title = label ? `${label} · ${brand}` : brand;
|
||||
}, [page, channelView, i18n.language, t]);
|
||||
}, [signedIn, page, channelView, i18n.language, t]);
|
||||
|
||||
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
|
||||
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
||||
|
||||
@@ -518,7 +518,7 @@ export default function PlexBrowse() {
|
||||
{/* The controls row sits directly above the grid: the applied-filter chips flow from the
|
||||
left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
|
||||
the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Settings → Account names the account's latest sign-in (prod UAT 2026-09-19: only the admin's user
|
||||
// list showed it), and its role in the reader's language — the raw `user` read in English under HU.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
import type { Me } 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?.time != null ? `${key}:${String(vars.time)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/format", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/format")>();
|
||||
return { ...actual, formatDateTime: (iso: string) => `at ${iso}` };
|
||||
});
|
||||
|
||||
import { AccountHeader } from "./SettingsPanel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const me = (extra: Partial<Me>): Me =>
|
||||
({
|
||||
email: "someone@example.com",
|
||||
display_name: "Someone",
|
||||
avatar_url: null,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
last_login_at: null,
|
||||
...extra,
|
||||
}) as Me;
|
||||
|
||||
describe("AccountHeader", () => {
|
||||
it("names the latest sign-in and the role in the reader's language", () => {
|
||||
render(<AccountHeader me={me({ last_login_at: "2026-09-19T02:00:00Z" })} />);
|
||||
expect(screen.getByTestId("account-last-login").textContent).toBe(
|
||||
"settings.account.lastLogin:at 2026-09-19T02:00:00Z",
|
||||
);
|
||||
expect(screen.getByText("nav.role.user")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says when none is recorded, and says nothing about it for the demo", () => {
|
||||
render(<AccountHeader me={me({})} />);
|
||||
expect(screen.getByTestId("account-last-login").textContent).toBe(
|
||||
"settings.account.lastLoginNone",
|
||||
);
|
||||
cleanup();
|
||||
render(<AccountHeader me={me({ is_demo: true })} />);
|
||||
expect(screen.queryByTestId("account-last-login")).toBeNull();
|
||||
expect(screen.getByText("nav.role.demo")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { withActiveAccount } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, HttpError, type Me } from "../lib/api";
|
||||
import { displayName, roleKey } from "../lib/api/account";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Avatar from "./ui/Avatar";
|
||||
@@ -778,6 +780,36 @@ export function DownloadAuth() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does
|
||||
* not recognise is something they can see. */
|
||||
export function AccountHeader({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={t("settings.account.title")}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Avatar
|
||||
src={me.avatar_url}
|
||||
fallback={me.display_name ?? me.email}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">{displayName(me)}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
|
||||
<div className="text-xs text-muted">{t(`nav.role.${roleKey(me)}`)}</div>
|
||||
{!me.is_demo && (
|
||||
<div className="text-xs text-muted" data-testid="account-last-login">
|
||||
{me.last_login_at
|
||||
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at) })
|
||||
: t("settings.account.lastLoginNone")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
@@ -801,22 +833,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Section title={t("settings.account.title")}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Avatar
|
||||
src={me.avatar_url}
|
||||
fallback={me.display_name ?? me.email}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">
|
||||
{me.display_name ?? me.email.split("@")[0]}
|
||||
</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
<div className="text-xs text-muted capitalize">{me.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<AccountHeader me={me} />
|
||||
|
||||
{!me.is_demo && <SignInMethods me={me} />}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import VideoInfoCard from "./VideoInfoCard";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { isResettable } from "../lib/api/feed";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
|
||||
import { isPlainLeftClick } from "../lib/nav";
|
||||
@@ -46,9 +47,8 @@ function Actions({
|
||||
e.stopPropagation();
|
||||
onState(video.id, video.status === status ? "new" : status);
|
||||
};
|
||||
// Pristine = never opened: default status and no resume position. The reset clears the
|
||||
// whole state (incl. an in-progress position the un-watch toggle can't touch).
|
||||
const resettable = video.status !== "new" || video.position_seconds > 0;
|
||||
// The reset clears the whole state (incl. an in-progress position the un-watch toggle can't touch).
|
||||
const resettable = isResettable(video);
|
||||
const toggleSave = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import VideoCard from "./VideoCard";
|
||||
import { VideoPinMenu } from "./ComfortPinMenu";
|
||||
import { FeedVideoMenu } from "./ComfortPinMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
|
||||
@@ -47,8 +47,8 @@ export default function VirtualFeed({
|
||||
fetchNextPage,
|
||||
}: VirtualFeedProps) {
|
||||
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
|
||||
// The comfort-shelf pin (E24 S3) lives HERE rather than in each of the feed's callers: this is the
|
||||
// list, and `useContextMenu`'s contract is one menu per list. Mounting it at this level is also
|
||||
// The card menu (its state actions and the comfort-shelf pin, E24 S3) lives HERE rather than in each
|
||||
// of the feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting it at this level is also
|
||||
// what gives the channel page the gesture for free — it renders `<Feed channelScoped>`, so it is
|
||||
// the same component all the way down.
|
||||
const pinMenu = useContextMenu<Video>();
|
||||
@@ -82,7 +82,12 @@ export default function VirtualFeed({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<VideoPinMenu menu={pinMenu} />
|
||||
<FeedVideoMenu
|
||||
menu={pinMenu}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ export default function LegalLayout({ title, children }: { title: string; childr
|
||||
const id = decodeURIComponent(window.location.hash.slice(1));
|
||||
if (id) document.getElementById(id)?.scrollIntoView();
|
||||
}, []);
|
||||
// The tab names the document, like every page of the app — a bookmark or a tab strip read just
|
||||
// "Siftlode" (prod UAT 2026-09-19).
|
||||
useEffect(() => {
|
||||
document.title = `${title} · Siftlode`;
|
||||
}, [title]);
|
||||
return (
|
||||
<div className="min-h-screen bg-bg text-fg">
|
||||
<div className="mx-auto w-[min(92vw,720px)] py-12">
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -111,11 +111,13 @@ export default function Toaster({
|
||||
// z-overlay, and catching the click — took play/pause, ±10, stop, prev/next and mute for as long
|
||||
// as a toast was up (the connection notice: until the server answered). The same rung UpNextCard
|
||||
// uses for the same reason. Clicks between the cards go through to what is underneath.
|
||||
// Its width is capped by its HOST (100%), not the viewport: it sits in the content column, right
|
||||
// of the sidebar, and a 100vw cap pushed it past the right edge of a phone (prod UAT 2026-09-19).
|
||||
<div
|
||||
// lib/listNav: focus on a toast's Undo is not the reader leaving the list the toast is about.
|
||||
data-toast-region=""
|
||||
aria-live={isTop && armed ? "polite" : "off"}
|
||||
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)] ${
|
||||
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100%-2rem)] ${
|
||||
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
|
||||
}`}
|
||||
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,10 @@
|
||||
"stream": "stream",
|
||||
"views": "views",
|
||||
"info": "Details",
|
||||
"noDescription": "No description"
|
||||
"noDescription": "No description",
|
||||
"menu": {
|
||||
"label": "Video",
|
||||
"unmarkWatched": "Mark unwatched",
|
||||
"unsave": "Remove from saved"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
"label": "Recent backfill — max age (days)",
|
||||
"hint": "How far back the first pass reaches per channel."
|
||||
},
|
||||
"rss_fallback_hours": {
|
||||
"label": "Feed fallback after (hours)",
|
||||
"hint": "When a channel's RSS feed has not answered for this many hours, its newest uploads are read through the API instead (1 unit), at most once per this many hours. 0 turns it off."
|
||||
},
|
||||
"shorts_probe_max_seconds": {
|
||||
"label": "Shorts probe — max duration (s)",
|
||||
"hint": "Only videos at or below this length are probed as possible Shorts."
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"page": {
|
||||
"title": "Downloads",
|
||||
"subtitle": "Download videos to the server, then save them to your device.",
|
||||
"addUrl": "Paste a YouTube link or video id…",
|
||||
"addUrl": "Paste a video link (YouTube, Facebook, …) or a YouTube video id…",
|
||||
"add": "Add",
|
||||
"paste": "Paste link from clipboard",
|
||||
"upload": "Upload",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"addAccount": "Add another account",
|
||||
"switchTo": "Switch to {{name}}",
|
||||
"audit": "Audit log",
|
||||
"logs": "Server log",
|
||||
"googleAddress": "Google account: {{email}}"
|
||||
},
|
||||
"sync": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"playlists_delete": "Delete playlist",
|
||||
"videos_backfill_recent": "Recent backfill",
|
||||
"videos_backfill_full": "Full-history backfill",
|
||||
"videos_feed_fallback": "Feed fallback (RSS down)",
|
||||
"videos_enrich": "Video enrichment",
|
||||
"videos_lookup": "Video lookup",
|
||||
"videos_search": "Live search",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"lastLogin": "Last sign-in: {{time}}",
|
||||
"lastLoginNone": "No sign-in recorded yet",
|
||||
"youtubeAccess": "YouTube access",
|
||||
"youtubeAccessIntro": "Sign-in only shares your name and email. YouTube access is granted separately, below — each is optional and revocable anytime in your Google Account.",
|
||||
"readTitle": "Read subscriptions (your Feed)",
|
||||
|
||||
@@ -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ó"
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,10 @@
|
||||
"stream": "stream",
|
||||
"views": "megtekintés",
|
||||
"info": "Részletek",
|
||||
"noDescription": "Nincs leírás"
|
||||
"noDescription": "Nincs leírás",
|
||||
"menu": {
|
||||
"label": "Videó",
|
||||
"unmarkWatched": "Megnézés visszavonása",
|
||||
"unsave": "Törlés a mentettek közül"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
"label": "Friss letöltés — max kor (nap)",
|
||||
"hint": "Az első kör csatornánként meddig nyúl vissza."
|
||||
},
|
||||
"rss_fallback_hours": {
|
||||
"label": "Feed-pótlás ennyi óra után",
|
||||
"hint": "Ha egy csatorna RSS-feedje ennyi órája nem válaszol, a legújabb feltöltéseit az API-n keresztül olvassuk be (1 egység), csatornánként legfeljebb ennyi óránként egyszer. 0 = kikapcsolva."
|
||||
},
|
||||
"shorts_probe_max_seconds": {
|
||||
"label": "Shorts-vizsgálat — max hossz (mp)",
|
||||
"hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként."
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"page": {
|
||||
"title": "Letöltések",
|
||||
"subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.",
|
||||
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||
"addUrl": "Illessz be egy videólinket (YouTube, Facebook, …) vagy YouTube-videóazonosítót…",
|
||||
"add": "Hozzáad",
|
||||
"paste": "Link beillesztése a vágólapról",
|
||||
"upload": "Feltöltés",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"playlists_delete": "Lejátszási lista törlése",
|
||||
"videos_backfill_recent": "Friss feltöltések betöltése",
|
||||
"videos_backfill_full": "Teljes előzmény betöltése",
|
||||
"videos_feed_fallback": "Feed-pótlás (RSS kiesés)",
|
||||
"videos_enrich": "Videó-gazdagítás",
|
||||
"videos_lookup": "Videó-lekérdezés",
|
||||
"videos_search": "Élő keresés",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
},
|
||||
"account": {
|
||||
"title": "Fiók",
|
||||
"lastLogin": "Utolsó belépés: {{time}}",
|
||||
"lastLoginNone": "Még nincs rögzített belépés",
|
||||
"youtubeAccess": "YouTube-hozzáférés",
|
||||
"youtubeAccessIntro": "A bejelentkezés csak a nevedet és e-mail-címedet osztja meg. A YouTube-hozzáférést külön kell megadni, lent — mindegyik opcionális és bármikor visszavonható a Google-fiókodban.",
|
||||
"readTitle": "Feliratkozások olvasása (a Feeded)",
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Me {
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
is_demo: boolean;
|
||||
/** The latest sign-in on any device (ISO), or null when none is recorded. */
|
||||
last_login_at: string | null;
|
||||
has_google: boolean;
|
||||
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
|
||||
* comparison, so a surface can render this whenever it is present. Someone who registered as
|
||||
@@ -33,6 +35,17 @@ export interface Me {
|
||||
diagnostics: DiagnosticsState;
|
||||
}
|
||||
|
||||
/** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user`
|
||||
* row), so it comes first. */
|
||||
export function roleKey(a: Pick<Me, "is_demo" | "role">): "admin" | "user" | "demo" {
|
||||
return a.is_demo ? "demo" : a.role === "admin" ? "admin" : "user";
|
||||
}
|
||||
|
||||
/** What an account is called on screen: its display name, else the local part of its email. */
|
||||
export function displayName(a: { display_name: string | null; email: string }): string {
|
||||
return a.display_name ?? a.email.split("@")[0]!;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
email: string;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, pinActiveAccount } from "../activeAccount";
|
||||
import { logRequest, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { newRequestId } from "../log";
|
||||
|
||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||
@@ -374,6 +374,7 @@ export function uploadReq<T = unknown>(
|
||||
status: xhr.status,
|
||||
// Test doubles may not implement it; a real XHR always does.
|
||||
route: xhr.getResponseHeader?.(ROUTE_HEADER),
|
||||
quiet: xhr.getResponseHeader?.(QUIET_HEADER) === "1",
|
||||
});
|
||||
if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req`
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface Video {
|
||||
watch_url: string;
|
||||
}
|
||||
|
||||
/** Whether a video has anything its reset would clear: pristine = never opened, the default status
|
||||
* and no resume position. The card's reset button and the card menu's reset both ask this. */
|
||||
export function isResettable(v: Pick<Video, "status" | "position_seconds">): boolean {
|
||||
return v.status !== "new" || v.position_seconds > 0;
|
||||
}
|
||||
|
||||
export interface VideoDetail {
|
||||
id: string;
|
||||
description: string | null;
|
||||
|
||||
@@ -170,6 +170,61 @@ describe("rememberNavPosition · the list it comes back to (deep-review E11 roun
|
||||
await waitFor(() => expect(focused()).toBe("a1"));
|
||||
});
|
||||
|
||||
// Home's Continue rail leaves out whatever the hero shows: back from a card, a different hero put a
|
||||
// card back in front of it, and by its place the focus landed on the neighbour (prod UAT 2026-09-19).
|
||||
it("finds the item by its name when the rail's contents moved", async () => {
|
||||
const Rail = ({ titles }: { titles: string[] }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true });
|
||||
return (
|
||||
<div ref={ref} aria-label="Continue watching">
|
||||
{titles.map((title, i) => (
|
||||
<div key={title} data-testid={title} data-nav-name={title} {...navItemAttrs(i)}>
|
||||
{title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const { unmount } = render(<Rail titles={["Knight Rider", "I Am Mother", "Dawnwalker"]} />);
|
||||
const stamped = leaveFrom("I Am Mother", 2);
|
||||
unmount();
|
||||
landOn(stamped);
|
||||
render(<Rail titles={["Harry Potter", "Knight Rider", "I Am Mother", "Dawnwalker"]} />);
|
||||
await waitFor(() => expect(focused()).toBe("I Am Mother"));
|
||||
});
|
||||
|
||||
// Names repeat — a log console's `http.request GET /api/feed 200` lines. The first of them is not
|
||||
// the one left: the saved place still holding the name wins, and when the list moved, the nearest.
|
||||
it("among items of one name, takes the saved place or the nearest", async () => {
|
||||
const Lines = ({ names }: { names: string[] }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true });
|
||||
return (
|
||||
<div ref={ref} aria-label="Lines">
|
||||
{names.map((name, i) => (
|
||||
<div key={i} data-testid={`l${i}`} data-nav-name={name} {...navItemAttrs(i)}>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const names = ["GET 200", "POST 200", "POST 200", "GET 200", "POST 200"];
|
||||
const first = render(<Lines names={names} />);
|
||||
const stamped = leaveFrom("l3", 4);
|
||||
first.unmount();
|
||||
landOn(stamped);
|
||||
const back = render(<Lines names={names} />);
|
||||
await waitFor(() => expect(focused()).toBe("l3"));
|
||||
back.unmount();
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
landOn(stamped);
|
||||
// A line arrived above: the left one is l4 now, a GET two places up is l1.
|
||||
render(<Lines names={["new", ...names]} />);
|
||||
await waitFor(() => expect(focused()).toBe("l4"));
|
||||
});
|
||||
|
||||
it("gives up on an unnamed list when the page came back with another number of lists", async () => {
|
||||
const { unmount } = render(<Page />);
|
||||
const stamped = leaveFrom("a1", 2);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { pathTemplate, send } from "./http";
|
||||
import { clear, events } from "./log";
|
||||
import { clear, events, setLevel } from "./log";
|
||||
|
||||
afterEach(() => {
|
||||
setLevel("INFO");
|
||||
clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -41,6 +42,18 @@ describe("send", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// The server logs the reads the app polls at DEBUG and says so (`X-Log-Quiet`); at INFO the
|
||||
// browser's copies filled My log with unread-count polls within minutes (prod UAT 2026-09-19).
|
||||
it("logs a read the server called quiet at DEBUG, so the base level keeps it out", async () => {
|
||||
vi.stubGlobal("fetch", async () => answer(200, { "X-Log-Quiet": "1" }));
|
||||
await send("/api/messages/unread_count");
|
||||
expect(events()).toEqual([]);
|
||||
|
||||
setLevel("DEBUG");
|
||||
await send("/api/messages/unread_count");
|
||||
expect(events().map((e) => e.level)).toEqual(["DEBUG"]);
|
||||
});
|
||||
|
||||
it("logs a network failure without the concrete path, and still rejects", async () => {
|
||||
vi.stubGlobal("fetch", async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
|
||||
@@ -12,6 +12,9 @@ import { log, newRequestId, type Level } from "./log";
|
||||
* Grouping by it is what makes a client line comparable, and it is safe by construction: a token in
|
||||
* the path (`/api/public/watch/{token}`) arrives already replaced by its parameter name. */
|
||||
export const ROUTE_HEADER = "X-Route";
|
||||
/** Set when the server logged this request at DEBUG (a polled read, the log console's own reads): the
|
||||
* browser's copy of the line goes to DEBUG too, from the server's one list (`logs._POLLED_ROUTES`). */
|
||||
export const QUIET_HEADER = "X-Log-Quiet";
|
||||
export const REQUEST_ID_HEADER = "X-Request-Id";
|
||||
|
||||
// A static path word: kept. Anything else (an id, a channel id, a share token) becomes `{}`.
|
||||
@@ -29,9 +32,9 @@ export function pathTemplate(url: string): string {
|
||||
}
|
||||
|
||||
/** Level of a request line — the server's own rule (`logs.log_requests`), so both sides agree on
|
||||
* what a 404 is. */
|
||||
function statusLevel(status: number): Level {
|
||||
return status >= 500 ? "ERROR" : status >= 400 ? "WARNING" : "INFO";
|
||||
* what a 404 is, and on which successes are too routine for INFO (`quiet`, the server's say). */
|
||||
function statusLevel(status: number, quiet: boolean): Level {
|
||||
return status >= 500 ? "ERROR" : status >= 400 ? "WARNING" : quiet ? "DEBUG" : "INFO";
|
||||
}
|
||||
|
||||
/** Record one request's outcome. `route` is the server's template when it sent one. */
|
||||
@@ -40,7 +43,9 @@ export function logRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
started: number,
|
||||
outcome: { status: number; route?: string | null } | { failed: "network" | "aborted" },
|
||||
outcome:
|
||||
| { status: number; route?: string | null; quiet?: boolean }
|
||||
| { failed: "network" | "aborted" },
|
||||
extra: Record<string, unknown> = {},
|
||||
): void {
|
||||
const duration_ms = Math.round((performance.now() - started) * 10) / 10;
|
||||
@@ -56,7 +61,7 @@ export function logRequest(
|
||||
}
|
||||
const path = outcome.route || pathTemplate(url);
|
||||
log.request(
|
||||
statusLevel(outcome.status),
|
||||
statusLevel(outcome.status, outcome.quiet === true),
|
||||
`${method} ${path} ${outcome.status}`,
|
||||
{ method, path, status: outcome.status, duration_ms, ...extra },
|
||||
requestId,
|
||||
@@ -92,7 +97,11 @@ export async function send(
|
||||
method,
|
||||
url,
|
||||
started,
|
||||
{ status: r.status, route: headerOf(r, ROUTE_HEADER) },
|
||||
{
|
||||
status: r.status,
|
||||
route: headerOf(r, ROUTE_HEADER),
|
||||
quiet: headerOf(r, QUIET_HEADER) === "1",
|
||||
},
|
||||
extra,
|
||||
);
|
||||
return r;
|
||||
|
||||
+45
-12
@@ -164,6 +164,18 @@ function slotOfRemoved(delta: 1 | -1): { list: NavList; index: number } | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** What an item is called: its own name when it gives one (`navItemAttrs`), else the open element's
|
||||
* label or text — a title link's text is the title (deep-review E11 round 3). */
|
||||
function itemName(el: HTMLElement): string {
|
||||
const open = el.querySelector("[data-nav-open]");
|
||||
return (
|
||||
el.getAttribute("data-nav-name") ||
|
||||
open?.getAttribute("aria-label") ||
|
||||
open?.textContent ||
|
||||
""
|
||||
).trim();
|
||||
}
|
||||
|
||||
function focusItem(el: HTMLElement, reveal = true): void {
|
||||
if (roved !== el) {
|
||||
if (named) roved?.removeAttribute("role");
|
||||
@@ -179,15 +191,7 @@ function focusItem(el: HTMLElement, reveal = true): void {
|
||||
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
|
||||
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
|
||||
// opens (deep-review E11 round 2). A row (<tr>) keeps its own role.
|
||||
// The item's own name when it gives one (`navItemAttrs`), else the open element's label or text:
|
||||
// a title link's text is the title (deep-review E11 round 3).
|
||||
const open = el.querySelector("[data-nav-open]");
|
||||
const name = (
|
||||
el.getAttribute("data-nav-name") ||
|
||||
open?.getAttribute("aria-label") ||
|
||||
open?.textContent ||
|
||||
""
|
||||
).trim();
|
||||
const name = itemName(el);
|
||||
if (
|
||||
el.tagName === "DIV" &&
|
||||
name &&
|
||||
@@ -321,12 +325,14 @@ function onKey(e: KeyboardEvent): void {
|
||||
// on screen again. An overlay (the YouTube player) never unmounts the list; it restores focus itself.
|
||||
|
||||
/** Where focus sits in the page's lists: the list's place in reading order, how many lists the page
|
||||
* had on screen, the list's accessible name when it has one (a rail's title), and the item's index. */
|
||||
* had on screen, the list's accessible name when it has one (a rail's title), the item's index, and
|
||||
* the item's own name. */
|
||||
export interface NavPosition {
|
||||
readonly list: number;
|
||||
readonly lists: number;
|
||||
readonly label?: string | null;
|
||||
readonly index: number;
|
||||
readonly name?: string | null;
|
||||
}
|
||||
|
||||
const labelOf = (list: NavList): string | null => list.el.getAttribute("aria-label");
|
||||
@@ -336,7 +342,15 @@ export function navPosition(): NavPosition | null {
|
||||
if (!cur) return null;
|
||||
const all = ordered();
|
||||
const list = all.indexOf(cur.list);
|
||||
return list < 0 ? null : { list, lists: all.length, label: labelOf(cur.list), index: cur.index };
|
||||
return list < 0
|
||||
? null
|
||||
: {
|
||||
list,
|
||||
lists: all.length,
|
||||
label: labelOf(cur.list),
|
||||
index: cur.index,
|
||||
name: itemName(cur.item) || null,
|
||||
};
|
||||
}
|
||||
|
||||
// How long a returning page gets to put its lists back (a lazy chunk, cached data), and how often we
|
||||
@@ -344,6 +358,19 @@ export function navPosition(): NavPosition | null {
|
||||
const RESTORE_MS = 2000;
|
||||
const RESTORE_POLL_MS = 50;
|
||||
|
||||
/** The item in `list` to hand the focus back to: the saved place while it still holds the saved
|
||||
* name, else the nearest item of that name (names repeat — a log console's identical request
|
||||
* lines), else the saved place. */
|
||||
function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
|
||||
const at = itemAt(list, pos.index);
|
||||
if (!pos.name || (at && itemName(at) === pos.name)) return at;
|
||||
const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
|
||||
const named = ownItems(list.el)
|
||||
.filter((it) => itemName(it) === pos.name)
|
||||
.sort((a, b) => distance(a) - distance(b));
|
||||
return named[0] ?? at;
|
||||
}
|
||||
|
||||
/** Focus the item at `pos` once its list is on screen again. A NAMED list (a rail) is found by its
|
||||
* name: a rail that appeared above it in the meantime — "Continue watching", filled by the very title
|
||||
* watched in between, after the page's cache expired — shifts every place after it, and matching by
|
||||
@@ -364,7 +391,13 @@ export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
: all.length === pos.lists
|
||||
? all[pos.list]
|
||||
: undefined;
|
||||
const el = list ? itemAt(list, pos.index) : null;
|
||||
// The item by its NAME first, its place second: a rail's contents can shift while the page was
|
||||
// away — Home's Continue rail leaves out whatever the hero shows, so a different hero moved every
|
||||
// card after it one place and the focus landed on the neighbour (prod UAT 2026-09-19).
|
||||
// The saved place still holding the saved name wins, and among several items of that name the
|
||||
// nearest one: names repeat (a log console's `http.request GET /api/feed 200` lines, two
|
||||
// downloads of one video), and the first of them is rarely the one left behind.
|
||||
const el = list ? restoreTarget(list, pos) : null;
|
||||
if (el) return focusItem(el, false);
|
||||
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ const EXPECTED: Record<Page, string> = {
|
||||
config: "header.configuration",
|
||||
users: "header.users",
|
||||
audit: "audit.title",
|
||||
logs: "adminLogs.title",
|
||||
settings: "settings.title",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,51 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.91.0",
|
||||
date: "2026-09-19",
|
||||
summary:
|
||||
"What a test run on the live instance turned up, fixed: new videos keep arriving while YouTube's feeds are down, an open tab survives a release, and Back returns to what you left.",
|
||||
features: [
|
||||
"A card's menu (right-click or long press) now offers the card's own actions — watched, save, hide, reset — beside the comfort-shelf pin.",
|
||||
"Settings → Account shows your role and your last sign-in. 0.88 announced the last sign-in in your account panel; it is actually there now.",
|
||||
],
|
||||
fixes: [
|
||||
"When YouTube's feed for a channel stays dark for hours, as it did for 393 of 396 channels on 18–19 September, the channel's newest uploads are read through the YouTube API instead, at most once every 2 hours per channel (adjustable by an administrator, 0 turns it off).",
|
||||
"A tab left open across a release no longer falls to the crash screen when it opens something it had not loaded yet: it reloads onto the new version once.",
|
||||
"Back from a request's lines in the Server log returns to the view the pivot was made from, search text included, as 0.90 promised.",
|
||||
"Back to Home keeps the question you asked (the time budget and the hero you rerolled to), and the keyboard lands on the card you opened, even when the rail's cards moved or several share a name.",
|
||||
"Browser tabs are named after the page again: signed out, and on the privacy and terms pages.",
|
||||
"On a phone, the feed and Plex toolbars wrap instead of running past the edge, and so do notifications.",
|
||||
"The countdown of a raised diagnostics level counts from the moment it was raised, not from when the page was opened.",
|
||||
"The download box's placeholder names every site it takes, Facebook included.",
|
||||
],
|
||||
chores: [
|
||||
"The screens' own polling reads (the unread badges, the download list, the scheduler) are logged at debug level in the server's log and in the browser's copy of it, where they made up about 95% of the lines.",
|
||||
"An account with no YouTube channel no longer writes a warning every six hours, and a scheduler job that fails carries an event code.",
|
||||
"Two adversarial review rounds: 15 findings raised, 11 survived, all fixed, and every regression test was proven to fail with its own fix removed.",
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fire = (): Event => {
|
||||
const e = new Event("vite:preloadError", { cancelable: true });
|
||||
window.dispatchEvent(e);
|
||||
return e;
|
||||
};
|
||||
|
||||
let uninstall = () => {};
|
||||
|
||||
describe("a lazy chunk the server no longer has", () => {
|
||||
afterEach(() => uninstall());
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("reloads the tab once, and says so to the error boundary", async () => {
|
||||
const { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild");
|
||||
const reload = vi.fn();
|
||||
uninstall = installStaleBuildReload(reload);
|
||||
|
||||
const e = fire();
|
||||
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(reloadingForNewBuild()).toBe(true);
|
||||
// The rest of the page's failed imports while the reload is under way: swallowed, no second reload.
|
||||
expect(fire().defaultPrevented).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the error through when the reload a moment ago did not help", async () => {
|
||||
sessionStorage.setItem("sf.staleBuildReload", String(Date.now() - 5_000));
|
||||
const { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild");
|
||||
const reload = vi.fn();
|
||||
uninstall = installStaleBuildReload(reload);
|
||||
|
||||
expect(fire().defaultPrevented).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(reloadingForNewBuild()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// A tab left open across a release still runs the old build, and the old build's lazy chunks are gone
|
||||
// from the server the moment the new one is deployed: its first lazy import (the download dialog, a
|
||||
// module it had not opened yet) answers 404, and the whole app fell to the crash screen. Measured on
|
||||
// prod 2026-09-19 — `GET /assets/DownloadDialog-….js 404` at 00:59, an hour after 0.89 went out.
|
||||
//
|
||||
// Vite announces such a failure as `vite:preloadError` (for the import itself as well as for its
|
||||
// preloaded dependencies); cancelling it and reloading puts the tab on the current build. Once per
|
||||
// minute at most: a chunk that is missing from the NEW build too would otherwise reload forever, so
|
||||
// the second failure inside the minute is let through to the error boundary, which says so.
|
||||
import { log } from "./log";
|
||||
|
||||
const KEY = "sf.staleBuildReload";
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
let reloading = false;
|
||||
|
||||
/** True once a reload for a newer build is under way: whatever fails to render in the meantime is
|
||||
* that missing chunk, and the error boundary must not report it as a crash. */
|
||||
export function reloadingForNewBuild(): boolean {
|
||||
return reloading;
|
||||
}
|
||||
|
||||
function lastReload(): number {
|
||||
try {
|
||||
return Number(sessionStorage.getItem(KEY) ?? 0) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the uninstall (for tests: the listener is on `window`, which outlives a module reset). */
|
||||
export function installStaleBuildReload(
|
||||
reload: () => void = () => window.location.reload(),
|
||||
): () => void {
|
||||
const onError = (event: Event) => {
|
||||
if (reloading) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastReload() < WINDOW_MS) return;
|
||||
try {
|
||||
sessionStorage.setItem(KEY, String(Date.now()));
|
||||
} catch {
|
||||
// No storage (a private window): without the marker a reload could repeat, so don't.
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
reloading = true;
|
||||
log.warn("app.stale_build", "a chunk of this build is gone from the server; reloading");
|
||||
reload();
|
||||
};
|
||||
window.addEventListener("vite:preloadError", onError);
|
||||
return () => window.removeEventListener("vite:preloadError", onError);
|
||||
}
|
||||
@@ -129,6 +129,7 @@ const PAGES = [
|
||||
"config",
|
||||
"users",
|
||||
"audit",
|
||||
"logs",
|
||||
"notifications",
|
||||
"messages",
|
||||
"downloads",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
import { useDebounced } from "./useDebounced";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("useDebounced", () => {
|
||||
it("hands a value on once it stopped changing for the delay", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result, rerender } = renderHook(({ v }) => useDebounced(v, 300), {
|
||||
initialProps: { v: "a" },
|
||||
});
|
||||
rerender({ v: "ab" });
|
||||
expect(result.current).toBe("a");
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(result.current).toBe("ab");
|
||||
});
|
||||
|
||||
it("takes the value at once when `settle` changes — a jump, not typing", () => {
|
||||
vi.useFakeTimers();
|
||||
const { result, rerender } = renderHook(({ v, s }) => useDebounced(v, 300, s), {
|
||||
initialProps: { v: "", s: 0 },
|
||||
});
|
||||
rerender({ v: "boom", s: 1 });
|
||||
expect(result.current).toBe("boom");
|
||||
rerender({ v: "boomx", s: 1 });
|
||||
expect(result.current).toBe("boom");
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(result.current).toBe("boomx");
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,21 @@ import { useEffect, useState } from "react";
|
||||
// Returns `value` after it has stopped changing for `delay` ms. Lets a text input stay
|
||||
// responsive (it owns the immediate value) while the query it drives only re-runs once the
|
||||
// user pauses — so we don't refetch the feed on every keystroke.
|
||||
export function useDebounced<T>(value: T, delay = 300): T {
|
||||
//
|
||||
// `settle`: a change of it takes `value` at once. A value that did not come from typing (Back
|
||||
// restoring a search, a pivot clearing one) has no keystrokes to wait out, and the lag only ran a
|
||||
// query mixing the new filters with the old text (deep-review 2026-09-19).
|
||||
export function useDebounced<T>(value: T, delay = 300, settle?: unknown): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
const [settled, setSettled] = useState(settle);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
if (settle !== settled) {
|
||||
setSettled(settle);
|
||||
setDebounced(value);
|
||||
return value;
|
||||
}
|
||||
return debounced;
|
||||
}
|
||||
|
||||
+45
-40
@@ -21,10 +21,13 @@ import "./index.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||
import { installClientLog } from "./lib/logHooks";
|
||||
import { installStaleBuildReload } from "./lib/staleBuild";
|
||||
import { appCopyOf } from "./i18n/appCopy";
|
||||
|
||||
// First, before any render: a crash or a navigation during boot belongs in the log too.
|
||||
installClientLog(appCopyOf);
|
||||
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
|
||||
installStaleBuildReload();
|
||||
|
||||
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
|
||||
// legal page never downloads the authenticated app bundle, and the app never carries them either.
|
||||
@@ -63,50 +66,52 @@ const root =
|
||||
</FloatingTree>
|
||||
) : (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
|
||||
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
||||
<FloatingTree>
|
||||
<ConfirmProvider>
|
||||
{/* Nothing below renders until the tab knows which account it is: every provider
|
||||
seeds per-account state in its initializer (see BootGate). */}
|
||||
<BootGate>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<PlexViewBridge>
|
||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
||||
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||
<SavedViewsProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||
loading when the gate opens continues it instead of blanking. */}
|
||||
<Suspense fallback={<BootScreen />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</SavedViewsProvider>
|
||||
</PlexViewBridge>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</BootGate>
|
||||
</ConfirmProvider>
|
||||
</FloatingTree>
|
||||
</ErrorBoundary>
|
||||
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
|
||||
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
||||
<FloatingTree>
|
||||
<ConfirmProvider>
|
||||
{/* Nothing below renders until the tab knows which account it is: every provider
|
||||
seeds per-account state in its initializer (see BootGate). */}
|
||||
<BootGate>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<PlexViewBridge>
|
||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
||||
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||
<SavedViewsProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||
loading when the gate opens continues it instead of blanking. */}
|
||||
<Suspense fallback={<BootScreen />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</SavedViewsProvider>
|
||||
</PlexViewBridge>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</BootGate>
|
||||
</ConfirmProvider>
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>{root}</Suspense>
|
||||
{/* Around EVERY entry, the public ones too: a crash on a shared /watch link, or its chunk gone
|
||||
twice over after a release (lib/staleBuild), left a blank white page with no way on. */}
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>{root}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user