feat: Settings → Diagnostics, a consented log level and the tab's own log
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a new Settings tab, after a consent box that says what is recorded, that the server keeps it and for how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent. - users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force. - The server logs an elevated user's requests at that level and stores every line of them (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation is active). Teljes adds the redacted query string to the request line. - POST /api/diagnostics/events stores the browser's log as source=web rows: account from the session, body capped while streaming, events/attrs capped, re-redacted, events under the consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed. - lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its own timer), on withdrawal and on 409. - Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user text, input values or a link's query), visibility/bfcache/resize, filter changes (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are warnings at every level). Offline/online at the base level. - "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy the filtered events as JSON lines, clear. - Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to the same sections); the policy now describes the server logs and diagnostic logging, self-service deletion and password sign-in. Fixed on the way: - A sync auth dependency ran logs.set_user on a copied context, so the account never reached a handler's own log lines (measured); a per-request mutable scope carries it now. - The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is a scrolling row above the content. - PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
This commit is contained in:
@@ -58,7 +58,7 @@ how it gets built. Concretely:
|
|||||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||||
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
||||||
`security`, `logs`, `logsink`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
`security`, `logs`, `logsink`, `diagnostics`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
||||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||||
@@ -96,7 +96,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||||
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
||||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`) | `console.*` (lint-enforced) |
|
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||||
|
|
||||||
## House rules that bite (learned from real bugs)
|
## House rules that bite (learned from real bugs)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add users.diagnostics_level + diagnostics_until — a user's consented diagnostics level (E22 S5)
|
||||||
|
|
||||||
|
Revision ID: 0087_users_diagnostics_level
|
||||||
|
Revises: 0086_log_events
|
||||||
|
Create Date: 2026-09-17
|
||||||
|
|
||||||
|
Both NULL = the base level (Alap). A raised level is ALWAYS paired with its expiry, computed by the
|
||||||
|
server at consent time (`app/diagnostics.py`), so no client can ask for a level that never ends.
|
||||||
|
Columns rather than `users.preferences`: that JSON is written wholesale by the client, and a consent
|
||||||
|
with a lifetime must not be something a client can write.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0087_users_diagnostics_level"
|
||||||
|
down_revision: str | None = "0086_log_events"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("diagnostics_level", sa.String(8), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"users", sa.Column("diagnostics_until", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "diagnostics_until")
|
||||||
|
op.drop_column("users", "diagnostics_level")
|
||||||
@@ -21,9 +21,9 @@ Three layers, by *who* runs the code — not by feature:
|
|||||||
|
|
||||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
||||||
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
|
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
|
||||||
configuration) and `logsink` (its `log_events` table), … are single-file primitives many
|
configuration), `logsink` (its `log_events` table) and `diagnostics` (a user's consented log
|
||||||
layers import. They depend on nothing above them (a core module must never import a `routes/…`
|
level), … are single-file primitives many layers import. They depend on nothing above them (a core
|
||||||
module).
|
module must never import a `routes/…` module).
|
||||||
|
|
||||||
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
|
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
|
||||||
*second* caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down
|
*second* caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down
|
||||||
|
|||||||
+4
-2
@@ -6,6 +6,7 @@ from sqlalchemy import delete, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
|
from app import diagnostics
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app import logs
|
from app import logs
|
||||||
from app import mailtext
|
from app import mailtext
|
||||||
@@ -355,8 +356,9 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
|
|||||||
# Attribute this request's log lines to the account (E22). Here, not in the request middleware:
|
# Attribute this request's log lines to the account (E22). Here, not in the request middleware:
|
||||||
# this is the one place a session becomes an account, and the middleware runs outside both the
|
# this is the one place a session becomes an account, and the middleware runs outside both the
|
||||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||||
# (it lives on the ASGI scope), so the access-log line can read it back.
|
# (it lives on the ASGI scope), so the access-log line can read it back. The user's consented
|
||||||
logs.set_user(user.id)
|
# diagnostics level rides along: their requests are logged — and stored — in that detail (S5).
|
||||||
|
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
|
||||||
request.state.user_id = user.id
|
request.state.user_id = user.id
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""A user's diagnostics level (E22 S5): what they consented to, and for how long.
|
||||||
|
|
||||||
|
Three levels, named in the UI Alap / Részletes / Teljes:
|
||||||
|
|
||||||
|
* INFO — the base, for everyone, no consent. The browser keeps its log in memory; nothing extra
|
||||||
|
is sent or stored.
|
||||||
|
* DEBUG — consented. The server logs this user's requests in detail and stores every line of
|
||||||
|
them, and the browser streams its own log to `POST /api/diagnostics/events`.
|
||||||
|
* TRACE — consented. As DEBUG, plus what the user typed into a search.
|
||||||
|
|
||||||
|
A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time,
|
||||||
|
never taken from the client, and an expired level reads as INFO without anything having to clear it.
|
||||||
|
The shared demo account cannot raise its level — its log would mix every visitor's session.
|
||||||
|
|
||||||
|
Core layer: imports `models` and `sysconfig`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Literal, get_args
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
|
||||||
|
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
|
||||||
|
BASE: DiagnosticsLevel = "INFO"
|
||||||
|
ELEVATION_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
|
||||||
|
"""The level in force for `user` right now: the consented one until it expires, else BASE."""
|
||||||
|
level, until = user.diagnostics_level, user.diagnostics_until
|
||||||
|
if level not in LEVELS or level == BASE or until is None:
|
||||||
|
return BASE
|
||||||
|
if until <= (now or datetime.now(timezone.utc)):
|
||||||
|
return BASE
|
||||||
|
return level # type: ignore[return-value] — narrowed by the LEVELS check above
|
||||||
|
|
||||||
|
|
||||||
|
def state(db: Session, user: User) -> dict:
|
||||||
|
"""The `/api/me` view of it: the level in force, when a raised one lapses, and how long stored
|
||||||
|
log lines are kept — the consent text states that number, so it must be the instance's own."""
|
||||||
|
level = effective_level(user)
|
||||||
|
return {
|
||||||
|
"level": level,
|
||||||
|
"until": user.diagnostics_until.isoformat() if level != BASE else None,
|
||||||
|
"retention_days": sysconfig.get_int(db, "log_retention_days"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_level(user: User, level: DiagnosticsLevel) -> None:
|
||||||
|
"""Record a consent (or its withdrawal). The caller commits. Raising the level again restarts
|
||||||
|
the clock — each consent is for the next ELEVATION_HOURS, not an extension of an older one."""
|
||||||
|
if level == BASE:
|
||||||
|
user.diagnostics_level = None
|
||||||
|
user.diagnostics_until = None
|
||||||
|
return
|
||||||
|
user.diagnostics_level = level
|
||||||
|
user.diagnostics_until = datetime.now(timezone.utc) + timedelta(hours=ELEVATION_HOURS)
|
||||||
+131
-19
@@ -35,6 +35,7 @@ import logging
|
|||||||
import logging.config
|
import logging.config
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -86,11 +87,44 @@ _CONTEXT_VARS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def set_user(user_id: int | None) -> None:
|
class _RequestScope:
|
||||||
"""Attribute the rest of this request's log lines to `user_id`. Called from the auth dependency
|
"""What the auth dependency learns about a request, for every line logged after it.
|
||||||
— the one place the session is actually resolved into an account — rather than from the request
|
|
||||||
middleware, which runs OUTSIDE the session middleware and in a different task context."""
|
A MUTABLE object behind a contextvar, not plain contextvars, because of how a request actually
|
||||||
|
runs: FastAPI resolves a sync dependency (`auth.current_user`) in a threadpool on a COPY of the
|
||||||
|
context, so a `ContextVar.set()` there is gone by the time the handler runs — measured: that is
|
||||||
|
how the account never reached a handler's own lines. `log_requests` installs one scope per
|
||||||
|
request; every copied context shares the same object, so what the dependency writes is seen by
|
||||||
|
the handler and by the request line itself."""
|
||||||
|
|
||||||
|
__slots__ = ("user_id", "level")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.user_id: int | None = None
|
||||||
|
# The user's consented diagnostics level as a logging number; None at the base level.
|
||||||
|
self.level: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
|
||||||
|
"log_request_scope", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = None) -> None:
|
||||||
|
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
|
||||||
|
consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the
|
||||||
|
auth dependency — the one place the session is actually resolved into an account — rather than
|
||||||
|
from the request middleware, which runs OUTSIDE the session middleware."""
|
||||||
_user_id.set(user_id)
|
_user_id.set(user_id)
|
||||||
|
scope = _scope.get()
|
||||||
|
if scope is None: # outside log_requests (a script, a unit test): this context only
|
||||||
|
scope = _RequestScope()
|
||||||
|
_scope.set(scope)
|
||||||
|
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
|
||||||
|
scope.user_id = user_id
|
||||||
|
scope.level = resolve_level(level) if elevated else None
|
||||||
|
if user_id is not None:
|
||||||
|
_track_elevation(user_id, scope.level, until)
|
||||||
|
|
||||||
|
|
||||||
def set_job(job_id: int | None) -> None:
|
def set_job(job_id: int | None) -> None:
|
||||||
@@ -193,7 +227,7 @@ def redact(value: Any, *, key: str = "") -> Any:
|
|||||||
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
|
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
|
||||||
_RECORD_STANDARD = frozenset(
|
_RECORD_STANDARD = frozenset(
|
||||||
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
|
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
|
||||||
| {"message", "asctime", "taskName", "event", "attrs", PERSIST}
|
| {"message", "asctime", "taskName", "event", "attrs", PERSIST, "elevated"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -207,9 +241,31 @@ class ContextFilter(logging.Filter):
|
|||||||
value = var.get()
|
value = var.get()
|
||||||
if value is not None and not hasattr(record, name):
|
if value is not None and not hasattr(record, name):
|
||||||
setattr(record, name, value)
|
setattr(record, name, value)
|
||||||
|
scope = _scope.get()
|
||||||
|
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
|
||||||
|
record.user_id = scope.user_id
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class LevelGate(logging.Filter):
|
||||||
|
"""The configured level, enforced at the HANDLER, with one exception: the lines of a request
|
||||||
|
whose user raised their diagnostics level (E22 S5).
|
||||||
|
|
||||||
|
The root logger's level cannot do this alone: it is one number for the whole process, and the
|
||||||
|
point is to log ONE user's requests in detail while everyone else's stay at the base level. So
|
||||||
|
the root is lowered to the lowest level any active elevation needs (`_track_elevation`), and
|
||||||
|
this gate drops what only an elevation let through unless the line belongs to that elevated
|
||||||
|
request. It also marks such a line `elevated`, which is what makes the `log_events` sink keep
|
||||||
|
every line of that request, not just its warnings."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
scope = _scope.get()
|
||||||
|
if scope is not None and scope.level is not None:
|
||||||
|
record.elevated = True
|
||||||
|
return record.levelno >= min(scope.level, _base_level)
|
||||||
|
return record.levelno >= _base_level
|
||||||
|
|
||||||
|
|
||||||
def _record_attrs(record: logging.LogRecord) -> dict[str, Any]:
|
def _record_attrs(record: logging.LogRecord) -> dict[str, Any]:
|
||||||
extra = {k: v for k, v in vars(record).items() if k not in _RECORD_STANDARD}
|
extra = {k: v for k, v in vars(record).items() if k not in _RECORD_STANDARD}
|
||||||
attrs = dict(getattr(record, "attrs", None) or {})
|
attrs = dict(getattr(record, "attrs", None) or {})
|
||||||
@@ -341,29 +397,73 @@ def configure_logging(source: str) -> None:
|
|||||||
"json": {"()": f"{__name__}.JsonFormatter"},
|
"json": {"()": f"{__name__}.JsonFormatter"},
|
||||||
"text": {"()": f"{__name__}.TextFormatter"},
|
"text": {"()": f"{__name__}.TextFormatter"},
|
||||||
},
|
},
|
||||||
"filters": {"context": {"()": f"{__name__}.ContextFilter"}},
|
"filters": {
|
||||||
|
"context": {"()": f"{__name__}.ContextFilter"},
|
||||||
|
"gate": {"()": f"{__name__}.LevelGate"},
|
||||||
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"stdout": {
|
"stdout": {
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"stream": sys.stdout,
|
"stream": sys.stdout,
|
||||||
"formatter": formatter,
|
"formatter": formatter,
|
||||||
"filters": ["context"],
|
"filters": ["context", "gate"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {"level": resolve_level(settings.log_level), "handlers": ["stdout"]},
|
"root": {"level": resolve_level(settings.log_level), "handlers": ["stdout"]},
|
||||||
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
|
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
set_level(settings.log_level)
|
||||||
|
|
||||||
|
|
||||||
def set_level(name: str) -> None:
|
def set_level(name: str) -> None:
|
||||||
"""Change the running process's level (the `log_level` admin knob). Third-party loggers keep
|
"""Change the running process's level (the `log_level` admin knob). Third-party loggers keep
|
||||||
their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's."""
|
their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's."""
|
||||||
logging.getLogger().setLevel(resolve_level(name))
|
global _base_level
|
||||||
|
_base_level = resolve_level(name)
|
||||||
|
_apply_root_level(force=True)
|
||||||
|
|
||||||
|
|
||||||
def current_level() -> str:
|
def current_level() -> str:
|
||||||
return logging.getLevelName(logging.getLogger().getEffectiveLevel())
|
"""The configured level — not the root logger's, which an active elevation may have lowered."""
|
||||||
|
return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel())
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-user elevation (E22 S5) -----------------------------------------------------------
|
||||||
|
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
|
||||||
|
# test, a script — so the gate lets everything through there, as it did before the gate existed.
|
||||||
|
_base_level = logging.NOTSET
|
||||||
|
# user id → (level number, expiry) for every elevation this process has seen and not yet expired.
|
||||||
|
_elevations: dict[int, tuple[int, datetime]] = {}
|
||||||
|
_elevations_lock = threading.Lock()
|
||||||
|
_root_applied: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None:
|
||||||
|
with _elevations_lock:
|
||||||
|
if level is None or until is None:
|
||||||
|
_elevations.pop(user_id, None)
|
||||||
|
else:
|
||||||
|
_elevations[user_id] = (level, until)
|
||||||
|
active = bool(_elevations)
|
||||||
|
if active or _root_applied != _base_level:
|
||||||
|
_apply_root_level()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_root_level(force: bool = False) -> None:
|
||||||
|
"""Root level = the lowest of the base and every unexpired elevation (expired ones are pruned
|
||||||
|
here). `setLevel` clears every logger's level cache, so it runs only when the number changes."""
|
||||||
|
global _root_applied
|
||||||
|
if _base_level == logging.NOTSET:
|
||||||
|
return # not configured: the root keeps whatever the process set up
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with _elevations_lock:
|
||||||
|
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]:
|
||||||
|
del _elevations[user_id]
|
||||||
|
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
|
||||||
|
if force or lowest != _root_applied:
|
||||||
|
logging.getLogger().setLevel(lowest)
|
||||||
|
_root_applied = lowest
|
||||||
|
|
||||||
|
|
||||||
def current_source() -> str:
|
def current_source() -> str:
|
||||||
@@ -397,6 +497,8 @@ async def log_requests(request, call_next):
|
|||||||
incoming = request.headers.get("x-request-id", "")
|
incoming = request.headers.get("x-request-id", "")
|
||||||
rid = incoming if _REQUEST_ID_RE.match(incoming) else uuid.uuid4().hex[:16]
|
rid = incoming if _REQUEST_ID_RE.match(incoming) else uuid.uuid4().hex[:16]
|
||||||
token = _request_id.set(rid)
|
token = _request_id.set(rid)
|
||||||
|
scope = _RequestScope()
|
||||||
|
scope_token = _scope.set(scope)
|
||||||
request.state.request_id = rid
|
request.state.request_id = rid
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
@@ -417,6 +519,7 @@ async def log_requests(request, call_next):
|
|||||||
**_request_owner(request, logging.ERROR),
|
**_request_owner(request, logging.ERROR),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_scope.reset(scope_token)
|
||||||
_request_id.reset(token)
|
_request_id.reset(token)
|
||||||
raise
|
raise
|
||||||
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||||
@@ -428,22 +531,23 @@ async def log_requests(request, call_next):
|
|||||||
level = logging.WARNING
|
level = logging.WARNING
|
||||||
else:
|
else:
|
||||||
level = logging.DEBUG if quiet else logging.INFO
|
level = logging.DEBUG if quiet else logging.INFO
|
||||||
|
attrs: dict[str, Any] = {
|
||||||
|
"method": request.method,
|
||||||
|
"path": _route_template(request),
|
||||||
|
"status": status,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
}
|
||||||
|
# Teljes (TRACE, the user's or the whole process's): the query string too — redacted, and in
|
||||||
|
# attrs only, never in the message.
|
||||||
|
if request.url.query and _traced(scope):
|
||||||
|
attrs["query"] = redact_text(request.url.query)
|
||||||
_request_log.log(
|
_request_log.log(
|
||||||
level,
|
level,
|
||||||
"%s %s %s",
|
"%s %s %s",
|
||||||
request.method,
|
request.method,
|
||||||
_route_template(request),
|
_route_template(request),
|
||||||
status,
|
status,
|
||||||
extra={
|
extra={**event("http.request", **attrs), **_request_owner(request, level)},
|
||||||
**event(
|
|
||||||
"http.request",
|
|
||||||
method=request.method,
|
|
||||||
path=_route_template(request),
|
|
||||||
status=status,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
),
|
|
||||||
**_request_owner(request, level),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
response.headers["X-Request-Id"] = rid
|
response.headers["X-Request-Id"] = rid
|
||||||
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
|
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
|
||||||
@@ -453,10 +557,18 @@ async def log_requests(request, call_next):
|
|||||||
matched = _matched_route(request)
|
matched = _matched_route(request)
|
||||||
if matched:
|
if matched:
|
||||||
response.headers["X-Route"] = matched
|
response.headers["X-Route"] = matched
|
||||||
|
_scope.reset(scope_token)
|
||||||
_request_id.reset(token)
|
_request_id.reset(token)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _traced(scope: _RequestScope) -> bool:
|
||||||
|
"""Whether this request is logged at TRACE — its user's consent, or the process's base level."""
|
||||||
|
if scope.level is not None and scope.level <= TRACE:
|
||||||
|
return True
|
||||||
|
return _base_level != logging.NOTSET and _base_level <= TRACE
|
||||||
|
|
||||||
|
|
||||||
# Anonymous request lines kept per client per minute. Signed-in traffic is not capped: it is a known
|
# Anonymous request lines kept per client per minute. Signed-in traffic is not capped: it is a known
|
||||||
# account and exactly what an admin needs to see. Anonymous traffic is where the volume can come from
|
# account and exactly what an admin needs to see. Anonymous traffic is where the volume can come from
|
||||||
# nobody in particular — a scanner walking `/wp-login.php`, a bot on a share link — and each of its
|
# nobody in particular — a scanner walking `/wp-login.php`, a bot on a share link — and each of its
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ for `log_retention_days`:
|
|||||||
* every WARNING and above,
|
* every WARNING and above,
|
||||||
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
|
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
|
||||||
static files),
|
static files),
|
||||||
|
* every line of a request whose user raised their diagnostics level (E22 S5 — `logs.LevelGate`
|
||||||
|
marks them), which is the detail they consented to have kept,
|
||||||
* nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
|
* nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
|
||||||
traffic per client.
|
traffic per client.
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ def should_store(record: logging.LogRecord) -> bool:
|
|||||||
"""Whether this record belongs in `log_events` (see the module docstring)."""
|
"""Whether this record belongs in `log_events` (see the module docstring)."""
|
||||||
if getattr(record, logs.PERSIST, True) is False:
|
if getattr(record, logs.PERSIST, True) is False:
|
||||||
return False
|
return False
|
||||||
if record.levelno >= logging.WARNING:
|
if record.levelno >= logging.WARNING or getattr(record, "elevated", False):
|
||||||
return True
|
return True
|
||||||
return getattr(record, "event", None) == "http.request" and record.levelno >= logging.INFO
|
return getattr(record, "event", None) == "http.request" and record.levelno >= logging.INFO
|
||||||
|
|
||||||
@@ -255,6 +257,9 @@ def start() -> None:
|
|||||||
if _sink is not None or not settings.log_db_sink:
|
if _sink is not None or not settings.log_db_sink:
|
||||||
return
|
return
|
||||||
_sink = LogSink()
|
_sink = LogSink()
|
||||||
|
# The same level gate as stdout: the root may be lowered for one user's elevated requests, and
|
||||||
|
# everyone else's detail must not reach the table through that gap either.
|
||||||
|
_sink.addFilter(logs.LevelGate())
|
||||||
logging.getLogger().addHandler(_sink)
|
logging.getLogger().addHandler(_sink)
|
||||||
_sink.start()
|
_sink.start()
|
||||||
# A short-lived process (the download child) exits without a lifespan: flush on the way out.
|
# A short-lived process (the download child) exits without a lifespan: flush on the way out.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from app.routes import (
|
|||||||
auth as auth_routes,
|
auth as auth_routes,
|
||||||
channels,
|
channels,
|
||||||
config as config_routes,
|
config as config_routes,
|
||||||
|
diagnostics as diagnostics_routes,
|
||||||
discover as discover_routes,
|
discover as discover_routes,
|
||||||
downloads,
|
downloads,
|
||||||
feed,
|
feed,
|
||||||
@@ -270,6 +271,7 @@ app.include_router(feed.router)
|
|||||||
app.include_router(discover_routes.router)
|
app.include_router(discover_routes.router)
|
||||||
app.include_router(search_routes.router)
|
app.include_router(search_routes.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
|
app.include_router(diagnostics_routes.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
|||||||
# routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is
|
# routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is
|
||||||
# possible). The shared demo account is never stamped — its "last login" would mean nobody.
|
# possible). The shared demo account is never stamped — its "last login" would mean nobody.
|
||||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# The diagnostics level this user consented to (E22 S5) and when it lapses back to the base
|
||||||
|
# level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry
|
||||||
|
# itself; read through `app.diagnostics.effective_level`, never directly (an expired level is
|
||||||
|
# not cleared eagerly — it simply stops counting).
|
||||||
|
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
|
||||||
|
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""The browser's log, sent while its user has a raised diagnostics level (E22 S5).
|
||||||
|
|
||||||
|
The client keeps its log in memory (frontend `lib/log.ts`). Only after the user consented to Részletes
|
||||||
|
or Teljes in Settings → Diagnosztika does it stream that log here, in batches, and each batch lands in
|
||||||
|
`log_events` beside the server's own rows: `source="web"`, the account from the SESSION (never from
|
||||||
|
the body), and the request id each event carries, which is what joins a browser line to the server
|
||||||
|
line of the same request.
|
||||||
|
|
||||||
|
Nothing from the body is trusted as-is:
|
||||||
|
* the whole body is capped before it is parsed, and so are the number of events and each event's
|
||||||
|
attributes;
|
||||||
|
* everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a
|
||||||
|
modified client would not;
|
||||||
|
* an event below the consented level is dropped (a Részletes consent does not store what only
|
||||||
|
Teljes may carry), and so is the whole batch once the level has lapsed;
|
||||||
|
* the timestamp is clamped to the consent window, so a row cannot dodge the retention job by
|
||||||
|
claiming to come from the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
from sqlalchemy import insert
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import diagnostics, logs, logsink
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import LogEvent, User
|
||||||
|
from app.ratelimit import RateLimiter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
|
||||||
|
|
||||||
|
BODY_MAX_BYTES = 512 * 1024
|
||||||
|
EVENTS_MAX = 200
|
||||||
|
ATTRS_MAX_BYTES = 16 * 1024
|
||||||
|
# The client flushes every few seconds and when its buffer fills; this is room for a burst, not a
|
||||||
|
# pace anything legitimate keeps up for a minute.
|
||||||
|
_batches = RateLimiter(max_events=30, window_seconds=60)
|
||||||
|
|
||||||
|
ClientLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||||
|
|
||||||
|
|
||||||
|
class ClientEvent(BaseModel):
|
||||||
|
ts: datetime
|
||||||
|
level: ClientLevel
|
||||||
|
event: str = Field(max_length=64, pattern=r"^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$")
|
||||||
|
msg: str = Field(max_length=8_000)
|
||||||
|
attrs: dict[str, Any] | None = None
|
||||||
|
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$")
|
||||||
|
tab_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,32}$")
|
||||||
|
version: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientEventsIn(BaseModel):
|
||||||
|
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
async def _capped_body(request: Request) -> bytes:
|
||||||
|
"""The request body, refused the moment it passes BODY_MAX_BYTES while still arriving. Counted,
|
||||||
|
not taken from Content-Length: a declared length can be absent (chunked) or wrong, and counting
|
||||||
|
stops reading at the same point a declared-length check would have."""
|
||||||
|
body = bytearray()
|
||||||
|
async for chunk in request.stream():
|
||||||
|
body += chunk
|
||||||
|
if len(body) > BODY_MAX_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
async def _parsed_events(request: Request) -> ClientEventsIn:
|
||||||
|
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
|
||||||
|
not a plain body parameter (FastAPI reads a declared body in full before anything else runs)."""
|
||||||
|
raw = await _capped_body(request)
|
||||||
|
try:
|
||||||
|
return ClientEventsIn.model_validate_json(raw)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -> dict[str, Any]:
|
||||||
|
attrs = logs.redact(item.attrs) if item.attrs else None
|
||||||
|
if attrs is not None:
|
||||||
|
size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode())
|
||||||
|
if size > ATTRS_MAX_BYTES:
|
||||||
|
attrs = {"attrs_dropped_bytes": size}
|
||||||
|
if item.tab_id:
|
||||||
|
attrs = {**(attrs or {}), "tab_id": item.tab_id}
|
||||||
|
ts = item.ts if item.ts.tzinfo else item.ts.replace(tzinfo=timezone.utc)
|
||||||
|
return logsink.to_row(
|
||||||
|
{
|
||||||
|
"ts": min(max(ts, window_start), now),
|
||||||
|
"level": item.level,
|
||||||
|
"sev": logs.SEVERITY[item.level],
|
||||||
|
"source": "web",
|
||||||
|
"logger": "web",
|
||||||
|
"event": item.event,
|
||||||
|
"msg": logs.redact_text(item.msg),
|
||||||
|
"attrs": attrs,
|
||||||
|
"request_id": item.request_id,
|
||||||
|
"user_id": user.id,
|
||||||
|
"version": item.version,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events")
|
||||||
|
def ingest_events(
|
||||||
|
# The account first: dependencies resolve in order, so an anonymous caller is refused before a
|
||||||
|
# byte of its body is read.
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
body: ClientEventsIn = Depends(_parsed_events),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
level = diagnostics.effective_level(user)
|
||||||
|
if level == diagnostics.BASE:
|
||||||
|
# Lapsed or withdrawn: the client stops streaming when it sees this.
|
||||||
|
raise HTTPException(status_code=409, detail={"code": "diagnostics_off"})
|
||||||
|
if not _batches.allow(str(user.id)):
|
||||||
|
raise HTTPException(status_code=429, detail={"code": "rate_limited"})
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS)
|
||||||
|
floor = logs.SEVERITY[level]
|
||||||
|
rows = [
|
||||||
|
_row(item, user, now, window_start)
|
||||||
|
for item in body.events
|
||||||
|
if logs.SEVERITY[item.level] >= floor
|
||||||
|
]
|
||||||
|
if rows:
|
||||||
|
db.execute(insert(LogEvent), rows)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "stored": len(rows)}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, RootModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -15,7 +17,9 @@ from app.auth import (
|
|||||||
optional_current_user,
|
optional_current_user,
|
||||||
purge_user,
|
purge_user,
|
||||||
)
|
)
|
||||||
|
from app import diagnostics
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
from app import logs
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
@@ -147,6 +151,9 @@ def get_me(
|
|||||||
"pending_activations": pending_activations,
|
"pending_activations": pending_activations,
|
||||||
"email_configured": email_configured,
|
"email_configured": email_configured,
|
||||||
"preferences": user.preferences or {},
|
"preferences": user.preferences or {},
|
||||||
|
# The consented diagnostics level in force and when it lapses (E22 S5). The client applies
|
||||||
|
# it to its own log and streams that log while it is raised.
|
||||||
|
"diagnostics": diagnostics.state(db, user),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -188,16 +195,45 @@ def delete_my_account(
|
|||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
class PreferencesIn(RootModel[dict[str, Any]]):
|
||||||
|
"""Free-form UI preferences — a JSON object of any keys (theme, filters, …), merged into the
|
||||||
|
stored ones. A model rather than a bare `dict` parameter so a non-object body is a 422 at the
|
||||||
|
boundary like every other request body."""
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences")
|
@router.put("/preferences")
|
||||||
def update_preferences(
|
def update_preferences(
|
||||||
preferences: dict,
|
preferences: PreferencesIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
# Merge so partial updates don't wipe other keys.
|
# Merge so partial updates don't wipe other keys.
|
||||||
merged = dict(user.preferences or {})
|
merged = dict(user.preferences or {})
|
||||||
merged.update(preferences)
|
merged.update(preferences.root)
|
||||||
user.preferences = merged
|
user.preferences = merged
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"preferences": merged}
|
return {"preferences": merged}
|
||||||
|
|
||||||
|
|
||||||
|
class DiagnosticsIn(BaseModel):
|
||||||
|
level: diagnostics.DiagnosticsLevel
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/diagnostics")
|
||||||
|
def update_diagnostics(
|
||||||
|
body: DiagnosticsIn,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the
|
||||||
|
base. The expiry is the server's: a raised level always lapses after
|
||||||
|
`diagnostics.ELEVATION_HOURS`, whatever the client asks. The shared demo account cannot raise
|
||||||
|
it — its log would interleave every visitor's session under one account."""
|
||||||
|
if user.is_demo and body.level != diagnostics.BASE:
|
||||||
|
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "diagnostics"})
|
||||||
|
diagnostics.set_level(user, body.level)
|
||||||
|
db.commit()
|
||||||
|
# The rest of THIS request, and the process's elevation registry, follow the new level now.
|
||||||
|
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
|
||||||
|
return {"ok": True, "diagnostics": diagnostics.state(db, user)}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ of the caller's already-translated fallback, so from that commit on the demo use
|
|||||||
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
|
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
|
||||||
|
|
||||||
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
|
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
|
||||||
Pinned at all FOUR sites: the shared dependency, the two auth routes that hand-roll the same check,
|
Pinned at every site: the shared dependency, the two auth routes that hand-roll the same check,
|
||||||
and the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
|
the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
|
||||||
all the sites were covered, so the next reader had no reason to look (round 7). An asymmetric fix is
|
all the sites were covered, so the next reader had no reason to look (round 7). An asymmetric fix is
|
||||||
the smell this repo bans, and a docstring asserting symmetry it does not have is worse than none.
|
the smell this repo bans, and a docstring asserting symmetry it does not have is worse than none.
|
||||||
|
|
||||||
The list is derived from the source, not typed out: `test_every_is_demo_refusal_is_on_the_list`
|
The list is derived from the source, not typed out: `test_every_is_demo_refusal_is_on_the_list`
|
||||||
below fails when a fifth `is_demo` 403 appears anywhere in `app/`, so this file cannot fall behind
|
below fails when a fifth `is_demo` 403 appears anywhere in `app/`, so this file cannot fall behind
|
||||||
the code the way round 6's did. Needs the DB.
|
the code the way round 6's did. (E22 S5 added the fifth: raising the diagnostics level.) Needs the DB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -33,6 +33,8 @@ def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as
|
|||||||
("post", "/auth/logout-others", None),
|
("post", "/auth/logout-others", None),
|
||||||
("post", "/auth/set-password", {"password": "a long enough password"}),
|
("post", "/auth/set-password", {"password": "a long enough password"}),
|
||||||
("delete", "/api/me/account", None),
|
("delete", "/api/me/account", None),
|
||||||
|
# E22 S5: the shared account's log would interleave every visitor's session.
|
||||||
|
("put", "/api/me/diagnostics", {"level": "DEBUG"}),
|
||||||
]:
|
]:
|
||||||
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
|
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
|
||||||
assert r.status_code == 403, (url, r.text)
|
assert r.status_code == 403, (url, r.text)
|
||||||
@@ -55,9 +57,9 @@ def test_every_is_demo_refusal_is_on_the_list():
|
|||||||
for path in sorted(app_dir.rglob("*.py")):
|
for path in sorted(app_dir.rglob("*.py")):
|
||||||
lines = path.read_text(encoding="utf-8").splitlines()
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
# `if user.is_demo:` / `if u.is_demo:` — the hand-rolled gate, not the ORM filters
|
# `if user.is_demo:` / `if u.is_demo and …:` — the hand-rolled gate, not the ORM
|
||||||
# (`User.is_demo.is_(False)`) and not the column definition.
|
# filters (`User.is_demo.is_(False)`) and not the column definition.
|
||||||
if not re.search(r"^\s*if\s+\w+\.is_demo\s*:", line):
|
if not re.search(r"^\s*if\s+\w+\.is_demo\b[^:]*:", line):
|
||||||
continue
|
continue
|
||||||
block = "\n".join(lines[i : i + 6])
|
block = "\n".join(lines[i : i + 6])
|
||||||
if "403" not in block:
|
if "403" not in block:
|
||||||
@@ -67,9 +69,9 @@ def test_every_is_demo_refusal_is_on_the_list():
|
|||||||
f"{path.relative_to(app_dir)}:{i + 1} refuses the demo account with an "
|
f"{path.relative_to(app_dir)}:{i + 1} refuses the demo account with an "
|
||||||
f"unstructured detail — the frontend cannot localize it:\n{block}"
|
f"unstructured detail — the frontend cannot localize it:\n{block}"
|
||||||
)
|
)
|
||||||
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete. A new one is welcome,
|
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete and diagnostics level. A
|
||||||
# but it has to be added to the loop above in the same commit.
|
# new one is welcome, but it has to be added to the loop above in the same commit.
|
||||||
assert len(sites) == 4, sites
|
assert len(sites) == 5, sites
|
||||||
|
|
||||||
|
|
||||||
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
|
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"""E22 S5 — a user's consented diagnostics level, and the browser log it lets in.
|
||||||
|
|
||||||
|
Three parts:
|
||||||
|
* the level itself: the server computes its expiry, a lapsed one reads as the base, the demo
|
||||||
|
account cannot raise it (DB lane, through the real routes);
|
||||||
|
* `POST /api/diagnostics/events`: refused at the base level, and nothing in a batch is trusted —
|
||||||
|
the account comes from the session, everything is redacted again, events under the consented
|
||||||
|
level are dropped, sizes and pace are capped (DB lane);
|
||||||
|
* the server side of the elevation: ONE user's requests are logged in detail while everyone
|
||||||
|
else's stay at the base level, and the account reaches a handler's own lines at all (pure, an
|
||||||
|
in-process app).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import diagnostics, logs, logsink
|
||||||
|
from app.models import LogEvent, User
|
||||||
|
from app.routes import diagnostics as diagnostics_routes
|
||||||
|
|
||||||
|
_PW = "correct horse battery"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the level ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_raised_level_counts_until_it_lapses_and_not_a_moment_longer():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
assert diagnostics.effective_level(User()) == "INFO"
|
||||||
|
raised = User(diagnostics_level="TRACE", diagnostics_until=now + timedelta(minutes=1))
|
||||||
|
assert diagnostics.effective_level(raised, now) == "TRACE"
|
||||||
|
assert diagnostics.effective_level(raised, now + timedelta(minutes=2)) == "INFO"
|
||||||
|
# A level without an expiry is not a level that never ends — it is no level at all.
|
||||||
|
assert diagnostics.effective_level(User(diagnostics_level="DEBUG")) == "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_in(client, make_user, **kw):
|
||||||
|
"""A real sign-in: `/api/me` resolves through `optional_current_user`, which the `as_user`
|
||||||
|
override does not cover (see test_me_email_flag)."""
|
||||||
|
user = make_user(email="diag@example.com", password=_PW, **kw)
|
||||||
|
r = client.post("/auth/password-login", json={"email": "diag@example.com", "password": _PW})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db, client, make_user):
|
||||||
|
user = _signed_in(client, make_user)
|
||||||
|
r = client.put("/api/me/diagnostics", json={"level": "DEBUG", "until": "2099-01-01T00:00:00Z"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
db.refresh(user)
|
||||||
|
expected = datetime.now(timezone.utc) + timedelta(hours=diagnostics.ELEVATION_HOURS)
|
||||||
|
assert abs((user.diagnostics_until - expected).total_seconds()) < 60
|
||||||
|
assert r.json()["diagnostics"]["level"] == "DEBUG"
|
||||||
|
assert client.get("/api/me").json()["diagnostics"]["level"] == "DEBUG"
|
||||||
|
|
||||||
|
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
|
||||||
|
db.refresh(user)
|
||||||
|
assert (user.diagnostics_level, user.diagnostics_until) == (None, None)
|
||||||
|
assert client.get("/api/me").json()["diagnostics"] == {
|
||||||
|
"level": "INFO",
|
||||||
|
"until": None,
|
||||||
|
"retention_days": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user):
|
||||||
|
_signed_in(
|
||||||
|
client,
|
||||||
|
make_user,
|
||||||
|
diagnostics_level="TRACE",
|
||||||
|
diagnostics_until=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
assert client.get("/api/me").json()["diagnostics"] == {
|
||||||
|
"level": "INFO",
|
||||||
|
"until": None,
|
||||||
|
"retention_days": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_three_levels_can_be_chosen(db, make_user, as_user):
|
||||||
|
client = as_user(make_user())
|
||||||
|
for bogus in ("WARNING", "debug", "EVERYTHING"):
|
||||||
|
assert client.put("/api/me/diagnostics", json={"level": bogus}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_demo_account_can_return_to_the_base_but_never_raise_it(db, make_user, as_user):
|
||||||
|
client = as_user(make_user(is_demo=True))
|
||||||
|
r = client.put("/api/me/diagnostics", json={"level": "TRACE"})
|
||||||
|
assert r.status_code == 403 and r.json()["detail"]["code"] == "demo"
|
||||||
|
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_preferences_must_be_a_json_object(db, make_user, as_user):
|
||||||
|
client = as_user(make_user())
|
||||||
|
assert client.put("/api/me/preferences", json=["not", "an", "object"]).status_code == 422
|
||||||
|
r = client.put("/api/me/preferences", json={"theme": "dark"})
|
||||||
|
assert r.status_code == 200 and r.json()["preferences"]["theme"] == "dark"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the browser's batches ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _event(**over) -> dict:
|
||||||
|
return {
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"level": "INFO",
|
||||||
|
"event": "nav.view",
|
||||||
|
"msg": "view",
|
||||||
|
"tab_id": "a1b2c3",
|
||||||
|
"version": "0.88.0",
|
||||||
|
**over,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def elevated(db, make_user, as_user):
|
||||||
|
"""A signed-in client whose user consented to `level` a minute ago, and a reader for its rows."""
|
||||||
|
diagnostics_routes._batches.reset()
|
||||||
|
|
||||||
|
def _make(level: str = "DEBUG"):
|
||||||
|
user = make_user(
|
||||||
|
diagnostics_level=level,
|
||||||
|
diagnostics_until=datetime.now(timezone.utc) + timedelta(hours=23),
|
||||||
|
)
|
||||||
|
|
||||||
|
def rows() -> list[LogEvent]:
|
||||||
|
db.expire_all()
|
||||||
|
return list(db.execute(select(LogEvent).order_by(LogEvent.id)).scalars())
|
||||||
|
|
||||||
|
return user, as_user(user), rows
|
||||||
|
|
||||||
|
yield _make
|
||||||
|
diagnostics_routes._batches.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_is_accepted_at_the_base_level(db, make_user, as_user):
|
||||||
|
client = as_user(make_user())
|
||||||
|
r = client.post("/api/diagnostics/events", json={"events": [_event()]})
|
||||||
|
assert r.status_code == 409 and r.json()["detail"]["code"] == "diagnostics_off"
|
||||||
|
assert not db.execute(select(LogEvent)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anonymous_caller_is_refused(client):
|
||||||
|
assert client.post("/api/diagnostics/events", json={"events": [_event()]}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_batch_is_stored_as_the_sessions_web_events_and_redacted_again(elevated):
|
||||||
|
user, client, rows = elevated("DEBUG")
|
||||||
|
r = client.post(
|
||||||
|
"/api/diagnostics/events",
|
||||||
|
json={
|
||||||
|
"events": [
|
||||||
|
_event(
|
||||||
|
level="WARNING",
|
||||||
|
event="ui.notify",
|
||||||
|
msg="sent to someone@example.com",
|
||||||
|
attrs={"password": "hunter2", "url": "/?token=abc123", "user_id": 999},
|
||||||
|
request_id="0123456789abcdef",
|
||||||
|
),
|
||||||
|
_event(level="DEBUG", event="ui.click", attrs={"control": "feed.refresh"}),
|
||||||
|
],
|
||||||
|
"user_id": 999, # ignored: the account is the session's
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.json() == {"ok": True, "stored": 2}
|
||||||
|
warning, click = rows()
|
||||||
|
assert (warning.source, warning.logger, warning.user_id) == ("web", "web", user.id)
|
||||||
|
assert warning.msg == "sent to [email]"
|
||||||
|
assert warning.attrs["password"] == logs.REDACTED
|
||||||
|
assert "abc123" not in json.dumps(warning.attrs)
|
||||||
|
assert warning.attrs["tab_id"] == "a1b2c3"
|
||||||
|
assert warning.request_id == "0123456789abcdef"
|
||||||
|
assert (warning.sev, click.level, click.version) == (13, "DEBUG", "0.88.0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_event_under_the_consented_level_is_dropped(elevated):
|
||||||
|
_user, client, rows = elevated("DEBUG")
|
||||||
|
r = client.post(
|
||||||
|
"/api/diagnostics/events",
|
||||||
|
json={"events": [_event(level="TRACE", msg="typed: lofi"), _event(level="DEBUG")]},
|
||||||
|
)
|
||||||
|
assert r.json()["stored"] == 1
|
||||||
|
assert [row.level for row in rows()] == ["DEBUG"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_timestamp_outside_the_consent_window_is_clamped(elevated):
|
||||||
|
_user, client, rows = elevated("DEBUG")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
future = (now + timedelta(days=365)).isoformat()
|
||||||
|
past = (now - timedelta(days=30)).isoformat()
|
||||||
|
client.post("/api/diagnostics/events", json={"events": [_event(ts=future), _event(ts=past)]})
|
||||||
|
late, early = rows()
|
||||||
|
assert late.ts <= datetime.now(timezone.utc)
|
||||||
|
assert early.ts >= now - timedelta(hours=diagnostics.ELEVATION_HOURS, minutes=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sizes_are_capped(elevated):
|
||||||
|
_user, client, rows = elevated("DEBUG")
|
||||||
|
too_many = [_event() for _ in range(diagnostics_routes.EVENTS_MAX + 1)]
|
||||||
|
assert client.post("/api/diagnostics/events", json={"events": too_many}).status_code == 422
|
||||||
|
|
||||||
|
huge = "x" * (diagnostics_routes.BODY_MAX_BYTES + 1)
|
||||||
|
r = client.post(
|
||||||
|
"/api/diagnostics/events",
|
||||||
|
content=json.dumps({"events": [_event(attrs={"blob": huge})]}),
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 413
|
||||||
|
|
||||||
|
big_attrs = {"blob": "y" * (diagnostics_routes.ATTRS_MAX_BYTES + 10)}
|
||||||
|
assert (
|
||||||
|
client.post("/api/diagnostics/events", json={"events": [_event(attrs=big_attrs)]}).json()[
|
||||||
|
"stored"
|
||||||
|
]
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
(row,) = rows()
|
||||||
|
assert "blob" not in row.attrs and row.attrs["attrs_dropped_bytes"] > 0
|
||||||
|
|
||||||
|
bad_code = client.post("/api/diagnostics/events", json={"events": [_event(event="Nav View")]})
|
||||||
|
assert bad_code.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pace_is_capped_per_user(elevated):
|
||||||
|
_user, client, _rows = elevated("DEBUG")
|
||||||
|
codes = [
|
||||||
|
client.post("/api/diagnostics/events", json={"events": []}).status_code for _ in range(31)
|
||||||
|
]
|
||||||
|
assert codes[:30] == [200] * 30 and codes[30] == 429
|
||||||
|
|
||||||
|
|
||||||
|
# --- the server side of an elevation ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _Capture(logging.Handler):
|
||||||
|
"""The stdout handler's filters (context + gate), capturing records."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(level=logging.NOTSET)
|
||||||
|
self.records: list[logging.LogRecord] = []
|
||||||
|
self.addFilter(logs.ContextFilter())
|
||||||
|
self.addFilter(logs.LevelGate())
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
self.records.append(record)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gated():
|
||||||
|
"""A process configured at INFO with a gated handler on the ROOT (where the real one sits), and
|
||||||
|
every piece of module state the elevation touches restored afterwards."""
|
||||||
|
root = logging.getLogger()
|
||||||
|
saved = (logs._base_level, dict(logs._elevations), logs._root_applied, root.level)
|
||||||
|
handler = _Capture()
|
||||||
|
root.addHandler(handler)
|
||||||
|
logs._elevations.clear()
|
||||||
|
logs.set_level("INFO")
|
||||||
|
try:
|
||||||
|
yield handler
|
||||||
|
finally:
|
||||||
|
root.removeHandler(handler)
|
||||||
|
logs._base_level, logs._root_applied = saved[0], saved[2]
|
||||||
|
logs._elevations.clear()
|
||||||
|
logs._elevations.update(saved[1])
|
||||||
|
root.setLevel(saved[3])
|
||||||
|
|
||||||
|
|
||||||
|
def _app() -> TestClient:
|
||||||
|
"""What a real request does: the request middleware, then a SYNC auth dependency that resolves
|
||||||
|
the account (run by FastAPI in a threadpool, on a copy of the context), then a handler."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.middleware("http")(logs.log_requests)
|
||||||
|
handler_log = logging.getLogger("siftlode.difftest")
|
||||||
|
|
||||||
|
def account(user_id: int, level: str = "INFO"):
|
||||||
|
until = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
|
logs.set_user(user_id, level, until)
|
||||||
|
|
||||||
|
@app.get("/api/thing/{user_id}")
|
||||||
|
def _thing(user_id: int, _=Depends(account)):
|
||||||
|
handler_log.debug("detail only an elevated request keeps")
|
||||||
|
handler_log.info("an ordinary line")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_account_reaches_the_handlers_own_lines(gated):
|
||||||
|
_app().get("/api/thing/41")
|
||||||
|
line = next(r for r in gated.records if r.getMessage() == "an ordinary line")
|
||||||
|
assert getattr(line, "user_id", None) == 41
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_users_elevation_logs_their_requests_in_detail_and_nobody_elses(gated):
|
||||||
|
client = _app()
|
||||||
|
client.get("/api/thing/7", params={"level": "DEBUG"})
|
||||||
|
client.get("/api/thing/8")
|
||||||
|
detail = [r for r in gated.records if r.getMessage().startswith("detail only")]
|
||||||
|
assert [r.user_id for r in detail] == [7]
|
||||||
|
assert getattr(detail[0], "elevated", False) is True
|
||||||
|
# Every line of the elevated request is marked for storage — the sink keeps it at any level.
|
||||||
|
ordinary = [r for r in gated.records if r.getMessage() == "an ordinary line"]
|
||||||
|
assert [logsink.should_store(r) for r in ordinary] == [True, False]
|
||||||
|
# The process's configured level is still what it reports, however low the root went.
|
||||||
|
assert logs.current_level() == "INFO" and logging.getLogger().level == logging.DEBUG
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_root_level_returns_to_the_base_when_the_elevation_ends(gated):
|
||||||
|
client = _app()
|
||||||
|
client.get("/api/thing/7", params={"level": "TRACE"})
|
||||||
|
assert logging.getLogger().level == logs.TRACE
|
||||||
|
client.get("/api/thing/7") # the same user, back at the base
|
||||||
|
assert logging.getLogger().level == logging.INFO
|
||||||
|
logs._track_elevation(9, logging.DEBUG, datetime.now(timezone.utc) - timedelta(seconds=1))
|
||||||
|
assert logging.getLogger().level == logging.INFO # an already-expired one is pruned at once
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_query_string_is_logged_only_for_a_traced_request_and_redacted(gated):
|
||||||
|
client = _app()
|
||||||
|
client.get("/api/thing/7", params={"level": "DEBUG", "q": "lofi"})
|
||||||
|
client.get("/api/thing/7", params={"level": "TRACE", "q": "lofi", "token": "SECRET"})
|
||||||
|
lines = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
|
||||||
|
assert "query" not in lines[0].attrs
|
||||||
|
assert "q=lofi" in lines[1].attrs["query"] and "SECRET" not in lines[1].attrs["query"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_sign_in_carries_the_consented_level_into_the_process(gated, client, db, make_user):
|
||||||
|
user = _signed_in(client, make_user)
|
||||||
|
assert client.put("/api/me/diagnostics", json={"level": "DEBUG"}).status_code == 200
|
||||||
|
logs._elevations.clear() # only a request resolving the account through auth may put it back
|
||||||
|
client.get("/api/me")
|
||||||
|
assert logs._elevations[user.id][0] == logging.DEBUG
|
||||||
|
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
|
||||||
|
assert user.id not in logs._elevations
|
||||||
@@ -48,6 +48,7 @@ import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
|||||||
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
||||||
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
|
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
|
||||||
import { FRONTEND_VERSION } from "./lib/version";
|
import { FRONTEND_VERSION } from "./lib/version";
|
||||||
|
import { applyDiagnostics } from "./lib/diagnostics";
|
||||||
|
|
||||||
// The page modules (and their side rails) are code-split per route via the module registry
|
// The page modules (and their side rails) are code-split per route via the module registry
|
||||||
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
||||||
@@ -151,6 +152,14 @@ export default function App() {
|
|||||||
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
|
||||||
|
// kept in more detail and streamed to the server until the level lapses. A lapse (its own timer, or
|
||||||
|
// the server saying it is off) refetches `me`, so Settings shows the base level again.
|
||||||
|
const diagnostics = meQuery.data?.diagnostics;
|
||||||
|
useEffect(() => {
|
||||||
|
applyDiagnostics(diagnostics, () => void queryClient.invalidateQueries({ queryKey: qk.me() }));
|
||||||
|
}, [diagnostics?.level, diagnostics?.until]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E22 S5: Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
|
||||||
|
// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
|
||||||
|
// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
|
||||||
|
// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { DiagnosticsLevel, DiagnosticsState, Me } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: { count?: number }) =>
|
||||||
|
opts?.count != null ? `${key}:${opts.count}` : key,
|
||||||
|
i18n: { language: "en" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const setLevel = vi.fn(
|
||||||
|
(level: DiagnosticsLevel): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
diagnostics: {
|
||||||
|
level,
|
||||||
|
until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
|
||||||
|
retention_days: 7,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mock("../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { ...actual.api, setDiagnosticsLevel: (l: DiagnosticsLevel) => setLevel(l) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount(me: Partial<Me>) {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
qc.setQueryData(qk.me(), {
|
||||||
|
id: 1,
|
||||||
|
is_demo: false,
|
||||||
|
preferences: {},
|
||||||
|
diagnostics: { level: "INFO", until: null, retention_days: 7 },
|
||||||
|
...me,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<DiagnosticsSettings />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelButton = (name: string) =>
|
||||||
|
within(screen.getByRole("group", { name: "settings.diagnostics.level" })).getByRole("button", {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DiagnosticsSettings", () => {
|
||||||
|
it("choosing a detailed level asks first and changes nothing until confirmed", async () => {
|
||||||
|
mount({});
|
||||||
|
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
|
||||||
|
const consent = screen.getByTestId("diagnostics-consent");
|
||||||
|
expect(consent.textContent).toContain("settings.diagnostics.consent.what.DEBUG");
|
||||||
|
expect(consent.textContent).toContain("settings.diagnostics.consent.server:7");
|
||||||
|
expect(setLevel).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(consent).getByRole("button", { name: "settings.diagnostics.consent.cancel" }),
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
|
||||||
|
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(setLevel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the consent button raises the level, and the status line offers the way back", async () => {
|
||||||
|
mount({});
|
||||||
|
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
|
||||||
|
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
|
||||||
|
expect(setLevel).toHaveBeenCalledWith("TRACE");
|
||||||
|
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
|
||||||
|
expect(levelButton("settings.diagnostics.levels.TRACE").getAttribute("aria-pressed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.revert" }));
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
|
||||||
|
expect(setLevel).toHaveBeenLastCalledWith("INFO");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 } });
|
||||||
|
fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
|
||||||
|
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
|
||||||
|
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a lapsed level shows as the base level", () => {
|
||||||
|
const until = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } });
|
||||||
|
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("diagnostics-status")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the shared demo account gets an explanation instead of the picker", () => {
|
||||||
|
mount({ is_demo: true });
|
||||||
|
expect(screen.queryByRole("group", { name: "settings.diagnostics.level" })).toBeNull();
|
||||||
|
expect(screen.getByText("settings.diagnostics.demo")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { api, type DiagnosticsLevel, type Me } from "../lib/api";
|
||||||
|
import { levelInForce } from "../lib/diagnostics";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import InlineNotice from "./ui/InlineNotice";
|
||||||
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
|
import { Section, SettingRow } from "./ui/form";
|
||||||
|
import MyLogViewer from "./MyLogViewer";
|
||||||
|
|
||||||
|
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());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!until) return;
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [until]);
|
||||||
|
if (!until) return null;
|
||||||
|
const minutes = Math.max(0, Math.ceil((Date.parse(until) - now) / 60_000));
|
||||||
|
return { h: Math.floor(minutes / 60), m: minutes % 60 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
|
||||||
|
//
|
||||||
|
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
|
||||||
|
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
|
||||||
|
// the server keeps it and for how long, and that it lapses; only its button raises the level.
|
||||||
|
// Returning to Alap needs no consent and happens at once.
|
||||||
|
export default function DiagnosticsSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const me = useMe();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const state = me.diagnostics;
|
||||||
|
const current = levelInForce(state);
|
||||||
|
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
|
||||||
|
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
|
||||||
|
|
||||||
|
const change = useMutation({
|
||||||
|
mutationFn: api.setDiagnosticsLevel,
|
||||||
|
meta: { failTitle: "settings.diagnostics.failed" },
|
||||||
|
onSuccess: (result) => {
|
||||||
|
qc.setQueryData<Me | null>(qk.me(), (prev) =>
|
||||||
|
prev ? { ...prev, diagnostics: result.diagnostics } : prev,
|
||||||
|
);
|
||||||
|
setPending(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const choose = (level: DiagnosticsLevel) => {
|
||||||
|
if (level === "INFO") {
|
||||||
|
setPending(null);
|
||||||
|
if (current !== "INFO") change.mutate("INFO");
|
||||||
|
} else {
|
||||||
|
setPending(level === current ? null : level);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section title={t("settings.diagnostics.title")}>
|
||||||
|
{me.is_demo ? (
|
||||||
|
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice>
|
||||||
|
) : (
|
||||||
|
<SettingRow
|
||||||
|
label={t("settings.diagnostics.level")}
|
||||||
|
hint={t("settings.diagnostics.levelHint")}
|
||||||
|
>
|
||||||
|
<SegmentedToggle
|
||||||
|
size="md"
|
||||||
|
ariaLabel={t("settings.diagnostics.level")}
|
||||||
|
value={pending ?? current}
|
||||||
|
onChange={choose}
|
||||||
|
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<div
|
||||||
|
data-testid="diagnostics-consent"
|
||||||
|
className="mt-2 rounded-xl border border-border bg-accent/5 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">{t(`settings.diagnostics.consent.title.${pending}`)}</p>
|
||||||
|
<ul className="mt-1.5 mb-3 list-disc space-y-1 pl-5 text-muted">
|
||||||
|
<li>{t(`settings.diagnostics.consent.what.${pending}`)}</li>
|
||||||
|
<li>
|
||||||
|
{t("settings.diagnostics.consent.server", {
|
||||||
|
count: state?.retention_days ?? 7,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
<li>{t("settings.diagnostics.consent.never")}</li>
|
||||||
|
<li>
|
||||||
|
{t("settings.diagnostics.consent.lapse")}{" "}
|
||||||
|
<a
|
||||||
|
href="/privacy#diagnostics"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t("settings.diagnostics.consent.policy")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setPending(null)}>
|
||||||
|
{t("settings.diagnostics.consent.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
data-testid="diagnostics-consent-confirm"
|
||||||
|
loading={change.isPending}
|
||||||
|
onClick={() => change.mutate(pending)}
|
||||||
|
>
|
||||||
|
{t("settings.diagnostics.consent.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current !== "INFO" && !pending && remaining && (
|
||||||
|
<div
|
||||||
|
data-testid="diagnostics-status"
|
||||||
|
role="status"
|
||||||
|
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-warn" aria-hidden />
|
||||||
|
<span>
|
||||||
|
{t("settings.diagnostics.status", {
|
||||||
|
level: levelName(current),
|
||||||
|
time:
|
||||||
|
remaining.h > 0
|
||||||
|
? t("settings.diagnostics.hoursMinutes", remaining)
|
||||||
|
: t("settings.diagnostics.minutes", remaining),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Button variant="link" loading={change.isPending} onClick={() => change.mutate("INFO")}>
|
||||||
|
{t("settings.diagnostics.revert")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("settings.myLog.title")}>
|
||||||
|
<MyLogViewer />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
|
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
|
||||||
|
import { logFilterChange } from "../lib/filterLog";
|
||||||
import { useNavigation } from "./NavigationProvider";
|
import { useNavigation } from "./NavigationProvider";
|
||||||
|
|
||||||
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
||||||
@@ -123,6 +124,7 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const setFilters = useCallback((next: FeedFilters) => {
|
const setFilters = useCallback((next: FeedFilters) => {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
|
logFilterChange("feed", next, next.q);
|
||||||
// Persist under THIS tab's active account so filters don't bleed between accounts.
|
// Persist under THIS tab's active account so filters don't bleed between accounts.
|
||||||
const key = accountKey(FILTERS_KEY, getActiveAccount());
|
const key = accountKey(FILTERS_KEY, getActiveAccount());
|
||||||
if (key) writeJSON(key, next);
|
if (key) writeJSON(key, next);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { jobTitle } from "./downloadRow";
|
import { jobTitle } from "./downloadRow";
|
||||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||||
|
import { useMediaLogRef } from "../lib/mediaLog";
|
||||||
|
|
||||||
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||||
@@ -30,6 +31,14 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
|||||||
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
||||||
// the fallback. Same precedence as the library-row thumbnail.
|
// the fallback. Same precedence as the library-row thumbnail.
|
||||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||||
|
const logMedia = useMediaLogRef("library");
|
||||||
|
const videoRef = useCallback(
|
||||||
|
(el: HTMLVideoElement | null) => {
|
||||||
|
exposeVideoToBackgroundPlay(el);
|
||||||
|
logMedia(el);
|
||||||
|
},
|
||||||
|
[logMedia],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||||
@@ -42,13 +51,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
|||||||
className="w-40 h-40 rounded-xl object-cover border border-border"
|
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<audio src={fileUrl} controls autoPlay className="w-full">
|
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
|
||||||
{t("downloads.player.unsupported")}
|
{t("downloads.player.unsupported")}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
ref={exposeVideoToBackgroundPlay}
|
ref={videoRef}
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E22 S5: "Saját napló" shows this tab's own log live, newest first, filters it by level group and
|
||||||
|
// text, and copies exactly what is filtered (oldest first, one JSON event per line).
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { clear, log, setLevel } from "../lib/log";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: { count?: number }) =>
|
||||||
|
opts?.count != null ? `${key}:${opts.count}` : key,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const copied: string[] = [];
|
||||||
|
vi.mock("../lib/clipboard", () => ({
|
||||||
|
copyText: (text: string) => {
|
||||||
|
copied.push(text);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MyLogViewer from "./MyLogViewer";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setLevel("DEBUG");
|
||||||
|
log.info("nav.view", "push feed", { page: "feed" });
|
||||||
|
log.warn("http.request", "GET /api/feed 503", { status: 503 });
|
||||||
|
log.debug("ui.click", "click feed-refresh", { control: "feed-refresh" });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
clear();
|
||||||
|
setLevel("INFO");
|
||||||
|
copied.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = () =>
|
||||||
|
within(screen.getByTestId("my-log-lines"))
|
||||||
|
.queryAllByRole("button")
|
||||||
|
.map((b) => b.textContent);
|
||||||
|
|
||||||
|
describe("MyLogViewer", () => {
|
||||||
|
it("lists the tab's events newest first and follows new ones", async () => {
|
||||||
|
render(<MyLogViewer />);
|
||||||
|
expect(rows().map((r) => r!.includes("ui.click"))).toEqual([true, false, false]);
|
||||||
|
await act(async () => {
|
||||||
|
log.error("app.uncaught", "TypeError: x is undefined");
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
});
|
||||||
|
expect(rows()[0]).toContain("app.uncaught");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by level group and by text, and copies only what is shown", async () => {
|
||||||
|
render(<MyLogViewer />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.warning/ }));
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toContain("GET /api/feed 503");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.all/ }));
|
||||||
|
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "FEED-REFRESH" } });
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.copy" }));
|
||||||
|
});
|
||||||
|
return copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).event);
|
||||||
|
};
|
||||||
|
expect(await copy()).toEqual(["ui.click"]); // only what the search left
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "" } });
|
||||||
|
expect(await copy()).toEqual(["nav.view", "http.request", "ui.click"]); // oldest first
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a row to its details and clears the log", async () => {
|
||||||
|
render(<MyLogViewer />);
|
||||||
|
const first = within(screen.getByTestId("my-log-lines")).getAllByRole("button")[0]!;
|
||||||
|
fireEvent.click(first);
|
||||||
|
expect(first.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(screen.getByTestId("my-log-lines").textContent).toContain('"control": "feed-refresh"');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.clear" }));
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
});
|
||||||
|
expect(rows()).toEqual([]);
|
||||||
|
expect(screen.getByText("settings.myLog.emptyAll")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
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 { notify } from "../lib/notifications";
|
||||||
|
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);
|
||||||
|
useEffect(() => {
|
||||||
|
let frame: number | null = null;
|
||||||
|
const unsubscribe = subscribe(() => {
|
||||||
|
if (frame !== null) return;
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
setList(events());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setList(events()); // whatever arrived between the first render and the subscription
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = (event: LogEvent, needle: string) =>
|
||||||
|
!needle ||
|
||||||
|
[event.event, event.msg, event.request_id ?? "", event.attrs ? JSON.stringify(event.attrs) : ""]
|
||||||
|
.join(" ")
|
||||||
|
.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
|
||||||
|
// (one event per line) for a bug report.
|
||||||
|
export default function MyLogViewer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const all = useLogEvents();
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [open, setOpen] = useState<LogEvent | null>(null);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const out: Record<Filter, number> = {
|
||||||
|
all: all.length,
|
||||||
|
error: 0,
|
||||||
|
warning: 0,
|
||||||
|
info: 0,
|
||||||
|
detail: 0,
|
||||||
|
};
|
||||||
|
for (const e of all) {
|
||||||
|
for (const [group, levels] of Object.entries(FILTER_LEVELS)) {
|
||||||
|
if (levels.includes(e.level)) out[group as Filter] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
const levels = filter === "all" ? null : FILTER_LEVELS[filter];
|
||||||
|
const out: LogEvent[] = [];
|
||||||
|
for (let i = all.length - 1; i >= 0; i--) {
|
||||||
|
const e = all[i]!;
|
||||||
|
if ((!levels || levels.includes(e.level)) && matches(e, needle)) out.push(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [all, filter, query]);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
// Oldest first, as a log reads — the list shows newest first only for watching it live.
|
||||||
|
const text = [...shown]
|
||||||
|
.reverse()
|
||||||
|
.map((e) => JSON.stringify(e))
|
||||||
|
.join("\n");
|
||||||
|
const ok = await copyText(text);
|
||||||
|
notify(
|
||||||
|
ok
|
||||||
|
? { level: "success", message: t("settings.myLog.copied", { count: shown.length }) }
|
||||||
|
: { level: "error", message: t("settings.myLog.copyFailed") },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters: readonly Filter[] = ["all", "error", "warning", "info", "detail"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
|
||||||
|
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||||
|
<label className="flex min-w-[10rem] 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={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("settings.myLog.search")}
|
||||||
|
aria-label={t("settings.myLog.search")}
|
||||||
|
className="min-w-0 flex-1 bg-transparent outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<FilterChipGroup
|
||||||
|
ariaLabel={t("settings.myLog.filterLabel")}
|
||||||
|
value={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
options={filters.map((f) => ({
|
||||||
|
value: f,
|
||||||
|
label: t(`settings.myLog.filters.${f}`),
|
||||||
|
count: counts[f],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-auto font-mono text-2xs" data-testid="my-log-lines">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
|
||||||
|
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
shown.slice(0, ROWS_SHOWN).map((e, i) => {
|
||||||
|
const tag = LEVEL_TAG[e.level];
|
||||||
|
const isOpen = open === e;
|
||||||
|
return (
|
||||||
|
<div key={`${e.ts}-${i}`} 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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</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("settings.myLog.count", { count: all.length })}
|
||||||
|
{shown.length > ROWS_SHOWN &&
|
||||||
|
` · ${t("settings.myLog.capped", { shown: ROWS_SHOWN, count: shown.length })}`}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon={<Copy className="h-3.5 w-3.5" />}
|
||||||
|
disabled={shown.length === 0}
|
||||||
|
onClick={() => void copy()}
|
||||||
|
>
|
||||||
|
{t("settings.myLog.copy")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
disabled={all.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
clear();
|
||||||
|
setOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("settings.myLog.clear")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
|||||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
import { createFragMeter } from "../lib/plexStats";
|
import { createFragMeter } from "../lib/plexStats";
|
||||||
|
import { enabled, log } from "../lib/log";
|
||||||
|
import { logMediaElement } from "../lib/mediaLog";
|
||||||
import { UpNextCard } from "./UpNextCard";
|
import { UpNextCard } from "./UpNextCard";
|
||||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||||
import {
|
import {
|
||||||
@@ -432,6 +434,22 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
|
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
|
||||||
// reap needs; past that the honest answer is that playback failed.
|
// reap needs; past that the honest answer is that playback failed.
|
||||||
hls.on(Hls.Events.ERROR, (_e, data) => {
|
hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||||
|
// Fatal ones are what the user saw fail; the recoverable ones hls.js retries by itself
|
||||||
|
// only matter to a detailed diagnosis.
|
||||||
|
if (data.fatal || enabled("DEBUG")) {
|
||||||
|
log.at(
|
||||||
|
data.fatal ? "WARNING" : "DEBUG",
|
||||||
|
"player.hls_error",
|
||||||
|
`plex hls ${data.details}`,
|
||||||
|
{
|
||||||
|
player: "plex",
|
||||||
|
fatal: data.fatal,
|
||||||
|
type: data.type,
|
||||||
|
details: data.details,
|
||||||
|
status: data.response?.code ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!data.fatal || !current()) return;
|
if (!data.fatal || !current()) return;
|
||||||
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
|
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -611,6 +629,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
|
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
|
||||||
const onWaiting = () => setBuffering(true);
|
const onWaiting = () => setBuffering(true);
|
||||||
const onResumed = () => setBuffering(false);
|
const onResumed = () => setBuffering(false);
|
||||||
|
const detachLog = logMediaElement(video, "plex");
|
||||||
video.addEventListener("timeupdate", onTime);
|
video.addEventListener("timeupdate", onTime);
|
||||||
video.addEventListener("play", onPlay);
|
video.addEventListener("play", onPlay);
|
||||||
video.addEventListener("pause", onPause);
|
video.addEventListener("pause", onPause);
|
||||||
@@ -625,6 +644,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
video.addEventListener("seeked", onResumed);
|
video.addEventListener("seeked", onResumed);
|
||||||
video.addEventListener("pause", onResumed);
|
video.addEventListener("pause", onResumed);
|
||||||
return () => {
|
return () => {
|
||||||
|
detachLog();
|
||||||
video.removeEventListener("timeupdate", onTime);
|
video.removeEventListener("timeupdate", onTime);
|
||||||
video.removeEventListener("play", onPlay);
|
video.removeEventListener("play", onPlay);
|
||||||
video.removeEventListener("pause", onPause);
|
video.removeEventListener("pause", onPause);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type PlexUrlState,
|
type PlexUrlState,
|
||||||
} from "../lib/plexUrlState";
|
} from "../lib/plexUrlState";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
|
import { logFilterChange } from "../lib/filterLog";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { useNavigation } from "./NavigationProvider";
|
import { useNavigation } from "./NavigationProvider";
|
||||||
|
|
||||||
@@ -219,27 +220,33 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [urlSeed]);
|
}, [urlSeed]);
|
||||||
|
|
||||||
const setScope = useCallback((v: string) => {
|
const setScope = useCallback((v: string) => {
|
||||||
|
logFilterChange("plex", { scope: v });
|
||||||
setScopeState(v);
|
setScopeState(v);
|
||||||
setAccountRaw(LS.plexLibrary, v);
|
setAccountRaw(LS.plexLibrary, v);
|
||||||
}, []);
|
}, []);
|
||||||
const setShow = useCallback((v: string) => {
|
const setShow = useCallback((v: string) => {
|
||||||
|
logFilterChange("plex", { show: v });
|
||||||
setShowState(v);
|
setShowState(v);
|
||||||
setAccountRaw(LS.plexShow, v);
|
setAccountRaw(LS.plexShow, v);
|
||||||
}, []);
|
}, []);
|
||||||
const setSort = useCallback((v: SortState) => {
|
const setSort = useCallback((v: SortState) => {
|
||||||
|
logFilterChange("plex", { sort: v });
|
||||||
setSortState(v);
|
setSortState(v);
|
||||||
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
||||||
}, []);
|
}, []);
|
||||||
const setFilters = useCallback((f: PlexFilters) => {
|
const setFilters = useCallback((f: PlexFilters) => {
|
||||||
|
logFilterChange("plex", { filters: f });
|
||||||
const s = JSON.stringify(f);
|
const s = JSON.stringify(f);
|
||||||
setFiltersRaw(s);
|
setFiltersRaw(s);
|
||||||
setAccountRaw(LS.plexFilters, s);
|
setAccountRaw(LS.plexFilters, s);
|
||||||
}, []);
|
}, []);
|
||||||
const setQ = useCallback((v: string) => {
|
const setQ = useCallback((v: string) => {
|
||||||
|
logFilterChange("plex", {}, v);
|
||||||
setQState(v);
|
setQState(v);
|
||||||
setAccountRaw(LS.plexQ, v);
|
setAccountRaw(LS.plexQ, v);
|
||||||
}, []);
|
}, []);
|
||||||
const setField = useCallback((v: PlexSearchField) => {
|
const setField = useCallback((v: PlexSearchField) => {
|
||||||
|
logFilterChange("plex", { field: v });
|
||||||
setFieldState(v);
|
setFieldState(v);
|
||||||
setAccountRaw(LS.plexField, v);
|
setAccountRaw(LS.plexField, v);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { withActiveAccount } from "../lib/activeAccount";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
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 { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
@@ -39,11 +40,12 @@ export interface PrefsController {
|
|||||||
saveState: PrefsSaveState;
|
saveState: PrefsSaveState;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "account";
|
type TabId = "appearance" | "notifications" | "account" | "diagnostics";
|
||||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "appearance", icon: Monitor },
|
{ id: "appearance", icon: Monitor },
|
||||||
{ id: "notifications", icon: Bell },
|
{ id: "notifications", icon: Bell },
|
||||||
{ id: "account", icon: User },
|
{ id: "account", icon: User },
|
||||||
|
{ id: "diagnostics", icon: Activity },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
||||||
@@ -65,11 +67,12 @@ export default function SettingsPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
<div className="glass rounded-2xl overflow-hidden">
|
<div className="glass rounded-2xl overflow-hidden">
|
||||||
<div className="flex">
|
<div className="flex flex-col sm:flex-row">
|
||||||
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
|
||||||
|
Tabs). Active is a clear accent pill. */}
|
||||||
<Tabs
|
<Tabs
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1"
|
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-36 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
tabs={TABS.map((tabItem) => ({
|
tabs={TABS.map((tabItem) => ({
|
||||||
@@ -87,6 +90,7 @@ export default function SettingsPanel() {
|
|||||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||||
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
||||||
|
{tab === "diagnostics" && <DiagnosticsSettings />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
|
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
|
||||||
// fetch them directly — so they render outside the authenticated App tree (see main.tsx),
|
// fetch them directly — so they render outside the authenticated App tree (see main.tsx),
|
||||||
// with no react-query provider: the operator contact is fetched with a bare `send` below.
|
// with no react-query provider: the operator contact is fetched with a bare `send` below.
|
||||||
import { useEffect, useState } from "react";
|
//
|
||||||
|
// Both pages exist in Hungarian and English, like the rest of the UI. The prose is a document, not
|
||||||
|
// a set of UI strings, so each language's body is its own component (see PrivacyPolicy/Terms) and
|
||||||
|
// `legalBodies.contract.dom.test.tsx` holds the two to the same sections; the chrome around them is i18n.
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { LANGUAGES, setLanguage } from "../../i18n";
|
||||||
|
import { formatDate } from "../../lib/format";
|
||||||
import { send } from "../../lib/http";
|
import { send } from "../../lib/http";
|
||||||
|
|
||||||
const LAST_UPDATED = "June 14, 2026";
|
// The day the text last changed materially — bump it with every edit of either body.
|
||||||
|
const LAST_UPDATED = "2026-09-17";
|
||||||
|
|
||||||
// Contact shown on the legal pages: the instance operator's configured admin email, from the
|
// Contact shown on the legal pages: the instance operator's configured admin email, from the
|
||||||
// public /auth/config. One shared fetch across the page; null when the operator set none.
|
// public /auth/config. One shared fetch across the page; null when the operator set none.
|
||||||
@@ -34,8 +43,9 @@ function useOperatorContact(): string | null {
|
|||||||
|
|
||||||
// The operator's contact rendered as a mailto link, or a neutral fallback phrase when unset.
|
// The operator's contact rendered as a mailto link, or a neutral fallback phrase when unset.
|
||||||
export function ContactEmail() {
|
export function ContactEmail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const email = useOperatorContact();
|
const email = useOperatorContact();
|
||||||
if (!email) return <>your instance's administrator</>;
|
if (!email) return <>{t("legal.operatorFallback")}</>;
|
||||||
return (
|
return (
|
||||||
<a href={`mailto:${email}`} className="text-accent hover:underline">
|
<a href={`mailto:${email}`} className="text-accent hover:underline">
|
||||||
{email}
|
{email}
|
||||||
@@ -43,7 +53,7 @@ export function ContactEmail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
|
export function LegalLink({ href, children }: { href: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||||
{children}
|
{children}
|
||||||
@@ -51,38 +61,57 @@ export function LegalLink({ href, children }: { href: string; children: React.Re
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LegalLayout({
|
export default function LegalLayout({ title, children }: { title: string; children: ReactNode }) {
|
||||||
title,
|
const { t, i18n } = useTranslation();
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const contact = useOperatorContact();
|
const contact = useOperatorContact();
|
||||||
|
// A link to a section (`/privacy#diagnostics`): the browser tries to scroll to the fragment before
|
||||||
|
// this lazily loaded page has rendered it, so jump there once the section exists.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = decodeURIComponent(window.location.hash.slice(1));
|
||||||
|
if (id) document.getElementById(id)?.scrollIntoView();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg text-fg">
|
<div className="min-h-screen bg-bg text-fg">
|
||||||
<div className="mx-auto w-[min(92vw,720px)] py-12">
|
<div className="mx-auto w-[min(92vw,720px)] py-12">
|
||||||
<a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8">
|
<div className="mb-8 flex items-center justify-between gap-4">
|
||||||
Sift<span className="text-accent">lode</span>
|
<a href="/" className="inline-block text-2xl font-bold tracking-tight">
|
||||||
</a>
|
Sift<span className="text-accent">lode</span>
|
||||||
|
</a>
|
||||||
|
<div role="group" aria-label={t("legal.languageLabel")} className="flex gap-1 text-xs">
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
type="button"
|
||||||
|
lang={lang.code}
|
||||||
|
aria-pressed={i18n.language?.startsWith(lang.code) ?? false}
|
||||||
|
onClick={() => setLanguage(lang.code)}
|
||||||
|
className="rounded-md px-2 py-1 text-muted hover:text-fg aria-pressed:bg-card aria-pressed:text-fg"
|
||||||
|
>
|
||||||
|
{lang.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="glass rounded-2xl p-8">
|
<div className="glass rounded-2xl p-8">
|
||||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||||
<p className="text-xs text-muted mt-1 mb-6">Last updated: {LAST_UPDATED}</p>
|
<p className="text-xs text-muted mt-1 mb-6">
|
||||||
|
{t("legal.lastUpdated", { date: formatDate(LAST_UPDATED, i18n.language) })}
|
||||||
|
</p>
|
||||||
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
|
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
|
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
|
||||||
<a href="/" className="hover:text-fg transition">
|
<a href="/" className="hover:text-fg transition">
|
||||||
Home
|
{t("legal.home")}
|
||||||
</a>
|
</a>
|
||||||
<a href="/privacy" className="hover:text-fg transition">
|
<a href="/privacy" className="hover:text-fg transition">
|
||||||
Privacy Policy
|
{t("common.privacyPolicy")}
|
||||||
</a>
|
</a>
|
||||||
<a href="/terms" className="hover:text-fg transition">
|
<a href="/terms" className="hover:text-fg transition">
|
||||||
Terms of Service
|
{t("common.termsOfService")}
|
||||||
</a>
|
</a>
|
||||||
{contact && (
|
{contact && (
|
||||||
<a href={`mailto:${contact}`} className="hover:text-fg transition">
|
<a href={`mailto:${contact}`} className="hover:text-fg transition">
|
||||||
Contact
|
{t("legal.contact")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
@@ -91,6 +120,12 @@ export default function LegalLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function H2({ children }: { children: React.ReactNode }) {
|
/** A section heading. `id` is required: it is the anchor a link can point at (Settings →
|
||||||
return <h2 className="text-base font-semibold text-fg pt-2">{children}</h2>;
|
* Diagnosztika links `/privacy#diagnostics`), and what holds the two languages to the same sections. */
|
||||||
|
export function H2({ id, children }: { id: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h2 id={id} className="text-base font-semibold text-fg pt-2 scroll-mt-6">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
|
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
|
||||||
|
import { useHungarian } from "./useHungarian";
|
||||||
|
|
||||||
|
// Bold lead-in for a list item.
|
||||||
|
const Lead = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<span className="text-fg font-medium">{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hu = useHungarian();
|
||||||
return (
|
return (
|
||||||
<LegalLayout title="Privacy Policy">
|
<LegalLayout title={t("common.privacyPolicy")}>
|
||||||
|
{hu ? <PrivacyHu /> : <PrivacyEn />}
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivacyEn() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<p>
|
<p>
|
||||||
Siftlode is a personal, non-commercial project operated by an individual in Hungary ("we",
|
Siftlode is a personal, non-commercial project operated by an individual in Hungary ("we",
|
||||||
"us"). It lets you view and organize your own YouTube subscriptions as a filterable feed.
|
"us"). It lets you view and organize your own YouTube subscriptions as a filterable feed.
|
||||||
@@ -10,22 +28,23 @@ export default function PrivacyPolicy() {
|
|||||||
By using Siftlode you agree to this policy.
|
By using Siftlode you agree to this policy.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>What we access</H2>
|
<H2 id="access">What we access</H2>
|
||||||
<p>Siftlode only accesses data you explicitly authorize, in two separate steps:</p>
|
<p>Siftlode only accesses data you explicitly authorize:</p>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google profile —
|
<Lead>Sign-in (always):</Lead> your email address — and, when you sign in with Google,
|
||||||
name, email address, profile picture, and Google account identifier — used solely to
|
your basic Google profile (name, profile picture, Google account identifier) — used solely
|
||||||
create and identify your account.
|
to create and identify your account. Signing in with an email and password stores only a
|
||||||
|
one-way hash of the password.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="text-fg font-medium">YouTube read (optional, opt-in):</span> with the{" "}
|
<Lead>YouTube read (optional, opt-in):</Lead> with the{" "}
|
||||||
<code className="text-xs">youtube.readonly</code> scope, your list of YouTube
|
<code className="text-xs">youtube.readonly</code> scope, your list of YouTube
|
||||||
subscriptions and the public metadata of those channels and their videos (titles,
|
subscriptions and the public metadata of those channels and their videos (titles,
|
||||||
thumbnails, durations, view counts, publish dates), used to build your feed.
|
thumbnails, durations, view counts, publish dates), used to build your feed.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with the{" "}
|
<Lead>YouTube manage (optional, opt-in):</Lead> with the{" "}
|
||||||
<code className="text-xs">youtube</code> scope, the ability to unsubscribe from a channel
|
<code className="text-xs">youtube</code> scope, the ability to unsubscribe from a channel
|
||||||
on YouTube — performed only when you click to do so.
|
on YouTube — performed only when you click to do so.
|
||||||
</li>
|
</li>
|
||||||
@@ -35,7 +54,7 @@ export default function PrivacyPolicy() {
|
|||||||
independently.
|
independently.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>How we use it</H2>
|
<H2 id="use">How we use it</H2>
|
||||||
<p>
|
<p>
|
||||||
Your data is used exclusively to provide Siftlode's features to you: building and displaying
|
Your data is used exclusively to provide Siftlode's features to you: building and displaying
|
||||||
your personalized subscription feed, and carrying out actions (such as unsubscribing) that
|
your personalized subscription feed, and carrying out actions (such as unsubscribing) that
|
||||||
@@ -43,7 +62,42 @@ export default function PrivacyPolicy() {
|
|||||||
app.
|
app.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Limited Use disclosure</H2>
|
<H2 id="logs">Server logs</H2>
|
||||||
|
<p>
|
||||||
|
To keep the service running and to find faults, the server records each request it answers:
|
||||||
|
which part of the app was called (for example <code className="text-xs">/api/feed</code>,
|
||||||
|
never the search terms or other values in the address), whether it succeeded, how long it
|
||||||
|
took, and which account made it. Warnings and errors are recorded too. Your IP address is
|
||||||
|
not stored in these records. They are kept for the retention period the operator sets — 7
|
||||||
|
days by default, at most 90 — and then deleted automatically.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="diagnostics">Diagnostic logging (optional, with your consent)</H2>
|
||||||
|
<p>
|
||||||
|
In Settings → Diagnostics you can see the log your own browser tab keeps. By default it
|
||||||
|
stays in that tab and is gone when the page reloads. If you choose the <Lead>Detailed</Lead>{" "}
|
||||||
|
or <Lead>Full</Lead> level and confirm, for the next 24 hours:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
your browser also records which buttons, filters and player controls you use and the
|
||||||
|
values in the address bar — and, at Full, the text you type into search;
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
that log is sent to the server and stored with your account alongside the server's more
|
||||||
|
detailed record of your requests, for the same retention period as the server logs;
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
passwords, tokens, email addresses and the content of your messages are never included, at
|
||||||
|
any level.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
After 24 hours the level returns to the default by itself, and you can return to it earlier
|
||||||
|
at any time. It is used only to diagnose problems with Siftlode.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="limited-use">Limited Use disclosure</H2>
|
||||||
<p>
|
<p>
|
||||||
Siftlode's use and transfer of information received from Google APIs to any other app will
|
Siftlode's use and transfer of information received from Google APIs to any other app will
|
||||||
adhere to the{" "}
|
adhere to the{" "}
|
||||||
@@ -55,32 +109,35 @@ export default function PrivacyPolicy() {
|
|||||||
control), and do not use it for advertising.
|
control), and do not use it for advertising.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Storage & security</H2>
|
<H2 id="storage">Storage & security</H2>
|
||||||
<p>
|
<p>
|
||||||
Data is stored in a private PostgreSQL database on a server we control, accessible only to
|
Data is stored in a private PostgreSQL database on a server we control, accessible only to
|
||||||
the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
|
the operator. Google OAuth refresh tokens are encrypted at rest. Private messages between
|
||||||
emails explicitly invited to the app. No system is perfectly secure, but we take reasonable
|
users are end-to-end encrypted in the browser: the server stores only the encrypted text and
|
||||||
measures to protect your data.
|
cannot read it. Access is restricted to the email addresses admitted to the app. No system
|
||||||
|
is perfectly secure, but we take reasonable measures to protect your data.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Retention & deletion</H2>
|
<H2 id="retention">Retention & deletion</H2>
|
||||||
<p>
|
<p>
|
||||||
We keep your data while your account is active. You can revoke Siftlode's access to your
|
We keep your data while your account is active. You can delete your account and its data
|
||||||
Google data at any time at{" "}
|
yourself at any time in Settings → Account, which also removes the stored log records of
|
||||||
|
your account. You can revoke Siftlode's access to your Google data at{" "}
|
||||||
<LegalLink href="https://myaccount.google.com/permissions">
|
<LegalLink href="https://myaccount.google.com/permissions">
|
||||||
myaccount.google.com/permissions
|
myaccount.google.com/permissions
|
||||||
</LegalLink>
|
</LegalLink>
|
||||||
; once revoked, the stored tokens can no longer be used. To have your account and associated
|
; once revoked, the stored tokens can no longer be used. For anything else, contact{" "}
|
||||||
data deleted, contact <ContactEmail />.
|
<ContactEmail />.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Cookies</H2>
|
<H2 id="cookies">Cookies & browser storage</H2>
|
||||||
<p>
|
<p>
|
||||||
Siftlode sets a single, signed session cookie to keep you logged in. It is not used for
|
Siftlode sets a single, signed session cookie to keep you logged in, and keeps your display
|
||||||
tracking or advertising, and there are no third-party analytics cookies.
|
preferences in your browser's local storage. Neither is used for tracking or advertising,
|
||||||
|
and there are no third-party analytics cookies.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>YouTube & Google</H2>
|
<H2 id="google">YouTube & Google</H2>
|
||||||
<p>
|
<p>
|
||||||
Siftlode uses YouTube API Services. By using Siftlode you also agree to the{" "}
|
Siftlode uses YouTube API Services. By using Siftlode you also agree to the{" "}
|
||||||
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>, and
|
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>, and
|
||||||
@@ -88,16 +145,162 @@ export default function PrivacyPolicy() {
|
|||||||
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Changes</H2>
|
<H2 id="changes">Changes</H2>
|
||||||
<p>
|
<p>
|
||||||
We may update this policy; material changes will be reflected by the "Last updated" date
|
We may update this policy; material changes will be reflected by the "Last updated" date
|
||||||
above. Continued use after a change constitutes acceptance.
|
above. Continued use after a change constitutes acceptance.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Contact</H2>
|
<H2 id="contact">Contact</H2>
|
||||||
<p>
|
<p>
|
||||||
Questions about this policy or your data: contact <ContactEmail />.
|
Questions about this policy or your data: contact <ContactEmail />.
|
||||||
</p>
|
</p>
|
||||||
</LegalLayout>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivacyHu() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
|
||||||
|
Magyarországon („mi”). Lehetővé teszi, hogy a saját YouTube-feliratkozásaidat szűrhető
|
||||||
|
hírfolyamként nézd és rendszerezd. Ez a tájékoztató leírja, milyen adatokhoz fér hozzá a
|
||||||
|
Siftlode, mire használja őket, és milyen lehetőségeid vannak. A Siftlode használatával
|
||||||
|
elfogadod ezt a tájékoztatót.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="access">Mihez férünk hozzá</H2>
|
||||||
|
<p>A Siftlode csak azokhoz az adatokhoz fér hozzá, amelyekhez kifejezetten hozzájárulsz:</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
<Lead>Bejelentkezés (mindig):</Lead> az e-mail-címed, Google-bejelentkezésnél pedig az
|
||||||
|
alapvető Google-profilod is (név, profilkép, Google-fiókazonosító), kizárólag a fiókod
|
||||||
|
létrehozásához és azonosításához. E-mail-címmel és jelszóval bejelentkezve csak a jelszó
|
||||||
|
egyirányú lenyomatát tároljuk.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Lead>YouTube olvasás (nem kötelező, külön engedélyezed):</Lead> a{" "}
|
||||||
|
<code className="text-xs">youtube.readonly</code> jogosultsággal a YouTube-feliratkozásaid
|
||||||
|
listája, valamint ezeknek a csatornáknak és videóiknak a nyilvános adatai (cím, bélyegkép,
|
||||||
|
hossz, megtekintésszám, megjelenés dátuma) a hírfolyamod felépítéséhez.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Lead>YouTube kezelés (nem kötelező, külön engedélyezed):</Lead> a{" "}
|
||||||
|
<code className="text-xs">youtube</code> jogosultsággal leiratkozhatsz egy csatornáról a
|
||||||
|
YouTube-on — csak akkor történik meg, ha erre kattintasz.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
A Siftlode puszta bejelentkezéssel is használható, és a YouTube-jogosultságokat egymástól
|
||||||
|
függetlenül adhatod meg vagy utasíthatod el.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="use">Mire használjuk</H2>
|
||||||
|
<p>
|
||||||
|
Az adataidat kizárólag a Siftlode funkcióinak nyújtására használjuk: a személyre szabott
|
||||||
|
hírfolyamod felépítésére és megjelenítésére, valamint az általad kezdeményezett műveletek
|
||||||
|
(például leiratkozás) végrehajtására. Nem használjuk hirdetésre, profilalkotásra vagy az
|
||||||
|
alkalmazástól független célra.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="logs">Szervernapló</H2>
|
||||||
|
<p>
|
||||||
|
A szolgáltatás működtetéséhez és a hibák felderítéséhez a szerver minden megválaszolt
|
||||||
|
kérésről feljegyzi: az alkalmazás melyik részét hívták (például{" "}
|
||||||
|
<code className="text-xs">/api/feed</code>, soha nem a keresőszót vagy más értéket a
|
||||||
|
címből), sikerült-e, mennyi ideig tartott, és melyik fiók indította. A figyelmeztetések és a
|
||||||
|
hibák is bekerülnek. Az IP-címedet ezek a feljegyzések nem tárolják. Az üzemeltető által
|
||||||
|
beállított ideig őrizzük őket — alapból 7 napig, legfeljebb 90 napig —, utána automatikusan
|
||||||
|
törlődnek.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="diagnostics">Diagnosztikai naplózás (nem kötelező, a beleegyezéseddel)</H2>
|
||||||
|
<p>
|
||||||
|
A Beállítások → Diagnosztika fülön láthatod a saját böngészőlapod naplóját. Alapból ez csak
|
||||||
|
abban a lapban marad, és az oldal újratöltésekor törlődik. Ha a <Lead>Részletes</Lead> vagy
|
||||||
|
a <Lead>Teljes</Lead> szintet választod és megerősíted, a következő 24 órában:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
a böngésződ azt is rögzíti, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a
|
||||||
|
címsor paramétereinek értékét — a Teljes szinten a keresőbe beírt szöveget is;
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
ezt a naplót elküldi a szervernek, amely a fiókodhoz tárolja, a kéréseid részletesebb
|
||||||
|
szerveroldali naplójával együtt, a szervernapló megőrzési idejéig;
|
||||||
|
</li>
|
||||||
|
<li>jelszó, token, e-mail-cím és az üzeneteid tartalma egyik szinten sem kerül bele.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
24 óra múlva a szint magától visszaáll az alapra, és bármikor korábban is visszaállíthatod.
|
||||||
|
Kizárólag a Siftlode hibáinak felderítésére használjuk.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="limited-use">Korlátozott felhasználás (Limited Use)</H2>
|
||||||
|
<p>
|
||||||
|
A Siftlode a Google API-kból kapott adatok felhasználása és más alkalmazásnak történő
|
||||||
|
átadása során betartja a{" "}
|
||||||
|
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
|
||||||
|
Google API Services User Data Policy
|
||||||
|
</LegalLink>{" "}
|
||||||
|
előírásait, beleértve a Limited Use követelményeit. Nem adjuk el az adataidat, nem adjuk át
|
||||||
|
harmadik félnek (kivéve, ami az alkalmazás működtetéséhez szükséges, például az általunk
|
||||||
|
kezelt tárhely-infrastruktúra), és nem használjuk hirdetésre.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="storage">Tárolás és biztonság</H2>
|
||||||
|
<p>
|
||||||
|
Az adatokat egy általunk kezelt szerveren, privát PostgreSQL-adatbázisban tároljuk, amelyhez
|
||||||
|
csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. A
|
||||||
|
felhasználók közötti privát üzenetek végponttól végpontig titkosítottak a böngészőben: a
|
||||||
|
szerver csak a titkosított szöveget tárolja, és nem tudja elolvasni. Csak az alkalmazásba
|
||||||
|
beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen biztonságos, de
|
||||||
|
észszerű intézkedésekkel védjük az adataidat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="retention">Megőrzés és törlés</H2>
|
||||||
|
<p>
|
||||||
|
Az adataidat addig őrizzük, amíg a fiókod aktív. A fiókodat és az adataidat bármikor magad
|
||||||
|
is törölheted a Beállítások → Fiók fülön; ez a fiókodhoz tárolt naplófeljegyzéseket is
|
||||||
|
törli. A Siftlode hozzáférését a Google-adataidhoz a{" "}
|
||||||
|
<LegalLink href="https://myaccount.google.com/permissions">
|
||||||
|
myaccount.google.com/permissions
|
||||||
|
</LegalLink>{" "}
|
||||||
|
oldalon vonhatod vissza; ezután a tárolt tokenek már nem használhatók. Minden más ügyben
|
||||||
|
fordulj ide: <ContactEmail />.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="cookies">Sütik és böngészőtár</H2>
|
||||||
|
<p>
|
||||||
|
A Siftlode egyetlen aláírt munkamenet-sütit használ, hogy bejelentkezve maradj, a
|
||||||
|
megjelenítési beállításaidat pedig a böngésződ helyi tárában tartja. Egyiket sem használjuk
|
||||||
|
követésre vagy hirdetésre, és nincsenek harmadik féltől származó analitikai sütik.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="google">YouTube és Google</H2>
|
||||||
|
<p>
|
||||||
|
A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
|
||||||
|
<LegalLink href="https://www.youtube.com/t/terms">
|
||||||
|
YouTube Általános Szerződési Feltételeit
|
||||||
|
</LegalLink>
|
||||||
|
, a Google-adataid kezelésére pedig a{" "}
|
||||||
|
<LegalLink href="https://policies.google.com/privacy">
|
||||||
|
Google Adatvédelmi irányelvei
|
||||||
|
</LegalLink>{" "}
|
||||||
|
vonatkoznak.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="changes">Változások</H2>
|
||||||
|
<p>
|
||||||
|
Ezt a tájékoztatót módosíthatjuk; a lényeges változásokat a fenti „Utoljára frissítve” dátum
|
||||||
|
jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="contact">Kapcsolat</H2>
|
||||||
|
<p>
|
||||||
|
Kérdés a tájékoztatóval vagy az adataiddal kapcsolatban: <ContactEmail />.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
|
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
|
||||||
|
import { useHungarian } from "./useHungarian";
|
||||||
|
|
||||||
export default function Terms() {
|
export default function Terms() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hu = useHungarian();
|
||||||
return (
|
return (
|
||||||
<LegalLayout title="Terms of Service">
|
<LegalLayout title={t("common.termsOfService")}>{hu ? <TermsHu /> : <TermsEn />}</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermsEn() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<p>
|
<p>
|
||||||
Siftlode is a personal, non-commercial project operated by an individual in Hungary. It
|
Siftlode is a personal, non-commercial project operated by an individual in Hungary. It
|
||||||
provides a filterable view of your own YouTube subscriptions. By accessing or using
|
provides a filterable view of your own YouTube subscriptions. By accessing or using
|
||||||
Siftlode, you agree to these Terms. If you do not agree, do not use the service.
|
Siftlode, you agree to these Terms. If you do not agree, do not use the service.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>The service</H2>
|
<H2 id="service">The service</H2>
|
||||||
<p>
|
<p>
|
||||||
Siftlode is offered on an invite-only basis, free of charge, and "as is", without warranties
|
Siftlode is offered on an invite-only basis, free of charge, and "as is", without warranties
|
||||||
of any kind. It is a hobby project: features may change, and the service may be slowed,
|
of any kind. It is a hobby project: features may change, and the service may be slowed,
|
||||||
interrupted, or discontinued at any time without notice.
|
interrupted, or discontinued at any time without notice.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Your account & acceptable use</H2>
|
<H2 id="account">Your account & acceptable use</H2>
|
||||||
<p>
|
<p>
|
||||||
You sign in with your Google account and may only access Siftlode with an email that has
|
You sign in with your Google account or with an email address and password, and may only
|
||||||
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
|
access Siftlode with an email that has been admitted. You agree to use the service lawfully,
|
||||||
overload it, and not to attempt to access other users' data. We may suspend or revoke access
|
not to abuse, disrupt, probe, or overload it, and not to attempt to access other users'
|
||||||
at our discretion, for example in case of misuse.
|
data. We may suspend or revoke access at our discretion, for example in case of misuse.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Third-party services</H2>
|
<H2 id="third-party">Third-party services</H2>
|
||||||
<p>
|
<p>
|
||||||
Siftlode uses YouTube API Services. By using Siftlode you agree to be bound by the{" "}
|
Siftlode uses YouTube API Services. By using Siftlode you agree to be bound by the{" "}
|
||||||
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>. Your
|
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>. Your
|
||||||
@@ -36,7 +47,7 @@ export default function Terms() {
|
|||||||
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>No warranty & limitation of liability</H2>
|
<H2 id="liability">No warranty & limitation of liability</H2>
|
||||||
<p>
|
<p>
|
||||||
To the maximum extent permitted by law, the service is provided without warranty, and the
|
To the maximum extent permitted by law, the service is provided without warranty, and the
|
||||||
operator is not liable for any indirect, incidental, or consequential damages, or for any
|
operator is not liable for any indirect, incidental, or consequential damages, or for any
|
||||||
@@ -44,22 +55,94 @@ export default function Terms() {
|
|||||||
to stop using the service and revoke its access to your account.
|
to stop using the service and revoke its access to your account.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Governing law</H2>
|
<H2 id="law">Governing law</H2>
|
||||||
<p>
|
<p>
|
||||||
These Terms are governed by the laws of Hungary and applicable European Union law, without
|
These Terms are governed by the laws of Hungary and applicable European Union law, without
|
||||||
regard to conflict-of-law rules.
|
regard to conflict-of-law rules.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Changes</H2>
|
<H2 id="changes">Changes</H2>
|
||||||
<p>
|
<p>
|
||||||
We may update these Terms; the "Last updated" date above reflects the latest version.
|
We may update these Terms; the "Last updated" date above reflects the latest version.
|
||||||
Continued use after a change constitutes acceptance.
|
Continued use after a change constitutes acceptance.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H2>Contact</H2>
|
<H2 id="contact">Contact</H2>
|
||||||
<p>
|
<p>
|
||||||
Questions: contact <ContactEmail />.
|
Questions: contact <ContactEmail />.
|
||||||
</p>
|
</p>
|
||||||
</LegalLayout>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermsHu() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
|
||||||
|
Magyarországon. A saját YouTube-feliratkozásaid szűrhető nézetét nyújtja. A Siftlode
|
||||||
|
elérésével vagy használatával elfogadod ezeket a feltételeket. Ha nem fogadod el őket, ne
|
||||||
|
használd a szolgáltatást.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="service">A szolgáltatás</H2>
|
||||||
|
<p>
|
||||||
|
A Siftlode csak meghívással, ingyenesen és „ahogy van” alapon érhető el, mindenféle
|
||||||
|
szavatosság nélkül. Hobbiprojekt: a funkciói változhatnak, és a szolgáltatás bármikor,
|
||||||
|
előzetes értesítés nélkül lelassulhat, szünetelhet vagy megszűnhet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="account">A fiókod és az elfogadható használat</H2>
|
||||||
|
<p>
|
||||||
|
Google-fiókkal vagy e-mail-címmel és jelszóval jelentkezel be, és csak beengedett
|
||||||
|
e-mail-címmel használhatod a Siftlode-ot. Vállalod, hogy a szolgáltatást jogszerűen
|
||||||
|
használod, nem éled vissza, nem zavarod meg, nem próbálod ki a gyenge pontjait és nem
|
||||||
|
terheled túl, és nem próbálsz más felhasználók adataihoz hozzáférni. A hozzáférést saját
|
||||||
|
belátásunk szerint felfüggeszthetjük vagy visszavonhatjuk, például visszaélés esetén.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="third-party">Harmadik fél szolgáltatásai</H2>
|
||||||
|
<p>
|
||||||
|
A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
|
||||||
|
<LegalLink href="https://www.youtube.com/t/terms">
|
||||||
|
YouTube Általános Szerződési Feltételeit
|
||||||
|
</LegalLink>
|
||||||
|
. Az adataidat az{" "}
|
||||||
|
<a href="/privacy" className="text-accent hover:underline">
|
||||||
|
Adatvédelmi irányelvekben
|
||||||
|
</a>{" "}
|
||||||
|
leírtak szerint kezeljük, a Google-adatokra pedig a{" "}
|
||||||
|
<LegalLink href="https://policies.google.com/privacy">
|
||||||
|
Google Adatvédelmi irányelvei
|
||||||
|
</LegalLink>{" "}
|
||||||
|
is vonatkoznak.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="liability">Szavatosság kizárása és a felelősség korlátozása</H2>
|
||||||
|
<p>
|
||||||
|
A jogszabályok által megengedett legteljesebb mértékben a szolgáltatás szavatosság nélkül
|
||||||
|
érhető el, és az üzemeltető nem felel a Siftlode használatából eredő közvetett, járulékos
|
||||||
|
vagy következményes károkért, sem adatvesztésért. Ha elégedetlen vagy, az egyetlen
|
||||||
|
lehetőséged, hogy abbahagyod a szolgáltatás használatát, és visszavonod a hozzáférését a
|
||||||
|
fiókodhoz.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="law">Irányadó jog</H2>
|
||||||
|
<p>
|
||||||
|
Ezekre a feltételekre Magyarország jogszabályai és az alkalmazandó európai uniós jog
|
||||||
|
irányadók, a kollíziós szabályok figyelmen kívül hagyásával.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="changes">Változások</H2>
|
||||||
|
<p>
|
||||||
|
Ezeket a feltételeket módosíthatjuk; a fenti „Utoljára frissítve” dátum a legfrissebb
|
||||||
|
változatot jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2 id="contact">Kapcsolat</H2>
|
||||||
|
<p>
|
||||||
|
Kérdés esetén: <ContactEmail />.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The legal pages are written out once per language (a document, not UI strings — see LegalLayout).
|
||||||
|
// What keeps the two from drifting apart is this: both languages must have the SAME sections, in the
|
||||||
|
// same order, by anchor — so a section added to one (E22 S5 added the log sections) cannot be
|
||||||
|
// missing from the other, and a link to `/privacy#diagnostics` lands in either language.
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
vi.mock("../../lib/http", () => ({ send: () => Promise.resolve({ ok: false }) }));
|
||||||
|
|
||||||
|
import { PrivacyEn, PrivacyHu } from "./PrivacyPolicy";
|
||||||
|
import { TermsEn, TermsHu } from "./Terms";
|
||||||
|
|
||||||
|
const sections = (Body: ComponentType) => {
|
||||||
|
const { container } = render(<Body />);
|
||||||
|
const ids = [...container.querySelectorAll("h2")].map((h) => h.id);
|
||||||
|
cleanup();
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("legal pages", () => {
|
||||||
|
it.each([
|
||||||
|
["privacy", PrivacyEn, PrivacyHu],
|
||||||
|
["terms", TermsEn, TermsHu],
|
||||||
|
])("%s has the same sections in Hungarian and English", (_name, En, Hu) => {
|
||||||
|
const en = sections(En);
|
||||||
|
expect(en.every(Boolean)).toBe(true);
|
||||||
|
expect(sections(Hu)).toEqual(en);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the privacy policy has the section the diagnostics consent links to", () => {
|
||||||
|
expect(sections(PrivacyEn)).toContain("diagnostics");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
/** Whether a legal page renders its Hungarian body (see LegalLayout). */
|
||||||
|
export function useHungarian(): boolean {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
return i18n.language?.startsWith("hu") ?? false;
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ import CountBadge from "./CountBadge";
|
|||||||
|
|
||||||
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
|
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
|
||||||
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
|
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
|
||||||
// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
|
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up.
|
||||||
// to localStorage so a reload keeps the user where they were.
|
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
||||||
|
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
||||||
|
// it is a horizontal row that scrolls. The active tab is persisted to localStorage so a reload keeps
|
||||||
|
// the user where they were.
|
||||||
|
|
||||||
export interface TabDef {
|
export interface TabDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,7 +39,9 @@ export default function Tabs({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const vertical = orientation === "vertical";
|
const vertical = orientation === "vertical";
|
||||||
const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
|
const wrapCls =
|
||||||
|
className ??
|
||||||
|
(vertical ? "flex gap-1 overflow-x-auto sm:flex-col" : "flex flex-wrap gap-1 mb-4");
|
||||||
return (
|
return (
|
||||||
<div className={wrapCls}>
|
<div className={wrapCls}>
|
||||||
{tabs.map((tb) => {
|
{tabs.map((tb) => {
|
||||||
@@ -50,7 +55,7 @@ export default function Tabs({
|
|||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
className={`pill items-center rounded-lg px-3 text-sm ${
|
className={`pill items-center rounded-lg px-3 text-sm ${
|
||||||
vertical
|
vertical
|
||||||
? "flex w-full justify-start gap-2 py-2 text-left"
|
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
|
||||||
: "inline-flex gap-1.5 py-1.5"
|
: "inline-flex gap-1.5 py-1.5"
|
||||||
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
|
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E22 S5: a clicked control's label is logged only when it is the app's own copy — as its English
|
||||||
|
// wording — so nothing a user wrote (a title, a tag, a channel name) can come out of the click log.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { appCopyOf } from "./appCopy";
|
||||||
|
|
||||||
|
describe("appCopyOf", () => {
|
||||||
|
it("finds app copy in either language and reads it in English, whatever the whitespace", () => {
|
||||||
|
expect(appCopyOf("Turn on for 24 hours")).toBe("Turn on for 24 hours");
|
||||||
|
expect(appCopyOf(" Bekapcsolom\n 24 órára ")).toBe("Turn on for 24 hours");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wording several keys share still names the wording, not one arbitrary key", () => {
|
||||||
|
// "Diagnosztika" is both the Settings tab and the Configuration group.
|
||||||
|
expect(appCopyOf("Diagnosztika")).toBe("Diagnostics");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds nothing in what a user wrote, and never matches an interpolated string", () => {
|
||||||
|
expect(appCopyOf("My holiday video 2024")).toBeNull();
|
||||||
|
expect(appCopyOf("")).toBeNull();
|
||||||
|
expect(appCopyOf(null)).toBeNull();
|
||||||
|
expect(appCopyOf("{{count}} events · newest first")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import i18n from "./index";
|
||||||
|
|
||||||
|
// The translation KEY a piece of on-screen text is, or null (E22 S5 click capture).
|
||||||
|
//
|
||||||
|
// A click is logged by what the control IS, and its visible label is the most telling part — but a
|
||||||
|
// label can be the user's own content (a video title, a tag, a channel name), which the log must not
|
||||||
|
// carry. Text that is exactly one of the app's own translated strings is app copy, not user content,
|
||||||
|
// so it can be logged — as its ENGLISH wording, whatever the UI language: the same click reads the
|
||||||
|
// same in every log, and nothing a user typed comes out of it (a user's tag that happens to equal a
|
||||||
|
// string of ours is logged as our string, which says nothing about the user). Not as a key: one
|
||||||
|
// wording is often several keys ("Diagnostics" is a settings tab AND a config group), so a key would
|
||||||
|
// name a control the user may never have touched. Interpolated strings (`{{n}} items`) never match.
|
||||||
|
let index: Map<string, string> | null = null;
|
||||||
|
|
||||||
|
const normalize = (text: string) => text.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
/** Every app string (any language) → its English wording. */
|
||||||
|
function build(): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
const resources = i18n.options.resources ?? {};
|
||||||
|
const english = (path: string[]): string | null => {
|
||||||
|
let node: unknown = resources.en?.translation;
|
||||||
|
for (const key of path) node = (node as Record<string, unknown> | undefined)?.[key];
|
||||||
|
return typeof node === "string" ? normalize(node) : null;
|
||||||
|
};
|
||||||
|
const walk = (node: unknown, path: string[]) => {
|
||||||
|
if (typeof node === "string") {
|
||||||
|
const text = normalize(node);
|
||||||
|
const en = english(path);
|
||||||
|
if (en && text.length >= 2 && !text.includes("{{") && !map.has(text)) map.set(text, en);
|
||||||
|
} else if (node && typeof node === "object") {
|
||||||
|
for (const [k, v] of Object.entries(node)) walk(v, [...path, k]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const lang of Object.keys(resources)) walk(resources[lang]?.translation, []);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appCopyOf(text: string | null | undefined): string | null {
|
||||||
|
if (!text) return null;
|
||||||
|
index ??= build();
|
||||||
|
return index.get(normalize(text)) ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"lastUpdated": "Last updated: {{date}}",
|
||||||
|
"home": "Home",
|
||||||
|
"contact": "Contact",
|
||||||
|
"operatorFallback": "your instance's administrator",
|
||||||
|
"languageLabel": "Language"
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"account": "Account"
|
"account": "Account",
|
||||||
|
"diagnostics": "Diagnostics"
|
||||||
},
|
},
|
||||||
"plexSync": {
|
"plexSync": {
|
||||||
"title": "Plex watch sync",
|
"title": "Plex watch sync",
|
||||||
@@ -190,5 +191,61 @@
|
|||||||
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your inbox instead, and fix it under Configuration → Email / SMTP.",
|
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your inbox instead, and fix it under Configuration → Email / SMTP.",
|
||||||
"deletedStillAllowed": "Entry removed, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
|
"deletedStillAllowed": "Entry removed, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
|
||||||
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so removing this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in."
|
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so removing this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in."
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"title": "Logging level",
|
||||||
|
"level": "Level",
|
||||||
|
"levelHint": "At Basic, the log lives only in this browser tab — errors, warnings, page changes and requests — and nothing is sent to the server. Detailed and Full record more and send it to the server for 24 hours, only with your consent.",
|
||||||
|
"levels": {
|
||||||
|
"INFO": "Basic",
|
||||||
|
"DEBUG": "Detailed",
|
||||||
|
"TRACE": "Full"
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"title": {
|
||||||
|
"DEBUG": "Turn on detailed logging for 24 hours",
|
||||||
|
"TRACE": "Turn on full logging for 24 hours"
|
||||||
|
},
|
||||||
|
"what": {
|
||||||
|
"DEBUG": "We record which buttons, filters and player controls you use, and the values in the address bar.",
|
||||||
|
"TRACE": "We record which buttons, filters and player controls you use, the values in the address bar, and the text you type into search."
|
||||||
|
},
|
||||||
|
"server_one": "The Siftlode server receives this too and keeps it for {{count}} day, so the problem can be tracked down.",
|
||||||
|
"server_other": "The Siftlode server receives this too and keeps it for {{count}} days, so the problem can be tracked down.",
|
||||||
|
"never": "Passwords, tokens, email addresses and the content of your messages are never included.",
|
||||||
|
"lapse": "After 24 hours it goes back to Basic by itself.",
|
||||||
|
"policy": "Privacy policy",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Turn on for 24 hours"
|
||||||
|
},
|
||||||
|
"status": "{{level}} logging · back to Basic in {{time}}",
|
||||||
|
"hoursMinutes": "{{h}} h {{m}} min",
|
||||||
|
"minutes": "{{m}} min",
|
||||||
|
"revert": "Back to Basic now",
|
||||||
|
"demo": "The demo account is shared by several people, so detailed logging can't be turned on here.",
|
||||||
|
"failed": "Couldn't change the logging level"
|
||||||
|
},
|
||||||
|
"myLog": {
|
||||||
|
"title": "My log",
|
||||||
|
"hint": "What this browser tab has recorded. It is gone when the page reloads.",
|
||||||
|
"search": "Search the events…",
|
||||||
|
"filterLabel": "Filter by level",
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"error": "Errors",
|
||||||
|
"warning": "Warnings",
|
||||||
|
"info": "Info",
|
||||||
|
"detail": "Detailed"
|
||||||
|
},
|
||||||
|
"count_one": "{{count}} event · newest first",
|
||||||
|
"count_other": "{{count}} events · newest first",
|
||||||
|
"capped": "showing the newest {{shown}} of {{count}}",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied_one": "Copied {{count}} event",
|
||||||
|
"copied_other": "Copied {{count}} events",
|
||||||
|
"copyFailed": "Couldn't copy to the clipboard",
|
||||||
|
"clear": "Clear",
|
||||||
|
"empty": "No event matches the filter.",
|
||||||
|
"emptyAll": "No events yet."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"lastUpdated": "Utoljára frissítve: {{date}}",
|
||||||
|
"home": "Kezdőlap",
|
||||||
|
"contact": "Kapcsolat",
|
||||||
|
"operatorFallback": "a példány adminisztrátora",
|
||||||
|
"languageLabel": "Nyelv"
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"appearance": "Megjelenés",
|
"appearance": "Megjelenés",
|
||||||
"notifications": "Értesítések",
|
"notifications": "Értesítések",
|
||||||
"account": "Fiók"
|
"account": "Fiók",
|
||||||
|
"diagnostics": "Diagnosztika"
|
||||||
},
|
},
|
||||||
"plexSync": {
|
"plexSync": {
|
||||||
"title": "Plex nézettség-szinkron",
|
"title": "Plex nézettség-szinkron",
|
||||||
@@ -190,5 +191,61 @@
|
|||||||
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az értesítéseidet figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
|
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az értesítéseidet figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
|
||||||
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
|
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
|
||||||
"deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik."
|
"deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik."
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"title": "Naplózási szint",
|
||||||
|
"level": "Szint",
|
||||||
|
"levelHint": "Alap szinten a napló csak ebben a böngészőlapban él (hibák, figyelmeztetések, oldalváltások, kérések), és semmi nem megy a szerverre. A Részletes és a Teljes többet rögzít, és 24 óráig a szervernek is elküldi, csak a beleegyezéseddel.",
|
||||||
|
"levels": {
|
||||||
|
"INFO": "Alap",
|
||||||
|
"DEBUG": "Részletes",
|
||||||
|
"TRACE": "Teljes"
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"title": {
|
||||||
|
"DEBUG": "Részletes naplózás bekapcsolása 24 órára",
|
||||||
|
"TRACE": "Teljes naplózás bekapcsolása 24 órára"
|
||||||
|
},
|
||||||
|
"what": {
|
||||||
|
"DEBUG": "Rögzítjük, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a címsor paramétereinek értékét.",
|
||||||
|
"TRACE": "Rögzítjük, melyik gombot, szűrőt és lejátszóvezérlőt használod, a címsor paramétereinek értékét, és a keresőbe beírt szöveget is."
|
||||||
|
},
|
||||||
|
"server_one": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
|
||||||
|
"server_other": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
|
||||||
|
"never": "Jelszó, token, e-mail-cím és az üzeneteid tartalma soha nem kerül bele.",
|
||||||
|
"lapse": "24 óra múlva magától visszaáll Alapra.",
|
||||||
|
"policy": "Adatvédelmi irányelvek",
|
||||||
|
"cancel": "Mégse",
|
||||||
|
"confirm": "Bekapcsolom 24 órára"
|
||||||
|
},
|
||||||
|
"status": "{{level}} naplózás · visszaáll Alapra {{time}} múlva",
|
||||||
|
"hoursMinutes": "{{h}} ó {{m}} p",
|
||||||
|
"minutes": "{{m}} p",
|
||||||
|
"revert": "Visszaállítás most",
|
||||||
|
"demo": "A demo fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.",
|
||||||
|
"failed": "Nem sikerült módosítani a naplózási szintet"
|
||||||
|
},
|
||||||
|
"myLog": {
|
||||||
|
"title": "Saját napló",
|
||||||
|
"hint": "Amit ez a böngészőlap rögzített. Az oldal újratöltésekor törlődik.",
|
||||||
|
"search": "Keresés az eseményekben…",
|
||||||
|
"filterLabel": "Szűrés szintre",
|
||||||
|
"filters": {
|
||||||
|
"all": "Mind",
|
||||||
|
"error": "Hibák",
|
||||||
|
"warning": "Figyelmeztetések",
|
||||||
|
"info": "Info",
|
||||||
|
"detail": "Részletes"
|
||||||
|
},
|
||||||
|
"count_one": "{{count}} esemény · a legújabb felül",
|
||||||
|
"count_other": "{{count}} esemény · a legújabb felül",
|
||||||
|
"capped": "a legújabb {{shown}} látszik a(z) {{count}} közül",
|
||||||
|
"copy": "Másolás",
|
||||||
|
"copied_one": "Kimásolva: {{count}} esemény",
|
||||||
|
"copied_other": "Kimásolva: {{count}} esemény",
|
||||||
|
"copyFailed": "Nem sikerült a vágólapra másolni",
|
||||||
|
"clear": "Ürítés",
|
||||||
|
"empty": "Nincs a szűrésnek megfelelő esemény.",
|
||||||
|
"emptyAll": "Még nincs esemény."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ import { messagingApi } from "./api/messaging";
|
|||||||
export type * from "./api/messaging";
|
export type * from "./api/messaging";
|
||||||
import { downloadsApi } from "./api/downloads";
|
import { downloadsApi } from "./api/downloads";
|
||||||
export type * from "./api/downloads";
|
export type * from "./api/downloads";
|
||||||
|
import { diagnosticsApi } from "./api/diagnostics";
|
||||||
|
export type * from "./api/diagnostics";
|
||||||
|
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
...accountApi,
|
...accountApi,
|
||||||
@@ -86,4 +89,5 @@ export const api = {
|
|||||||
...plexApi,
|
...plexApi,
|
||||||
...messagingApi,
|
...messagingApi,
|
||||||
...downloadsApi,
|
...downloadsApi,
|
||||||
|
...diagnosticsApi,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
|
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
|
||||||
// app version, and preference save. Only touches core's req.
|
// app version, and preference save. Only touches core's req.
|
||||||
import { req } from "./core";
|
import { req } from "./core";
|
||||||
|
import type { DiagnosticsState } from "./diagnostics";
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,6 +29,8 @@ export interface Me {
|
|||||||
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
|
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
|
||||||
email_configured: boolean;
|
email_configured: boolean;
|
||||||
preferences: Record<string, unknown>;
|
preferences: Record<string, unknown>;
|
||||||
|
/** The consented diagnostics level in force and when it lapses (E22 S5). */
|
||||||
|
diagnostics: DiagnosticsState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and
|
||||||
|
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
|
||||||
|
import type { LogEvent } from "../log";
|
||||||
|
import { beacon, req } from "./core";
|
||||||
|
|
||||||
|
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
|
||||||
|
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
|
||||||
|
|
||||||
|
export interface DiagnosticsState {
|
||||||
|
level: DiagnosticsLevel;
|
||||||
|
/** When a raised level lapses back to INFO (ISO); null at INFO. */
|
||||||
|
until: string | null;
|
||||||
|
/** How many days this instance keeps stored log lines — the consent text states it. */
|
||||||
|
retention_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or
|
||||||
|
* every batch would carry the previous batch's request line forever. */
|
||||||
|
export const DIAGNOSTICS_EVENTS_PATH = "/api/diagnostics/events";
|
||||||
|
|
||||||
|
export const diagnosticsApi = {
|
||||||
|
setDiagnosticsLevel: (
|
||||||
|
level: DiagnosticsLevel,
|
||||||
|
): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
|
||||||
|
req("/api/me/diagnostics", { method: "PUT", body: JSON.stringify({ level }) }),
|
||||||
|
// Quiet: a lost batch is not the user's problem to be told about in a dialog.
|
||||||
|
sendLogEvents: (events: LogEvent[]): Promise<{ ok: true; stored: number }> =>
|
||||||
|
req(
|
||||||
|
DIAGNOSTICS_EVENTS_PATH,
|
||||||
|
{ method: "POST", body: JSON.stringify({ events }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
/** The last batch of a closing tab (keepalive, fire-and-forget). */
|
||||||
|
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }),
|
||||||
|
};
|
||||||
@@ -32,6 +32,7 @@ const SLICE_MODULES = [
|
|||||||
["plexApi", () => import("./plex")],
|
["plexApi", () => import("./plex")],
|
||||||
["messagingApi", () => import("./messaging")],
|
["messagingApi", () => import("./messaging")],
|
||||||
["downloadsApi", () => import("./downloads")],
|
["downloadsApi", () => import("./downloads")],
|
||||||
|
["diagnosticsApi", () => import("./diagnostics")],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
let api: Slice;
|
let api: Slice;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E22 S5: the detail the consented levels add — filter changes and what a player went through.
|
||||||
|
// Below Részletes they cost nothing and log nothing, except a media error, which is always a warning.
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { logFilterChange } from "./filterLog";
|
||||||
|
import { clear, events, setLevel } from "./log";
|
||||||
|
import { logMediaElement, logYouTubeError, logYouTubeState } from "./mediaLog";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clear();
|
||||||
|
setLevel("INFO");
|
||||||
|
});
|
||||||
|
|
||||||
|
const attrsOf = (code: string) =>
|
||||||
|
events()
|
||||||
|
.filter((e) => e.event === code)
|
||||||
|
.map((e) => e.attrs);
|
||||||
|
|
||||||
|
describe("filter changes", () => {
|
||||||
|
it("are not logged at the base level", () => {
|
||||||
|
logFilterChange("feed", { tags: [3] }, "lofi");
|
||||||
|
expect(events()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("log their values at DEBUG, but only whether there is a search", () => {
|
||||||
|
setLevel("DEBUG");
|
||||||
|
logFilterChange("feed", { tags: [3], sort: "newest", q: "lofi beats" }, "lofi beats");
|
||||||
|
expect(attrsOf("filter.change")).toEqual([
|
||||||
|
{ scope: "feed", tags: [3], sort: "newest", query_set: true },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(events())).not.toContain("lofi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("log the typed search at TRACE, and never the display titles riding in a filter", () => {
|
||||||
|
setLevel("TRACE");
|
||||||
|
logFilterChange("plex", { filters: { collections: ["9"] }, collectionTitles: { 9: "Mine" } });
|
||||||
|
logFilterChange("plex", {}, "alien");
|
||||||
|
expect(attrsOf("filter.change")).toEqual([
|
||||||
|
{ scope: "plex", filters: { collections: ["9"] } },
|
||||||
|
{ scope: "plex", query: "alien" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("player events", () => {
|
||||||
|
it("a media error is a warning at every level; the rest waits for DEBUG", () => {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const detach = logMediaElement(video, "plex");
|
||||||
|
video.dispatchEvent(new Event("waiting"));
|
||||||
|
video.dispatchEvent(new Event("error"));
|
||||||
|
logYouTubeState(3, 12.4);
|
||||||
|
logYouTubeError(150);
|
||||||
|
expect(events().map((e) => [e.event, e.level])).toEqual([
|
||||||
|
["player.media_error", "WARNING"],
|
||||||
|
["player.media_error", "WARNING"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
clear();
|
||||||
|
setLevel("DEBUG");
|
||||||
|
video.dispatchEvent(new Event("waiting"));
|
||||||
|
logYouTubeState(3, 12.4);
|
||||||
|
detach();
|
||||||
|
video.dispatchEvent(new Event("stalled"));
|
||||||
|
expect(attrsOf("player.media")).toEqual([
|
||||||
|
{ player: "plex", event: "waiting", at: 0 },
|
||||||
|
{ player: "youtube", event: "buffering", at: 12 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E22 S5: the consented level applied to the tab, and the stream it opens. The module keeps its state
|
||||||
|
// at module level (what is applied, the queue), so every test imports a fresh copy — and the log
|
||||||
|
// from that same copy, or it would be reading a different ring.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const sent: unknown[][] = [];
|
||||||
|
const unloaded: unknown[][] = [];
|
||||||
|
let answer: () => Promise<unknown> = () => Promise.resolve({ ok: true, stored: 0 });
|
||||||
|
|
||||||
|
vi.mock("./api", async () => {
|
||||||
|
const core = await vi.importActual<typeof import("./api/core")>("./api/core");
|
||||||
|
return {
|
||||||
|
HttpError: core.HttpError,
|
||||||
|
DIAGNOSTICS_EVENTS_PATH: "/api/diagnostics/events",
|
||||||
|
api: {
|
||||||
|
sendLogEvents: (events: unknown[]) => {
|
||||||
|
sent.push(events);
|
||||||
|
return answer();
|
||||||
|
},
|
||||||
|
sendLogEventsOnUnload: (events: unknown[]) => void unloaded.push(events),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.resetModules();
|
||||||
|
sent.length = 0;
|
||||||
|
unloaded.length = 0;
|
||||||
|
answer = () => Promise.resolve({ ok: true, stored: 0 });
|
||||||
|
});
|
||||||
|
let current: typeof import("./diagnostics") | null = null;
|
||||||
|
afterEach(() => {
|
||||||
|
// A module copy left streaming would still hear `pagehide` in the next test.
|
||||||
|
current?.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||||
|
current = null;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fresh() {
|
||||||
|
const diagnostics = await import("./diagnostics");
|
||||||
|
current = diagnostics;
|
||||||
|
const logMod = await import("./log");
|
||||||
|
const apiMod = await import("./api");
|
||||||
|
return { ...diagnostics, ...logMod, HttpError: apiMod.HttpError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
|
||||||
|
level,
|
||||||
|
until: new Date(Date.now() + hours * 3_600_000).toISOString(),
|
||||||
|
retention_days: 7,
|
||||||
|
});
|
||||||
|
const codes = (batch: unknown[] | undefined) =>
|
||||||
|
(batch ?? []).map((e) => (e as { event: string }).event);
|
||||||
|
|
||||||
|
describe("diagnostics level", () => {
|
||||||
|
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||||
|
d.log.info("nav.view", "x");
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
expect(d.enabled("DEBUG")).toBe(false);
|
||||||
|
expect(codes(d.events())).toEqual(["nav.view"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a raised level streams only what happens after it, without its own request lines", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
d.log.info("before.consent", "not sent");
|
||||||
|
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||||
|
expect(d.enabled("DEBUG")).toBe(true);
|
||||||
|
d.log.debug("ui.click", "click");
|
||||||
|
d.log.request("INFO", "POST", { path: "/api/diagnostics/events", status: 200 }, "abcdef0123");
|
||||||
|
d.log.request("INFO", "GET", { path: "/api/feed", status: 200 }, "abcdef0124");
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(codes(sent[0])).toEqual(["diagnostics.level", "ui.click", "http.request"]);
|
||||||
|
expect(JSON.stringify(sent[0])).not.toContain("/api/diagnostics/events");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||||
|
for (let i = 0; i < 250; i++) d.log.debug("ui.click", `click ${i}`);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(sent.map((b) => b.length)).toEqual([100]);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(sent.every((b) => b.length <= 100)).toBe(true);
|
||||||
|
expect(sent.flat()).toHaveLength(251); // the 250 clicks + the line that opened the stream
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at once when the level is lowered, and drops what was not sent yet", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
d.applyDiagnostics(raised("TRACE"), () => {});
|
||||||
|
d.log.trace("search.typed", "lofi");
|
||||||
|
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||||
|
d.log.info("nav.view", "after");
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
expect(d.enabled("TRACE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lapses on its own timer and tells the app to refetch", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
const lapsed = vi.fn();
|
||||||
|
d.applyDiagnostics(raised("DEBUG", 1), lapsed);
|
||||||
|
await vi.advanceTimersByTimeAsync(3_600_000 + 1);
|
||||||
|
expect(lapsed).toHaveBeenCalledOnce();
|
||||||
|
expect(d.enabled("DEBUG")).toBe(false);
|
||||||
|
sent.length = 0;
|
||||||
|
d.log.info("nav.view", "after the lapse");
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-expired level is the base level", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
d.applyDiagnostics(raised("DEBUG", -1), () => {});
|
||||||
|
expect(d.enabled("DEBUG")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the server saying it is off stops the stream; being told to slow down keeps the batch", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
const lapsed = vi.fn();
|
||||||
|
d.applyDiagnostics(raised("DEBUG"), lapsed);
|
||||||
|
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
|
||||||
|
d.log.debug("ui.click", "kept for later");
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
answer = () => Promise.reject(new d.HttpError(409, "off"));
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(codes(sent[1])).toEqual(codes(sent[0])); // the same batch, retried after the 429
|
||||||
|
expect(lapsed).toHaveBeenCalledOnce();
|
||||||
|
expect(d.enabled("DEBUG")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a closing tab sends what is still waiting with a keepalive request", async () => {
|
||||||
|
const d = await fresh();
|
||||||
|
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||||
|
d.log.debug("ui.click", "last click");
|
||||||
|
window.dispatchEvent(new Event("pagehide"));
|
||||||
|
expect(codes(unloaded[0])).toEqual(["diagnostics.level", "ui.click"]);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// The user's consented diagnostics level, applied to this tab (E22 S5).
|
||||||
|
//
|
||||||
|
// `applyDiagnostics(me.diagnostics)` runs whenever `me` answers. It sets the client log's level, and
|
||||||
|
// while that level is raised (Részletes / Teljes) it streams the log to the server:
|
||||||
|
// * only events kept AFTER the level was raised — the consent is for what happens from then on;
|
||||||
|
// * in batches, every FLUSH_MS or once BATCH_MAX are waiting, and the rest with a keepalive
|
||||||
|
// request when the tab closes;
|
||||||
|
// * never the lines about its own requests, or each batch would carry the previous one's;
|
||||||
|
// * and it stops at once when the level is lowered, when it lapses (a timer, not the next `me`
|
||||||
|
// refetch), or when the server answers that it is off.
|
||||||
|
// A lost batch is dropped, not retried in a loop: this is a diagnostic stream, not a ledger, and a
|
||||||
|
// retry storm against a struggling server is the last thing a diagnosis needs. The one exception is
|
||||||
|
// being told to slow down (429): that batch goes back to the front of the queue.
|
||||||
|
import { api, DIAGNOSTICS_EVENTS_PATH, HttpError, type DiagnosticsState } from "./api";
|
||||||
|
import { log, setLevel, subscribe, type LogEvent } from "./log";
|
||||||
|
|
||||||
|
const FLUSH_MS = 5_000;
|
||||||
|
/** Per request. The server takes up to 200; staying under it leaves room for a burst. */
|
||||||
|
const BATCH_MAX = 100;
|
||||||
|
/** Waiting events kept while the server is slow or away; past it the oldest go. */
|
||||||
|
const QUEUE_MAX = 1_000;
|
||||||
|
|
||||||
|
/** The level actually in force: the consented one until it lapses. */
|
||||||
|
export function levelInForce(state: DiagnosticsState | null | undefined, now = Date.now()) {
|
||||||
|
if (!state || state.level === "INFO" || !state.until) return "INFO";
|
||||||
|
return Date.parse(state.until) > now ? state.level : "INFO";
|
||||||
|
}
|
||||||
|
|
||||||
|
let applied: string | null = null;
|
||||||
|
let lapseTimer: number | null = null;
|
||||||
|
let onLapsed: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** Apply `state` to this tab. `lapsed` is called when a raised level runs out or the server says it
|
||||||
|
* is off, so the caller can refetch `me` (and the Settings tab can show the base level again). */
|
||||||
|
export function applyDiagnostics(
|
||||||
|
state: DiagnosticsState | null | undefined,
|
||||||
|
lapsed: () => void,
|
||||||
|
): void {
|
||||||
|
onLapsed = lapsed;
|
||||||
|
const level = levelInForce(state);
|
||||||
|
const key = level === "INFO" ? "INFO" : `${level}@${state?.until}`;
|
||||||
|
if (key === applied) return;
|
||||||
|
const wasRaised = applied !== null && applied !== "INFO";
|
||||||
|
applied = key;
|
||||||
|
if (lapseTimer !== null) window.clearTimeout(lapseTimer);
|
||||||
|
lapseTimer = null;
|
||||||
|
if (level === "INFO") {
|
||||||
|
stopStreaming();
|
||||||
|
setLevel("INFO");
|
||||||
|
// Only a CHANGE is worth a line; every tab starts at the base level.
|
||||||
|
if (wasRaised) log.info("diagnostics.level", "diagnostics back at the base level", { level });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLevel(level);
|
||||||
|
startStreaming(); // first, so the stream opens with the line saying why it exists
|
||||||
|
log.info("diagnostics.level", "diagnostics level raised", { level, until: state?.until });
|
||||||
|
// setTimeout's delay is a signed 32-bit number; a longer wait would fire at once. The server
|
||||||
|
// grants 24 h, so the clamp only matters for a clock that is badly off.
|
||||||
|
const wait = Math.min(Date.parse(state!.until!) - Date.now(), 2 ** 31 - 1);
|
||||||
|
lapseTimer = window.setTimeout(lapse, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lapse(): void {
|
||||||
|
lapseTimer = null;
|
||||||
|
applied = "INFO";
|
||||||
|
stopStreaming();
|
||||||
|
setLevel("INFO");
|
||||||
|
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" });
|
||||||
|
onLapsed?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- streaming ----------------------------------------------------------------------------------
|
||||||
|
let queue: LogEvent[] = [];
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let flushTimer: number | null = null;
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
|
const isOwnRequest = (event: LogEvent) =>
|
||||||
|
event.event === "http.request" && event.attrs?.path === DIAGNOSTICS_EVENTS_PATH;
|
||||||
|
|
||||||
|
function startStreaming(): void {
|
||||||
|
if (unsubscribe) return;
|
||||||
|
unsubscribe = subscribe((event) => {
|
||||||
|
if (!event || isOwnRequest(event)) return;
|
||||||
|
queue.push(event);
|
||||||
|
if (queue.length > QUEUE_MAX) queue.splice(0, queue.length - QUEUE_MAX);
|
||||||
|
if (queue.length >= BATCH_MAX) void flush();
|
||||||
|
});
|
||||||
|
flushTimer = window.setInterval(() => void flush(), FLUSH_MS);
|
||||||
|
window.addEventListener("pagehide", flushOnUnload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStreaming(): void {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
if (flushTimer !== null) window.clearInterval(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
window.removeEventListener("pagehide", flushOnUnload);
|
||||||
|
// What was captured under the consent but not yet sent stays in the tab's own log, not the server's.
|
||||||
|
queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
if (inFlight || !queue.length) return;
|
||||||
|
inFlight = true;
|
||||||
|
const batch = queue.splice(0, BATCH_MAX);
|
||||||
|
try {
|
||||||
|
await api.sendLogEvents(batch);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError && err.status === 429) {
|
||||||
|
queue.unshift(...batch);
|
||||||
|
if (queue.length > QUEUE_MAX) queue.length = QUEUE_MAX;
|
||||||
|
} else if (err instanceof HttpError && err.status === 409) {
|
||||||
|
lapse(); // the server has it off: withdrawn in another tab, or lapsed by its clock
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushOnUnload(): void {
|
||||||
|
while (queue.length) api.sendLogEventsOnUnload(queue.splice(0, BATCH_MAX));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// A filter/sort/view change, for the client log (E22 S5). Filter state is what makes "the feed shows
|
||||||
|
// nothing" diagnosable, so its VALUES are logged — at Részletes (DEBUG), the level the user consented
|
||||||
|
// to for exactly that. What the user TYPED into a search box is Teljes (TRACE) only; below it the log
|
||||||
|
// just says whether there is a search at all.
|
||||||
|
import { enabled, log } from "./log";
|
||||||
|
|
||||||
|
// Keys that are display data riding along in a filter object, not filter state.
|
||||||
|
const NOT_STATE = new Set(["q", "collectionTitles"]);
|
||||||
|
|
||||||
|
export function logFilterChange(scope: string, values: object, typed?: string): void {
|
||||||
|
if (!enabled("DEBUG")) return;
|
||||||
|
const attrs: Record<string, unknown> = { scope };
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (!NOT_STATE.has(key)) attrs[key] = value;
|
||||||
|
}
|
||||||
|
if (typed !== undefined) {
|
||||||
|
if (enabled("TRACE")) attrs.query = typed;
|
||||||
|
else attrs.query_set = typed !== "";
|
||||||
|
}
|
||||||
|
log.debug("filter.change", `${scope} filters changed`, attrs);
|
||||||
|
}
|
||||||
+30
-3
@@ -4,8 +4,9 @@
|
|||||||
// the same fields and level names as a server line, so a client batch can land in `log_events` beside
|
// the same fields and level names as a server line, so a client batch can land in `log_events` beside
|
||||||
// the server's own rows and a request joins across the two by `request_id`.
|
// the server's own rows and a request joins across the two by `request_id`.
|
||||||
//
|
//
|
||||||
// Events are kept in memory only, in a bounded ring: nothing leaves the browser from here. (Sending a
|
// Events are kept in memory only, in a bounded ring: nothing leaves the browser from here. Sending
|
||||||
// batch is the diagnostics switch's job, and only with the user's consent — E22 S5.)
|
// them is `lib/diagnostics.ts`'s job, and only while the user's consented level is raised (E22 S5);
|
||||||
|
// it and the Settings log viewer follow the ring through `subscribe`.
|
||||||
//
|
//
|
||||||
// REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler,
|
// REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler,
|
||||||
// held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`).
|
// held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`).
|
||||||
@@ -144,9 +145,34 @@ export function events(): LogEvent[] {
|
|||||||
return ring.slice();
|
return ring.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop every kept event (tests; later the viewer's "clear"). */
|
/** Drop every kept event (the viewer's "clear", tests). */
|
||||||
export function clear(): void {
|
export function clear(): void {
|
||||||
ring = [];
|
ring = [];
|
||||||
|
notify(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Told about every kept event as it is kept (`null` = the ring was cleared). A listener that throws
|
||||||
|
// is its own problem: it must not stop the others, nor the code that logged.
|
||||||
|
type Listener = (event: LogEvent | null) => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
/** Follow the ring: called with each event once it is kept, and with `null` when it is cleared.
|
||||||
|
* Returns the unsubscribe. */
|
||||||
|
export function subscribe(listener: Listener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(event: LogEvent | null): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch {
|
||||||
|
// see Listener
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(
|
function emit(
|
||||||
@@ -172,6 +198,7 @@ function emit(
|
|||||||
if (requestId) entry.request_id = requestId;
|
if (requestId) entry.request_id = requestId;
|
||||||
ring.push(entry);
|
ring.push(entry);
|
||||||
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
||||||
|
notify(entry);
|
||||||
} catch {
|
} catch {
|
||||||
// Logging must never break the code that logs (an attr with a throwing getter, a cycle).
|
// Logging must never break the code that logs (an attr with a throwing getter, a cycle).
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import { installClientLog } from "./logHooks";
|
|||||||
|
|
||||||
// History is wrapped ONCE per window (installClientLog is idempotent), so this file installs once
|
// History is wrapped ONCE per window (installClientLog is idempotent), so this file installs once
|
||||||
// and every test reads only what it caused.
|
// and every test reads only what it caused.
|
||||||
|
// The app passes i18n/appCopy.ts; here only "Frissítés" (and "Refresh") is app copy, read as "Refresh".
|
||||||
|
const appCopy = (text: string | null) =>
|
||||||
|
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
installClientLog();
|
installClientLog(appCopy);
|
||||||
installClientLog(); // a hot reload's second call must not wrap History twice
|
installClientLog(appCopy); // a hot reload's second call must not wrap History twice
|
||||||
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
|
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
|
||||||
clear();
|
clear();
|
||||||
});
|
});
|
||||||
@@ -107,3 +111,70 @@ describe("client log hooks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("control capture (a raised diagnostics level only)", () => {
|
||||||
|
const clicks = () => events().filter((e) => e.event === "ui.click");
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records nothing at the base level", () => {
|
||||||
|
document.body.innerHTML = `<button data-testid="feed-refresh">Refresh</button>`;
|
||||||
|
document.querySelector("button")!.click();
|
||||||
|
expect(clicks()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a control by its test id, icon, app-copy label and state", () => {
|
||||||
|
setLevel("DEBUG");
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "");
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div data-testid="feed-toolbar">
|
||||||
|
<button aria-pressed="true"><svg class="lucide lucide-refresh-cw"></svg> Frissítés </button>
|
||||||
|
</div>`;
|
||||||
|
document.querySelector("svg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(clicks().map((e) => e.attrs)).toEqual([
|
||||||
|
{
|
||||||
|
tag: "button",
|
||||||
|
within: "feed-toolbar",
|
||||||
|
icon: "refresh-cw",
|
||||||
|
label: "Refresh",
|
||||||
|
pressed: "true",
|
||||||
|
page: "feed",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never records what the user wrote: a title on a card, an input's value, a link's query", () => {
|
||||||
|
setLevel("TRACE");
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<button data-testid="video-card">My secret holiday video</button>
|
||||||
|
<input type="text" value="typed private words" />
|
||||||
|
<input type="checkbox" checked />
|
||||||
|
<a href="/watch/Ab3xYz9Tok?token=zzz">Some video title</a>
|
||||||
|
<a href="https://example.org/profile/someone">elsewhere</a>
|
||||||
|
<div role="button" aria-label="Delete tag holiday2024">x</div>`;
|
||||||
|
for (const el of document.querySelectorAll("button, input, a, [role=button]")) {
|
||||||
|
el.addEventListener("click", (e) => e.preventDefault());
|
||||||
|
(el as HTMLElement).click();
|
||||||
|
}
|
||||||
|
const text = JSON.stringify(clicks());
|
||||||
|
for (const secret of [
|
||||||
|
"holiday",
|
||||||
|
"typed private",
|
||||||
|
"Ab3xYz9Tok",
|
||||||
|
"zzz",
|
||||||
|
"Some video",
|
||||||
|
"someone",
|
||||||
|
]) {
|
||||||
|
expect(text).not.toContain(secret);
|
||||||
|
}
|
||||||
|
expect(clicks().map((e) => e.attrs)).toMatchObject([
|
||||||
|
{ control: "video-card" },
|
||||||
|
{ tag: "input", type: "text" },
|
||||||
|
{ tag: "input", type: "checkbox", checked: false }, // the state the click is turning it to
|
||||||
|
{ tag: "a", href: "/watch/{}" },
|
||||||
|
{ tag: "a", href: "external" },
|
||||||
|
{ tag: "div", role: "button" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// The client log's app-wide hooks (E22 S4), installed once from main.tsx before anything renders:
|
// The client log's app-wide hooks (E22 S4), installed once from main.tsx before anything renders:
|
||||||
// the start snapshot, uncaught errors and rejections, and navigation.
|
// the start snapshot, uncaught errors and rejections, navigation, connectivity, and — only while the
|
||||||
|
// user's consented level is raised (E22 S5) — which control was used and what the tab itself went
|
||||||
|
// through (hidden/shown, restored from the back-forward cache, resized).
|
||||||
//
|
//
|
||||||
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
|
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
|
||||||
// overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` /
|
// overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` /
|
||||||
@@ -61,10 +63,112 @@ function recordView(via: Via): void {
|
|||||||
log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view });
|
log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What counts as a control for click capture: anything a user operates.
|
||||||
|
const CONTROL = [
|
||||||
|
"button",
|
||||||
|
"a[href]",
|
||||||
|
"input",
|
||||||
|
"select",
|
||||||
|
"textarea",
|
||||||
|
"summary",
|
||||||
|
...[
|
||||||
|
"button",
|
||||||
|
"link",
|
||||||
|
"tab",
|
||||||
|
"menuitem",
|
||||||
|
"menuitemradio",
|
||||||
|
"menuitemcheckbox",
|
||||||
|
"option",
|
||||||
|
"switch",
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
].map((role) => `[role="${role}"]`),
|
||||||
|
].join(", ");
|
||||||
|
const ARIA_STATE = ["aria-pressed", "aria-checked", "aria-expanded", "aria-selected"] as const;
|
||||||
|
|
||||||
|
type AppCopy = (text: string | null) => string | null;
|
||||||
|
|
||||||
|
/** Which control this is — never what it says, unless what it says is the app's own copy.
|
||||||
|
* Recorded: its test id (its own, else the nearest enclosing one as `within`), its lucide icon, the
|
||||||
|
* English wording of its aria-label/text/title when that is exactly app copy (`appCopy`), its tag,
|
||||||
|
* role and input type, its aria state, whether a checkbox is checked, and for a link the path
|
||||||
|
* TEMPLATE or just "external". Never: an input's value, free label text, an href's query. */
|
||||||
|
function controlAttrs(el: Element, appCopy: AppCopy): Record<string, unknown> {
|
||||||
|
const attrs: Record<string, unknown> = { tag: el.tagName.toLowerCase() };
|
||||||
|
const role = el.getAttribute("role");
|
||||||
|
if (role) attrs.role = role;
|
||||||
|
const testId = el.getAttribute("data-testid");
|
||||||
|
if (testId) attrs.control = testId;
|
||||||
|
else {
|
||||||
|
const within = el.parentElement?.closest("[data-testid]")?.getAttribute("data-testid");
|
||||||
|
if (within) attrs.within = within;
|
||||||
|
}
|
||||||
|
const icon = [...(el.querySelector("svg.lucide")?.classList ?? [])].find(
|
||||||
|
(c) => c.startsWith("lucide-") && c !== "lucide-icon",
|
||||||
|
);
|
||||||
|
if (icon) attrs.icon = icon.slice("lucide-".length);
|
||||||
|
const label =
|
||||||
|
appCopy(el.getAttribute("aria-label")) ??
|
||||||
|
appCopy(el.textContent) ??
|
||||||
|
appCopy(el.getAttribute("title"));
|
||||||
|
if (label) attrs.label = label;
|
||||||
|
if (el instanceof HTMLInputElement) {
|
||||||
|
attrs.type = el.type;
|
||||||
|
if (el.type === "checkbox" || el.type === "radio") attrs.checked = el.checked;
|
||||||
|
}
|
||||||
|
for (const name of ARIA_STATE) {
|
||||||
|
const value = el.getAttribute(name);
|
||||||
|
if (value !== null) attrs[name.slice("aria-".length)] = value;
|
||||||
|
}
|
||||||
|
if (el instanceof HTMLAnchorElement) {
|
||||||
|
attrs.href = el.origin === window.location.origin ? pathTemplate(el.pathname) : "external";
|
||||||
|
}
|
||||||
|
const page = (window.history.state as Record<string, unknown> | null)?.sfPage;
|
||||||
|
if (typeof page === "string") attrs.page = page;
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDetailHooks(appCopy: AppCopy): void {
|
||||||
|
// Capture phase, so a handler that stops propagation cannot hide the click from the log.
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(e) => {
|
||||||
|
if (!enabled("DEBUG") || !(e.target instanceof Element)) return;
|
||||||
|
const control = e.target.closest(CONTROL);
|
||||||
|
if (!control) return;
|
||||||
|
const attrs = controlAttrs(control, appCopy);
|
||||||
|
const name = attrs.control ?? attrs.label ?? attrs.icon ?? attrs.within ?? attrs.tag;
|
||||||
|
log.debug("ui.click", `click ${String(name)}`, attrs);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
log.debug("app.visibility", document.visibilityState, { state: document.visibilityState });
|
||||||
|
});
|
||||||
|
window.addEventListener("pageshow", (e) => {
|
||||||
|
if (e.persisted) log.debug("app.restored", "restored from the back-forward cache");
|
||||||
|
});
|
||||||
|
let resizeTimer: number | null = null;
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (!enabled("DEBUG")) return;
|
||||||
|
if (resizeTimer !== null) window.clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = window.setTimeout(() => {
|
||||||
|
resizeTimer = null;
|
||||||
|
const viewport = `${window.innerWidth}x${window.innerHeight}`;
|
||||||
|
log.debug("app.resize", viewport, { viewport });
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
// Connectivity is worth a line at the base level: it explains a burst of failed requests.
|
||||||
|
window.addEventListener("offline", () => log.warn("app.network", "offline", { online: false }));
|
||||||
|
window.addEventListener("online", () => log.info("app.network", "online", { online: true }));
|
||||||
|
}
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. */
|
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
|
||||||
export function installClientLog(): void {
|
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
|
||||||
|
* module stays free of the i18n runtime. */
|
||||||
|
export function installClientLog(appCopy: AppCopy = () => null): void {
|
||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
|
|
||||||
@@ -103,4 +207,5 @@ export function installClientLog(): void {
|
|||||||
}
|
}
|
||||||
window.addEventListener("popstate", () => recordView("pop"));
|
window.addEventListener("popstate", () => recordView("pop"));
|
||||||
recordView("start");
|
recordView("start");
|
||||||
|
installDetailHooks(appCopy);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// What a player went through, for the client log (E22 S5) — one definition for every player, so a
|
||||||
|
// stall in the Plex player and one in the library player read the same in a diagnosis.
|
||||||
|
//
|
||||||
|
// A media ERROR is a warning at every level: it is the thing a user reports. The rest — play, pause,
|
||||||
|
// buffering, seeking, the end, metadata, rate — is Részletes (DEBUG) and costs nothing below it.
|
||||||
|
// Positions are whole seconds; nothing here names what is playing (the request log already has the
|
||||||
|
// item's route template, and a title is the user's content).
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
import { enabled, log } from "./log";
|
||||||
|
|
||||||
|
const MEDIA_EVENTS = [
|
||||||
|
"loadedmetadata",
|
||||||
|
"play",
|
||||||
|
"pause",
|
||||||
|
"waiting",
|
||||||
|
"stalled",
|
||||||
|
"seeking",
|
||||||
|
"seeked",
|
||||||
|
"ratechange",
|
||||||
|
"ended",
|
||||||
|
"emptied",
|
||||||
|
"error",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Log `media`'s playback events as `player`. Returns the detach. */
|
||||||
|
export function logMediaElement(media: HTMLMediaElement, player: string): () => void {
|
||||||
|
const onEvent = (e: Event) => {
|
||||||
|
const at = Math.round(media.currentTime || 0);
|
||||||
|
if (e.type === "error") {
|
||||||
|
const err = media.error;
|
||||||
|
log.warn("player.media_error", `${player} media error ${err?.code ?? "?"}`, {
|
||||||
|
player,
|
||||||
|
code: err?.code ?? null,
|
||||||
|
message: err?.message || null,
|
||||||
|
at,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!enabled("DEBUG")) return;
|
||||||
|
const attrs: Record<string, unknown> = { player, event: e.type, at };
|
||||||
|
if (e.type === "ratechange") attrs.rate = media.playbackRate;
|
||||||
|
if (e.type === "loadedmetadata" && Number.isFinite(media.duration)) {
|
||||||
|
attrs.duration = Math.round(media.duration);
|
||||||
|
}
|
||||||
|
log.debug("player.media", `${player} ${e.type}`, attrs);
|
||||||
|
};
|
||||||
|
for (const type of MEDIA_EVENTS) media.addEventListener(type, onEvent);
|
||||||
|
return () => {
|
||||||
|
for (const type of MEDIA_EVENTS) media.removeEventListener(type, onEvent);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ref callback that logs the element it is given (and stops when React hands it null). */
|
||||||
|
export function useMediaLogRef(player: string): (media: HTMLMediaElement | null) => void {
|
||||||
|
const detach = useRef<(() => void) | null>(null);
|
||||||
|
return useCallback(
|
||||||
|
(media: HTMLMediaElement | null) => {
|
||||||
|
detach.current?.();
|
||||||
|
detach.current = media ? logMediaElement(media, player) : null;
|
||||||
|
},
|
||||||
|
[player],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The IFrame API's numeric player states, named (https://developers.google.com/youtube/iframe_api_reference).
|
||||||
|
const YT_STATES: Record<number, string> = {
|
||||||
|
[-1]: "unstarted",
|
||||||
|
0: "ended",
|
||||||
|
1: "playing",
|
||||||
|
2: "paused",
|
||||||
|
3: "buffering",
|
||||||
|
5: "cued",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The YouTube player changed state (DEBUG). */
|
||||||
|
export function logYouTubeState(state: number, at: number): void {
|
||||||
|
if (!enabled("DEBUG")) return;
|
||||||
|
const name = YT_STATES[state] ?? String(state);
|
||||||
|
log.debug("player.media", `youtube ${name}`, {
|
||||||
|
player: "youtube",
|
||||||
|
event: name,
|
||||||
|
at: Math.round(at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The YouTube player refused the video — 2 bad id, 5 HTML5 error, 100 gone, 101/150 embed denied. */
|
||||||
|
export function logYouTubeError(code: number): void {
|
||||||
|
log.warn("player.media_error", `youtube error ${code}`, { player: "youtube", code });
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { api, type Video } from "./api";
|
|||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { patchCachedProgress } from "./progressCache";
|
import { patchCachedProgress } from "./progressCache";
|
||||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
||||||
|
import { logYouTubeError, logYouTubeState } from "./mediaLog";
|
||||||
import type { AutoMode, LoopMode } from "./playerModes";
|
import type { AutoMode, LoopMode } from "./playerModes";
|
||||||
|
|
||||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||||
@@ -401,6 +402,11 @@ export function useYtProgress({
|
|||||||
reapplyStored();
|
reapplyStored();
|
||||||
},
|
},
|
||||||
onStateChange: (e: YTPlayerEvent) => {
|
onStateChange: (e: YTPlayerEvent) => {
|
||||||
|
const p0 = playerRef.current;
|
||||||
|
logYouTubeState(
|
||||||
|
e?.data,
|
||||||
|
p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
|
||||||
|
);
|
||||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||||
if (e?.data === 1) {
|
if (e?.data === 1) {
|
||||||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||||||
@@ -418,6 +424,7 @@ export function useYtProgress({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (e: YTPlayerEvent) => {
|
onError: (e: YTPlayerEvent) => {
|
||||||
|
logYouTubeError(typeof e?.data === "number" ? e.data : -1);
|
||||||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||||||
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
|
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
|
||||||
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
|
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import "./index.css";
|
|||||||
import "flag-icons/css/flag-icons.min.css";
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||||
import { installClientLog } from "./lib/logHooks";
|
import { installClientLog } from "./lib/logHooks";
|
||||||
|
import { appCopyOf } from "./i18n/appCopy";
|
||||||
|
|
||||||
// First, before any render: a crash or a navigation during boot belongs in the log too.
|
// First, before any render: a crash or a navigation during boot belongs in the log too.
|
||||||
installClientLog();
|
installClientLog(appCopyOf);
|
||||||
|
|
||||||
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
|
// 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.
|
// legal page never downloads the authenticated app bundle, and the app never carries them either.
|
||||||
|
|||||||
Reference in New Issue
Block a user