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:
2026-09-17 02:42:12 +02:00
parent a210992bfc
commit 5883ea509d
49 changed files with 2790 additions and 122 deletions
+39 -3
View File
@@ -1,5 +1,7 @@
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel
from pydantic import BaseModel, RootModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -15,7 +17,9 @@ from app.auth import (
optional_current_user,
purge_user,
)
from app import diagnostics
from app import email as email_mod
from app import logs
from app import sysconfig
from app.db import get_db
from app.models import Invite, User
@@ -147,6 +151,9 @@ def get_me(
"pending_activations": pending_activations,
"email_configured": email_configured,
"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}
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")
def update_preferences(
preferences: dict,
preferences: PreferencesIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
# Merge so partial updates don't wipe other keys.
merged = dict(user.preferences or {})
merged.update(preferences)
merged.update(preferences.root)
user.preferences = merged
db.add(user)
db.commit()
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)}