From b5996a0e56460820ca93c932b542089dda5112db Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 19 Sep 2026 01:54:48 +0200
Subject: [PATCH] feat(diagnostics): the admin log console
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Admin → Diagnostics reads the server's stored log: one list, newest first,
where a line opens in place to its full record (time, source and logger,
account, request and job ids, version, message, attributes, stack). A
request or job id narrows the list to that request or job with every other
filter off, shown as a removable chip. Filters: level group, source,
account, event, period and a free-text search; all of them live in the URL
(`l*` keys, which setPage clears like the Plex and Channels keys), so a
filtered view is a link and Back restores it. "Live" re-reads the newest
page every few seconds while the tab is visible; paging into older rows
turns it off. Copy puts the loaded lines on the clipboard as JSON lines.
The facet counts now honour every filter but their own, as the feed's do:
a request pivot narrows them, and a picked level does not zero its
siblings. A value in use stays offered even when the counts drop it.
"Saját napló" and the console draw a line with one shared component,
`LogLine`, whose phone layout gives the message its own full-width line
(sharing the code's line left it a few characters). The level groups
moved to lib/log.ts for both.
The console's own reads are logged at DEBUG on success, like the
healthcheck: at INFO its live tail filled the log with itself.
---
backend/app/logs.py | 8 +-
backend/app/routes/admin_logs.py | 121 +++--
backend/tests/test_admin_logs.py | 20 +
backend/tests/test_logs.py | 18 +-
.../src/components/AdminLogs.dom.test.tsx | 152 +++++++
frontend/src/components/AdminLogs.tsx | 425 ++++++++++++++++++
frontend/src/components/LogLine.tsx | 68 +++
frontend/src/components/MyLogViewer.tsx | 103 ++---
.../NavigationProvider.dom.test.tsx | 11 +
.../src/components/NavigationProvider.tsx | 2 +
frontend/src/components/moduleRegistry.tsx | 2 +
frontend/src/i18n/locales/en/adminLogs.json | 40 ++
frontend/src/i18n/locales/en/header.json | 1 +
frontend/src/i18n/locales/hu/adminLogs.json | 40 ++
frontend/src/i18n/locales/hu/header.json | 1 +
frontend/src/lib/api/admin.ts | 84 +++-
frontend/src/lib/log.ts | 11 +
frontend/src/lib/logsUrlState.test.ts | 42 ++
frontend/src/lib/logsUrlState.ts | 80 ++++
frontend/src/lib/modules.ts | 9 +
frontend/src/lib/pageMeta.test.ts | 1 +
frontend/src/lib/queryKeys.ts | 4 +
frontend/src/lib/urlState.ts | 1 +
23 files changed, 1127 insertions(+), 117 deletions(-)
create mode 100644 frontend/src/components/AdminLogs.dom.test.tsx
create mode 100644 frontend/src/components/AdminLogs.tsx
create mode 100644 frontend/src/components/LogLine.tsx
create mode 100644 frontend/src/i18n/locales/en/adminLogs.json
create mode 100644 frontend/src/i18n/locales/hu/adminLogs.json
create mode 100644 frontend/src/lib/logsUrlState.test.ts
create mode 100644 frontend/src/lib/logsUrlState.ts
diff --git a/backend/app/logs.py b/backend/app/logs.py
index 013076ed..cfb49e7c 100644
--- a/backend/app/logs.py
+++ b/backend/app/logs.py
@@ -638,9 +638,11 @@ def current_source() -> str:
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
-# (5.7k lines/day saying nothing) and hashed static assets are served by the hundred per page load.
-# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
-_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
+# (5.7k lines/day saying nothing), hashed static assets are served by the hundred per page load, and
+# the admin log console reads the stored log — its live tail every few seconds, so at INFO the log
+# would fill with the console watching itself. A FAILURE on any of them still logs at WARNING/ERROR
+# — this lowers the floor, it does not hide.
+_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/", "/api/admin/logs")
# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is
# what an operator pins a level on when they want request lines and nothing else. The module it
diff --git a/backend/app/routes/admin_logs.py b/backend/app/routes/admin_logs.py
index 4f351b4b..01d19ef0 100644
--- a/backend/app/routes/admin_logs.py
+++ b/backend/app/routes/admin_logs.py
@@ -18,6 +18,7 @@ commits, so a slower batch can land below an id the tail already passed — and
import base64
import binascii
import json
+from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
@@ -91,8 +92,62 @@ def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
return {str(uid): email for uid, email in rows}
-@router.get("")
-def list_logs(
+@dataclass(frozen=True)
+class LogFilters:
+ """The console's filters, shared by the page and its facet counts."""
+
+ levels: list[str]
+ sources: list[str]
+ user: str | None
+ event: str | None
+ request_id: str | None
+ job_id: int | None
+ q: str | None
+ since: datetime | None
+ until: datetime | None
+
+ def conditions(self, skip: str | None = None) -> list:
+ """The WHERE conditions, ANDed by the caller. `skip` leaves one filter out: a facet counts
+ its own values under every OTHER filter, so picking a level does not zero the other
+ levels' counts (the way back), while a request pivot does narrow them (honest counts)."""
+ out = []
+ if self.levels and skip != "levels":
+ out.append(LogEvent.level.in_(self.levels))
+ if self.sources and skip != "sources":
+ out.append(LogEvent.source.in_(self.sources))
+ if self.user is not None and skip != "user":
+ out.append(
+ LogEvent.user_id.is_(None)
+ if self.user == NONE
+ else LogEvent.user_id == int(self.user)
+ )
+ if self.event is not None and skip != "event":
+ out.append(
+ LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
+ )
+ if self.request_id is not None:
+ out.append(LogEvent.request_id == self.request_id)
+ if self.job_id is not None:
+ out.append(LogEvent.job_id == self.job_id)
+ if self.since is not None:
+ out.append(LogEvent.ts >= _utc(self.since))
+ if self.until is not None:
+ out.append(LogEvent.ts < _utc(self.until))
+ if self.q and self.q.strip():
+ like = f"%{like_escape(self.q.strip())}%"
+ out.append(
+ or_(
+ LogEvent.msg.ilike(like),
+ LogEvent.event.ilike(like),
+ LogEvent.request_id.ilike(like),
+ LogEvent.exc.ilike(like),
+ cast(LogEvent.attrs, Text).ilike(like),
+ )
+ )
+ return out
+
+
+def log_filters(
levels: list[Level] = Query(default=[]),
sources: list[str] = Query(default=[], max_length=8),
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
@@ -104,6 +159,13 @@ def list_logs(
q: str | None = Query(default=None, max_length=200),
since: datetime | None = None,
until: datetime | None = None,
+) -> LogFilters:
+ return LogFilters(list(levels), list(sources), user, event, request_id, job_id, q, since, until)
+
+
+@router.get("")
+def list_logs(
+ filters: LogFilters = Depends(log_filters),
before: str | None = Query(default=None, max_length=200),
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
_: User = Depends(admin_user),
@@ -111,36 +173,7 @@ def list_logs(
) -> dict:
"""One page of rows, newest first, matching every filter given (they AND together); `before`
continues from a previous page's `next_cursor`."""
- query = select(LogEvent)
- if levels:
- query = query.where(LogEvent.level.in_(levels))
- if sources:
- query = query.where(LogEvent.source.in_(sources))
- if user is not None:
- query = query.where(
- LogEvent.user_id.is_(None) if user == NONE else LogEvent.user_id == int(user)
- )
- if event is not None:
- query = query.where(LogEvent.event.is_(None) if event == NONE else LogEvent.event == event)
- if request_id is not None:
- query = query.where(LogEvent.request_id == request_id)
- if job_id is not None:
- query = query.where(LogEvent.job_id == job_id)
- if since is not None:
- query = query.where(LogEvent.ts >= _utc(since))
- if until is not None:
- query = query.where(LogEvent.ts < _utc(until))
- if q and q.strip():
- like = f"%{like_escape(q.strip())}%"
- query = query.where(
- or_(
- LogEvent.msg.ilike(like),
- LogEvent.event.ilike(like),
- LogEvent.request_id.ilike(like),
- LogEvent.exc.ilike(like),
- cast(LogEvent.attrs, Text).ilike(like),
- )
- )
+ query = select(LogEvent).where(*filters.conditions())
if before is not None:
ts, row_id = _decode_cursor(before)
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
@@ -169,18 +202,17 @@ def list_logs(
@router.get("/facets")
def log_facets(
- since: datetime | None = None,
+ filters: LogFilters = Depends(log_filters),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
- """What the filter chips offer, with counts: every level, source and account present in the
- window, and its most frequent events. Over the window alone, not the other filters — a chip
- row that shrank as you clicked would hide the way back."""
- window = [LogEvent.ts >= _utc(since)] if since is not None else []
+ """What the filter chips offer, with counts: the levels, sources and accounts present, and the
+ most frequent events — each counted under every filter but its own (`LogFilters.conditions`).
+ `total` is the "all levels" count: every filter but the level one."""
- def counts(col, cap: int | None = None) -> list[dict]:
+ def counts(col, skip: str, cap: int | None = None) -> list[dict]:
n = func.count().label("n")
- stmt = select(col, n).where(*window).group_by(col).order_by(n.desc(), col)
+ stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
if cap is not None:
stmt = stmt.limit(cap)
return [
@@ -188,12 +220,13 @@ def log_facets(
for value, count in db.execute(stmt).all()
]
- users = counts(LogEvent.user_id)
+ users = counts(LogEvent.user_id, "user")
+ total = select(func.count()).select_from(LogEvent).where(*filters.conditions("levels"))
return {
- "levels": counts(LogEvent.level),
- "sources": counts(LogEvent.source),
- "events": counts(LogEvent.event, FACET_EVENTS_MAX),
+ "levels": counts(LogEvent.level, "levels"),
+ "sources": counts(LogEvent.source, "sources"),
+ "events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
"users": [{**u, "value": str(u["value"])} for u in users],
"accounts": _accounts(db, {u["value"] for u in users if u["value"] != NONE}),
- "total": db.scalar(select(func.count()).select_from(LogEvent).where(*window)) or 0,
+ "total": db.scalar(total) or 0,
}
diff --git a/backend/tests/test_admin_logs.py b/backend/tests/test_admin_logs.py
index 3bf24702..7d634616 100644
--- a/backend/tests/test_admin_logs.py
+++ b/backend/tests/test_admin_logs.py
@@ -152,3 +152,23 @@ def test_facets_count_the_window_and_name_the_rows_without_a_value(db, admin):
]
assert body["users"] == [{"value": "1", "count": 2}, {"value": "none", "count": 1}]
assert body["accounts"] == {"1": "admin@example.com"}
+
+
+def test_a_facet_counts_under_every_other_filter_but_not_its_own(db, admin):
+ _add(
+ db,
+ _row(request_id="req-aaaaaaaa"),
+ _row(request_id="req-aaaaaaaa", level="ERROR", sev=17, source="worker"),
+ _row(request_id="req-bbbbbbbb", level="ERROR", sev=17),
+ _row(request_id="req-bbbbbbbb"),
+ )
+
+ body = admin.get(
+ "/api/admin/logs/facets", params={"request_id": "req-aaaaaaaa", "levels": ["ERROR"]}
+ ).json()
+
+ # The pivot narrows every count; the picked level does not zero its sibling (the way back).
+ assert body["levels"] == [{"value": "ERROR", "count": 1}, {"value": "INFO", "count": 1}]
+ assert body["total"] == 2
+ # Every other facet counts under the level filter too.
+ assert body["sources"] == [{"value": "worker", "count": 1}]
diff --git a/backend/tests/test_logs.py b/backend/tests/test_logs.py
index 556db984..a01403fb 100644
--- a/backend/tests/test_logs.py
+++ b/backend/tests/test_logs.py
@@ -14,7 +14,7 @@ import logging
from pathlib import Path
import pytest
-from fastapi import FastAPI, Request
+from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from app import logs
@@ -213,6 +213,12 @@ def _client() -> TestClient:
def _health():
return {"ok": True}
+ @app.get("/api/admin/logs/facets")
+ def _log_console(fail: bool = False):
+ if fail:
+ raise HTTPException(status_code=400, detail={"code": "bad_cursor"})
+ return {"ok": True}
+
@app.get("/boom")
def _boom():
raise RuntimeError("kaboom")
@@ -307,6 +313,16 @@ def test_the_healthcheck_is_logged_at_debug_so_it_cannot_drown_the_log(sink):
assert len(sink.lines) == before
+def test_the_log_consoles_own_reads_are_debug_but_its_failures_are_not(sink):
+ http = logging.getLogger("siftlode.http")
+ http.setLevel(logs.TRACE)
+ client = _client()
+ client.get("/api/admin/logs/facets")
+ assert sink.objects()[-1]["level"] == "DEBUG"
+ client.get("/api/admin/logs/facets?fail=1")
+ assert sink.objects()[-1]["level"] == "WARNING"
+
+
def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
client = _client()
client.get("/api/videos/missing/deeper") # 404 from the router
diff --git a/frontend/src/components/AdminLogs.dom.test.tsx b/frontend/src/components/AdminLogs.dom.test.tsx
new file mode 100644
index 00000000..773ec9e7
--- /dev/null
+++ b/frontend/src/components/AdminLogs.dom.test.tsx
@@ -0,0 +1,152 @@
+// @vitest-environment jsdom
+//
+// E22.6: the admin log console. A request id narrows the list to that request (and says so, in the
+// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
+// the newest page only; a failed read is an error, not an empty log.
+import { afterEach, beforeEach, 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 { LogFacets, LogPage, LogQuery, ServerLogRow } from "../lib/api";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, vars?: Record) =>
+ vars?.id != null ? `${key}:${String(vars.id)}` : key,
+ i18n: { language: "en" },
+ }),
+ };
+});
+
+const logsMock = vi.fn<(query: LogQuery, before: string | null) => Promise>();
+const facetsMock = vi.fn<(query: LogQuery) => Promise>();
+vi.mock("../lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ adminLogs: (query: LogQuery, before: string | null) => logsMock(query, before),
+ adminLogFacets: (query: LogQuery) => facetsMock(query),
+ },
+ };
+});
+
+import AdminLogs from "./AdminLogs";
+
+function row(id: number, extra: Partial = {}): ServerLogRow {
+ return {
+ id,
+ ts: new Date(Date.UTC(2026, 8, 19, 12, 0, id)).toISOString(),
+ level: "INFO",
+ source: "api",
+ logger: "siftlode.http",
+ event: "http.request",
+ msg: `GET /api/thing/${id} 200`,
+ attrs: null,
+ exc: null,
+ request_id: `req-${String(id).padStart(8, "0")}`,
+ user_id: 1,
+ job_id: null,
+ debug_session_id: null,
+ version: "0.90.0",
+ ...extra,
+ };
+}
+
+const FACETS: LogFacets = {
+ levels: [{ value: "INFO", count: 3 }],
+ sources: [{ value: "api", count: 3 }],
+ events: [{ value: "http.request", count: 3 }],
+ users: [{ value: "1", count: 3 }],
+ accounts: { "1": "admin@example.com" },
+ total: 3,
+};
+
+function renderConsole() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+const lines = () =>
+ within(screen.getByTestId("admin-logs-lines"))
+ .queryAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"));
+
+beforeEach(() => {
+ window.history.replaceState(null, "", "/?page=logs");
+ facetsMock.mockResolvedValue(FACETS);
+});
+afterEach(() => {
+ cleanup();
+ logsMock.mockReset();
+ facetsMock.mockReset();
+});
+
+describe("AdminLogs", () => {
+ it("a request id narrows the list to that request, with every other filter off", async () => {
+ window.history.replaceState(null, "", "/?page=logs&lgrp=error&lq=boom");
+ logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
+ renderConsole();
+
+ await waitFor(() => expect(lines()).toHaveLength(1));
+ fireEvent.click(lines()[0]!);
+ fireEvent.click(screen.getByRole("button", { name: "req-00000001" }));
+
+ await waitFor(() =>
+ expect(logsMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ request_id: "req-00000001", levels: undefined, q: undefined }),
+ null,
+ ),
+ );
+ expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy();
+ const url = new URLSearchParams(window.location.search);
+ expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual(["req-00000001", null, null]);
+ });
+
+ it("keeps the account in use offered when the facets no longer count it", async () => {
+ window.history.replaceState(null, "", "/?page=logs&lu=7");
+ logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
+ renderConsole();
+ await waitFor(() => expect(facetsMock).toHaveBeenCalled());
+
+ fireEvent.click(screen.getByTestId("logs-user"));
+
+ expect(await screen.findByTestId("logs-user-7")).toBeTruthy();
+ expect(screen.getByTestId("logs-user-1")).toBeTruthy();
+ });
+
+ it("Live watches the newest page only", async () => {
+ logsMock.mockImplementation(async (_q, before) =>
+ before === null
+ ? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
+ : { items: [row(1)], accounts: {}, next_cursor: null },
+ );
+ renderConsole();
+ await waitFor(() => expect(lines()).toHaveLength(2));
+ fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
+ await waitFor(() => expect(lines()).toHaveLength(3));
+
+ fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
+
+ await waitFor(() => expect(lines()).toHaveLength(2));
+ expect(
+ screen.getByRole("button", { name: "adminLogs.live" }).getAttribute("aria-pressed"),
+ ).toBe("true");
+ });
+
+ it("a failed read is an error with a retry, not an empty log", async () => {
+ logsMock.mockRejectedValue(new Error("502"));
+ renderConsole();
+
+ expect(await screen.findByRole("alert")).toBeTruthy();
+ expect(screen.queryByText("adminLogs.empty")).toBeNull();
+ });
+});
diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx
new file mode 100644
index 00000000..9289de67
--- /dev/null
+++ b/frontend/src/components/AdminLogs.tsx
@@ -0,0 +1,425 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { Copy, Radio, Search, X } from "lucide-react";
+
+import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
+import { copyText } from "../lib/clipboard";
+import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
+import {
+ DEFAULT_LOGS_STATE,
+ logsParamsToState,
+ logsParamUpdates,
+ WINDOW_HOURS,
+ type LogsUrlState,
+ type LogWindow,
+} from "../lib/logsUrlState";
+import { notify } from "../lib/notifications";
+import { qk } from "../lib/queryKeys";
+import { useDebounced } from "../lib/useDebounced";
+import { useUrlMirror } from "../lib/useUrlMirror";
+import LogLine from "./LogLine";
+import { Button } from "./ui/Button";
+import FilterChipGroup from "./ui/FilterChipGroup";
+import FilterSelect from "./ui/FilterSelect";
+import { Section } from "./ui/form";
+import { LoadingState, StateMessage } from "./ui/QueryState";
+
+// Admin → Diagnosztika: the server's stored log (`log_events`, E22.6). One list, newest first; a line
+// opens in place to its full record, and its request id / job id narrow the list to that request or
+// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
+// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
+// into older rows turns it off.
+
+const LIVE_MS = 4000;
+const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
+const WINDOWS: readonly LogWindow[] = ["1h", "24h", "7d"];
+// A pivot shows everything about one request or job: the other filters would only hide part of it.
+const PIVOT_RESET: Omit = {
+ ...DEFAULT_LOGS_STATE,
+ window: "7d",
+};
+
+function sinceOf(window: LogWindow): string {
+ return new Date(Date.now() - WINDOW_HOURS[window] * 3_600_000).toISOString();
+}
+
+function toQuery(state: LogsUrlState, q: string): LogQuery {
+ return {
+ levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
+ sources: state.sources,
+ user: state.user ?? undefined,
+ event: state.event ?? undefined,
+ request_id: state.requestId ?? undefined,
+ job_id: state.jobId ?? undefined,
+ q: q.trim() || undefined,
+ };
+}
+
+export default function AdminLogs() {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const [state, setState] = useState(() =>
+ logsParamsToState(new URLSearchParams(window.location.search)),
+ );
+ const [live, setLive] = useState(false);
+ const [open, setOpen] = useState(null);
+ const q = useDebounced(state.q, 300);
+ useUrlMirror(logsParamUpdates(state));
+
+ const update = (patch: Partial) => {
+ setOpen(null);
+ setState((s) => ({ ...s, ...patch }));
+ };
+
+ const query = toQuery(state, q);
+ const key = qk.adminLogs({ ...query, window: state.window });
+ const logs = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ api.adminLogs({ ...query, since: sinceOf(state.window) }, pageParam),
+ initialPageParam: null as string | null,
+ getNextPageParam: (last) => last.next_cursor ?? undefined,
+ placeholderData: keepPreviousData,
+ refetchInterval: live ? LIVE_MS : false,
+ });
+ const facets = useQuery({
+ queryKey: qk.adminLogFacets({ ...query, window: state.window }),
+ queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.window) }),
+ placeholderData: keepPreviousData,
+ refetchInterval: live ? LIVE_MS * 4 : false,
+ });
+
+ const rows = useMemo(() => logs.data?.pages.flatMap((p) => p.items) ?? [], [logs.data]);
+ const accounts = useMemo(
+ () =>
+ Object.assign(
+ {},
+ facets.data?.accounts,
+ ...(logs.data?.pages.map((p) => p.accounts) ?? []),
+ ) as Record,
+ [facets.data, logs.data],
+ );
+ const accountLabel = (id: string) =>
+ id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`);
+
+ const toggleLive = () => {
+ if (!live) {
+ // Watching the top: drop the older pages, or every tick would re-read all of them.
+ qc.setQueryData(key, (d) =>
+ d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d,
+ );
+ }
+ setLive(!live);
+ };
+
+ const loadOlder = () => {
+ setLive(false);
+ void logs.fetchNextPage();
+ };
+
+ const copy = async () => {
+ // Oldest first, as a log reads.
+ const text = [...rows]
+ .reverse()
+ .map((r) => JSON.stringify(r))
+ .join("\n");
+ const ok = await copyText(text);
+ notify(
+ ok
+ ? { level: "success", message: t("adminLogs.copied", { count: rows.length }) }
+ : { level: "error", message: t("adminLogs.copyFailed") },
+ );
+ };
+
+ const groupCount = (group: LevelGroup) => {
+ const levels = facets.data?.levels ?? [];
+ const sum = (list: readonly LogFacet[]) => list.reduce((n, f) => n + f.count, 0);
+ if (group === "all") return facets.data?.total;
+ const inGroup: readonly string[] = LEVEL_GROUPS[group];
+ return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined;
+ };
+
+ const pivot = (patch: Pick) => {
+ setLive(false);
+ update({ ...PIVOT_RESET, ...patch });
+ };
+
+ // "Any", then what the facet counted. The value in use stays offered even at zero — under the
+ // other filters it may count nothing, and a menu without it would show no way back.
+ const facetOptions = (
+ list: readonly LogFacet[] | undefined,
+ selected: string | null,
+ label: (v: string) => string,
+ ) => {
+ const facets = list ?? [];
+ const kept =
+ selected && !facets.some((f) => f.value === selected) ? [{ value: selected, count: 0 }] : [];
+ return [
+ { value: "", label: t("adminLogs.any") },
+ ...[...facets, ...kept].map((f) => ({
+ value: f.value,
+ label: label(f.value),
+ count: f.count,
+ })),
+ ];
+ };
+
+ return (
+
+
+ {t("adminLogs.intro")}
+
+
+
+
+ update({ group })}
+ options={GROUPS.map((g) => ({
+ value: g,
+ label: t(`settings.myLog.filters.${g}`),
+ count: groupCount(g),
+ }))}
+ />
+
+
+
+ v)}
+ selected={[state.sources[0] ?? ""]}
+ onChange={([source]) => update({ sources: source ? [source] : [] })}
+ testId="logs-source"
+ />
+ update({ user: user || null })}
+ testId="logs-user"
+ />
+
+ v === "none" ? t("adminLogs.noEvent") : v,
+ )}
+ selected={[state.event ?? ""]}
+ onChange={([event]) => update({ event: event || null })}
+ testId="logs-event"
+ />
+ ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
+ selected={[state.window]}
+ onChange={([window]) => update({ window: window as LogWindow })}
+ testId="logs-window"
+ />
+
+ }
+ aria-pressed={live}
+ onClick={toggleLive}
+ >
+ {t("adminLogs.live")}
+
+ }
+ disabled={rows.length === 0}
+ onClick={() => void copy()}
+ >
+ {t("adminLogs.copy")}
+
+
+
+
+ {(state.requestId || state.jobId !== null) && (
+
+ {state.requestId && (
+
update({ requestId: null })}
+ />
+ )}
+ {state.jobId !== null && (
+ update({ jobId: null })}
+ />
+ )}
+
+ )}
+
+
+ {logs.isError && !logs.data ? (
+
void logs.refetch()}>
+ {t("common.loadError")}
+
+ ) : logs.isLoading ? (
+
+ ) : rows.length === 0 ? (
+
+ {t("adminLogs.empty")}
+
+ ) : (
+ rows.map((row) => (
+
setOpen(open === row.id ? null : row.id)}
+ >
+ pivot({ requestId, jobId: null })}
+ onJob={(jobId) => pivot({ requestId: null, jobId })}
+ />
+
+ ))
+ )}
+
+
+
+ {t("adminLogs.count", { count: rows.length })}
+ {logs.hasNextPage && (
+
+ )}
+
+
+
+
+ );
+}
+
+function PivotChip({
+ label,
+ clearLabel,
+ onClear,
+}: {
+ label: string;
+ clearLabel: string;
+ onClear: () => void;
+}) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function RowDetail({
+ row,
+ account,
+ onRequest,
+ onJob,
+}: {
+ row: ServerLogRow;
+ account: string | null;
+ onRequest: (id: string) => void;
+ onJob: (id: number) => void;
+}) {
+ const { t } = useTranslation();
+ const pivotCls =
+ "font-mono text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid";
+ return (
+
+
+ - {t("adminLogs.fields.when")}
+ - {new Date(row.ts).toLocaleString()}
+ - {t("adminLogs.fields.source")}
+ -
+ {row.source} · {row.logger}
+
+ - {t("adminLogs.fields.account")}
+ - {account ?? "—"}
+ {row.request_id && (
+ <>
+ - {t("adminLogs.fields.request")}
+ -
+
+
+ >
+ )}
+ {row.job_id !== null && (
+ <>
+ - {t("adminLogs.fields.job")}
+ -
+
+
+ >
+ )}
+ {row.version && (
+ <>
+ - {t("adminLogs.fields.version")}
+ - {row.version}
+ >
+ )}
+
+
{row.msg}
+ {row.attrs && (
+
{JSON.stringify(row.attrs, null, 2)}
+ )}
+ {row.exc &&
{row.exc}}
+
+ );
+}
diff --git a/frontend/src/components/LogLine.tsx b/frontend/src/components/LogLine.tsx
new file mode 100644
index 00000000..7dc63fed
--- /dev/null
+++ b/frontend/src/components/LogLine.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+
+import type { Level } from "../lib/log";
+
+// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
+// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
+// in place to whatever detail its screen has. One component, so a level reads the same on both.
+
+const LEVEL_TAG: Record = {
+ TRACE: { text: "TRACE", className: "text-muted opacity-80" },
+ DEBUG: { text: "DEBUG", className: "text-muted" },
+ INFO: { text: "INFO", className: "text-info" },
+ WARNING: { text: "WARN", className: "text-warn" },
+ ERROR: { text: "ERROR", className: "text-danger" },
+ CRITICAL: { text: "FATAL", className: "text-danger" },
+};
+
+/** `HH:MM:SS.mmm` in local time — a log is read to the millisecond. */
+function logClock(ts: string): string {
+ const d = new Date(ts);
+ const pad = (n: number, w = 2) => String(n).padStart(w, "0");
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
+}
+
+export default function LogLine({
+ ts,
+ level,
+ event,
+ msg,
+ open,
+ onToggle,
+ title,
+ children,
+}: {
+ ts: string;
+ level: Level;
+ event: string;
+ msg: string;
+ open: boolean;
+ onToggle: () => void;
+ /** The row button's tooltip — the admin console puts the full date there. */
+ title?: string;
+ /** The detail shown while the line is open. */
+ children: ReactNode;
+}) {
+ const tag = LEVEL_TAG[level];
+ return (
+
+
+ {open &&
{children}
}
+
+ );
+}
diff --git a/frontend/src/components/MyLogViewer.tsx b/frontend/src/components/MyLogViewer.tsx
index dd14fe31..16ec4912 100644
--- a/frontend/src/components/MyLogViewer.tsx
+++ b/frontend/src/components/MyLogViewer.tsx
@@ -3,33 +3,23 @@ import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
import { copyText } from "../lib/clipboard";
-import { clear, events, subscribe, type Level, type LogEvent } from "../lib/log";
+import {
+ clear,
+ events,
+ LEVEL_GROUPS,
+ subscribe,
+ type LevelGroup as Filter,
+ type LogEvent,
+} from "../lib/log";
import { notify } from "../lib/notifications";
+import LogLine from "./LogLine";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
-type Filter = "all" | "error" | "warning" | "info" | "detail";
-
-const FILTER_LEVELS: Record, 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 = {
- TRACE: { text: "TRACE", className: "text-muted opacity-80" },
- DEBUG: { text: "DEBUG", className: "text-muted" },
- INFO: { text: "INFO", className: "text-info" },
- WARNING: { text: "WARN", className: "text-warn" },
- ERROR: { text: "ERROR", className: "text-danger" },
- CRITICAL: { text: "FATAL", className: "text-danger" },
-};
-
/** The ring, live: re-read at most once per animation frame however fast events arrive. */
function useLogEvents(): LogEvent[] {
const [list, setList] = useState(events);
@@ -73,12 +63,6 @@ const matches = (event: LogEvent, needle: string) =>
.toLowerCase()
.includes(needle);
-const clock = (ts: string) => {
- const d = new Date(ts);
- const pad = (n: number, w = 2) => String(n).padStart(w, "0");
- return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
-};
-
// "Saját napló" — this tab's own client log (lib/log.ts), exactly what it would send while a raised
// diagnostics level is on: newest first, filtered by level group and free text, a row opens to its
// correlation id and attributes, and "Copy" puts the FILTERED events on the clipboard as JSON lines
@@ -99,7 +83,7 @@ export default function MyLogViewer() {
detail: 0,
};
for (const e of all) {
- for (const [group, levels] of Object.entries(FILTER_LEVELS)) {
+ for (const [group, levels] of Object.entries(LEVEL_GROUPS)) {
if (levels.includes(e.level)) out[group as Filter] += 1;
}
}
@@ -108,7 +92,7 @@ export default function MyLogViewer() {
const shown = useMemo(() => {
const needle = query.trim().toLowerCase();
- const levels = filter === "all" ? null : FILTER_LEVELS[filter];
+ const levels = filter === "all" ? null : LEVEL_GROUPS[filter];
const out: LogEvent[] = [];
for (let i = all.length - 1; i >= 0; i--) {
const e = all[i]!;
@@ -166,47 +150,32 @@ export default function MyLogViewer() {
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
) : (
- shown.slice(0, ROWS_SHOWN).map((e) => {
- const tag = LEVEL_TAG[e.level];
- const isOpen = open === e;
- return (
-
-
- {isOpen && (
-
- {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,
- )}
-
+ shown.slice(0, ROWS_SHOWN).map((e) => (
+
setOpen(open === e ? null : e)}
+ >
+
+ {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,
)}
-
- );
- })
+
+
+ ))
)}
diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx
index 2a94c602..0d19f2ce 100644
--- a/frontend/src/components/NavigationProvider.dom.test.tsx
+++ b/frontend/src/components/NavigationProvider.dom.test.tsx
@@ -74,6 +74,17 @@ describe("NavigationProvider · setPage's same-page guard", () => {
expect(result.current.page).toBe("plex");
});
+ it("leaves every page's own URL keys behind, the log console's included", () => {
+ window.history.replaceState(
+ { sfPage: "logs" },
+ "",
+ "/?page=logs&lgrp=error&lreq=req-abcdef12&pscope=movie&ctab=hidden&keep=1",
+ );
+ const { result } = render();
+ act(() => result.current.setPage("plex"));
+ expect(window.location.search).toBe("?page=plex&keep=1");
+ });
+
it("pushes nothing when it is already that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index c9c526a1..82faf9a8 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { urlWithParams, type Page } from "../lib/urlState";
+import { LOGS_KEYS } from "../lib/logsUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
@@ -89,6 +90,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
ctab: null,
csort: null,
cf: null,
+ ...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
}),
);
notifyNav();
diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx
index da3aff5f..464dbba3 100644
--- a/frontend/src/components/moduleRegistry.tsx
+++ b/frontend/src/components/moduleRegistry.tsx
@@ -32,6 +32,7 @@ const Scheduler = lazyWithPreload(() => import("./Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
+const AdminLogs = lazyWithPreload(() => import("./AdminLogs"));
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
export type PageDef = {
@@ -63,6 +64,7 @@ export const PAGE_CONTENT: Record = {
config: { Component: ConfigPanel },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog, fadeTop: false },
+ logs: { Component: AdminLogs },
settings: { Component: SettingsPanel },
};
diff --git a/frontend/src/i18n/locales/en/adminLogs.json b/frontend/src/i18n/locales/en/adminLogs.json
new file mode 100644
index 00000000..0a99b6c0
--- /dev/null
+++ b/frontend/src/i18n/locales/en/adminLogs.json
@@ -0,0 +1,40 @@
+{
+ "title": "Server log",
+ "intro": "The server's stored log — lines from the API, the downloader and browsers, kept until the retention period ends. A line opens in place; click its request or job id to keep only that request's or job's lines.",
+ "search": "Search the message, code, stack…",
+ "levelLabel": "Filter by level",
+ "source": "Source",
+ "account": "Account",
+ "event": "Event",
+ "window": "Period",
+ "windows": {
+ "1h": "1 hour",
+ "24h": "24 hours",
+ "7d": "7 days"
+ },
+ "any": "Any",
+ "noAccount": "No account",
+ "noEvent": "No code",
+ "live": "Live",
+ "copy": "Copy",
+ "copied_one": "Copied {{count}} line",
+ "copied_other": "Copied {{count}} lines",
+ "copyFailed": "Couldn't copy to the clipboard",
+ "pivotRequest": "request {{id}}",
+ "pivotJob": "job {{id}}",
+ "clearPivot": "Clear filter",
+ "showRequest": "Only this request's lines",
+ "showJob": "Only this job's lines",
+ "empty": "No lines match the filters.",
+ "count_one": "{{count}} line loaded · newest first",
+ "count_other": "{{count}} lines loaded · newest first",
+ "older": "Load older",
+ "fields": {
+ "when": "Time",
+ "source": "Source",
+ "account": "Account",
+ "request": "Request",
+ "job": "Job",
+ "version": "Version"
+ }
+}
diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json
index 256a8888..c29248ed 100644
--- a/frontend/src/i18n/locales/en/header.json
+++ b/frontend/src/i18n/locales/en/header.json
@@ -35,6 +35,7 @@
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}",
"audit": "Audit log",
+ "logs": "Diagnostics",
"googleAddress": "Google account: {{email}}"
},
"sync": {
diff --git a/frontend/src/i18n/locales/hu/adminLogs.json b/frontend/src/i18n/locales/hu/adminLogs.json
new file mode 100644
index 00000000..e5418b73
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/adminLogs.json
@@ -0,0 +1,40 @@
+{
+ "title": "Szervernapló",
+ "intro": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés- vagy job-azonosítójára kattintva csak az ahhoz tartozó sorok maradnak.",
+ "search": "Keresés az üzenetben, a kódban, a hívási veremben…",
+ "levelLabel": "Szűrés szintre",
+ "source": "Forrás",
+ "account": "Fiók",
+ "event": "Esemény",
+ "window": "Időszak",
+ "windows": {
+ "1h": "1 óra",
+ "24h": "24 óra",
+ "7d": "7 nap"
+ },
+ "any": "Bármi",
+ "noAccount": "Fiók nélkül",
+ "noEvent": "Kód nélkül",
+ "live": "Élő",
+ "copy": "Másolás",
+ "copied_one": "Kimásolva: {{count}} sor",
+ "copied_other": "Kimásolva: {{count}} sor",
+ "copyFailed": "Nem sikerült a vágólapra másolni",
+ "pivotRequest": "kérés {{id}}",
+ "pivotJob": "job {{id}}",
+ "clearPivot": "Szűrő törlése",
+ "showRequest": "Csak ennek a kérésnek a sorai",
+ "showJob": "Csak ennek a jobnak a sorai",
+ "empty": "Nincs a szűrésnek megfelelő sor.",
+ "count_one": "{{count}} sor betöltve · a legújabb felül",
+ "count_other": "{{count}} sor betöltve · a legújabb felül",
+ "older": "Régebbiek betöltése",
+ "fields": {
+ "when": "Időpont",
+ "source": "Forrás",
+ "account": "Fiók",
+ "request": "Kérés",
+ "job": "Job",
+ "version": "Verzió"
+ }
+}
diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json
index 3b5bb633..2c744d33 100644
--- a/frontend/src/i18n/locales/hu/header.json
+++ b/frontend/src/i18n/locales/hu/header.json
@@ -35,6 +35,7 @@
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}",
"audit": "Auditnapló",
+ "logs": "Diagnosztika",
"googleAddress": "Google-fiók: {{email}}"
},
"sync": {
diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts
index 3585795e..d26533fb 100644
--- a/frontend/src/lib/api/admin.ts
+++ b/frontend/src/lib/api/admin.ts
@@ -1,6 +1,8 @@
// The admin surface: discovery purge, system configuration, users & roles, the audit log, the
-// scheduler + maintenance controls, the demo-account whitelist, and access invites. Only touches
-// core's req. (Grouped by sub-area below; kept in one module as the cohesive "admin" domain.)
+// diagnostics console's server log, the scheduler + maintenance controls, the demo-account
+// whitelist, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
+// module as the cohesive "admin" domain.)
+import type { Level } from "../log";
import { req } from "./core";
export interface DemoWhitelistEntry {
@@ -115,6 +117,79 @@ export interface AuditListResult {
returned: number;
}
+/** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's
+ * redactor took out arrive as `[redacted]` / `[email]`. */
+export interface ServerLogRow {
+ id: number;
+ ts: string;
+ level: Level;
+ source: string;
+ logger: string;
+ event: string | null;
+ msg: string;
+ attrs: Record | null;
+ exc: string | null;
+ request_id: string | null;
+ user_id: number | null;
+ job_id: number | null;
+ debug_session_id: string | null;
+ version: string | null;
+}
+
+/** The console's filters, as the API takes them. `none` picks the rows without an account / code. */
+export interface LogQuery {
+ levels?: readonly Level[];
+ sources?: readonly string[];
+ user?: string;
+ event?: string;
+ request_id?: string;
+ job_id?: number;
+ q?: string;
+ since?: string;
+}
+
+export interface LogPage {
+ items: ServerLogRow[];
+ /** The accounts this page names, by id; an erased one is absent. */
+ accounts: Record;
+ next_cursor: string | null;
+}
+
+export interface LogFacet {
+ value: string;
+ count: number;
+}
+
+/** Each facet is counted under every filter but its own; `total` is the all-levels count. */
+export interface LogFacets {
+ levels: LogFacet[];
+ sources: LogFacet[];
+ events: LogFacet[];
+ users: LogFacet[];
+ accounts: Record;
+ total: number;
+}
+
+function logParams(query: LogQuery, extra: Record = {}): string {
+ const p = new URLSearchParams();
+ for (const level of query.levels ?? []) p.append("levels", level);
+ for (const source of query.sources ?? []) p.append("sources", source);
+ const single: Record = {
+ user: query.user,
+ event: query.event,
+ request_id: query.request_id,
+ job_id: query.job_id,
+ q: query.q,
+ since: query.since,
+ ...extra,
+ };
+ for (const [key, value] of Object.entries(single)) {
+ if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
+ }
+ const s = p.toString();
+ return s ? `?${s}` : "";
+}
+
export interface SchedulerJob {
id: string;
interval_minutes: number;
@@ -172,6 +247,11 @@ export const adminApi = {
// --- users & roles ---
adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
+ // --- diagnostics console ---
+ adminLogs: (query: LogQuery, before: string | null): Promise =>
+ req(`/api/admin/logs${logParams(query, { before })}`),
+ adminLogFacets: (query: LogQuery): Promise =>
+ req(`/api/admin/logs/facets${logParams(query)}`),
adminUsers: (): Promise => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
diff --git a/frontend/src/lib/log.ts b/frontend/src/lib/log.ts
index 7942ff70..d1c91d6b 100644
--- a/frontend/src/lib/log.ts
+++ b/frontend/src/lib/log.ts
@@ -31,6 +31,17 @@ export const SEVERITY: Record = {
CRITICAL: 21,
};
+/** How a log viewer groups the six levels into its filter chips — "Saját napló" and the admin
+ * console both read a log through these, so a level lands in the same chip on either screen. */
+export type LevelGroup = "all" | "error" | "warning" | "info" | "detail";
+
+export const LEVEL_GROUPS: Record, readonly Level[]> = {
+ error: ["ERROR", "CRITICAL"],
+ warning: ["WARNING"],
+ info: ["INFO"],
+ detail: ["DEBUG", "TRACE"],
+};
+
export interface LogEvent {
ts: string;
level: Level;
diff --git a/frontend/src/lib/logsUrlState.test.ts b/frontend/src/lib/logsUrlState.test.ts
new file mode 100644
index 00000000..67c80712
--- /dev/null
+++ b/frontend/src/lib/logsUrlState.test.ts
@@ -0,0 +1,42 @@
+// The admin log console's filters in the address bar: a link carries them, a hand-edited value the
+// API would refuse degrades to "no filter", and a filter at its default leaves no key behind.
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_LOGS_STATE,
+ logsParamsToState,
+ logsParamUpdates,
+ type LogsUrlState,
+} from "./logsUrlState";
+
+const params = (updates: Record) =>
+ new URLSearchParams(Object.entries(updates).filter((e): e is [string, string] => e[1] !== null));
+
+describe("logsUrlState", () => {
+ it("round-trips every filter", () => {
+ const state: LogsUrlState = {
+ group: "error",
+ sources: ["worker"],
+ user: "none",
+ event: "download.job.failed",
+ requestId: "req-abcdef12",
+ jobId: 42,
+ q: "100% done",
+ window: "7d",
+ };
+ expect(logsParamsToState(params(logsParamUpdates(state)))).toEqual(state);
+ });
+
+ it("leaves no key for a filter at its default", () => {
+ expect(Object.values(logsParamUpdates(DEFAULT_LOGS_STATE)).every((v) => v === null)).toBe(true);
+ });
+
+ it("drops a value the API would refuse instead of sending it", () => {
+ const state = logsParamsToState(
+ new URLSearchParams(
+ "lgrp=loud&lsrc=api,WORKER,x y&lu=me&lev=Not.A.Code&lreq=short&ljob=0&lwin=1y",
+ ),
+ );
+ expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] });
+ });
+});
diff --git a/frontend/src/lib/logsUrlState.ts b/frontend/src/lib/logsUrlState.ts
new file mode 100644
index 00000000..3105af84
--- /dev/null
+++ b/frontend/src/lib/logsUrlState.ts
@@ -0,0 +1,80 @@
+// The admin diagnostics console's filters in the address bar (`?page=logs&lgrp=error&lreq=…`), so a
+// filtered view — a request's lines, say — is a link. Keys are namespaced (`l…`) like the Plex and
+// Channels ones; setPage clears them (LOGS_KEYS) and the console re-mirrors them while it is open.
+import type { LevelGroup } from "./log";
+
+export type LogWindow = "1h" | "24h" | "7d";
+
+export interface LogsUrlState {
+ group: LevelGroup;
+ sources: string[];
+ /** An account id, or `none` for the rows without one. */
+ user: string | null;
+ /** An event code, or `none` for the rows without one. */
+ event: string | null;
+ requestId: string | null;
+ jobId: number | null;
+ q: string;
+ window: LogWindow;
+}
+
+export const DEFAULT_LOGS_STATE: LogsUrlState = {
+ group: "all",
+ sources: [],
+ user: null,
+ event: null,
+ requestId: null,
+ jobId: null,
+ q: "",
+ window: "24h",
+};
+
+export const LOGS_KEYS = ["lgrp", "lsrc", "lu", "lev", "lreq", "ljob", "lq", "lwin"] as const;
+
+export const WINDOW_HOURS: Record = { "1h": 1, "24h": 24, "7d": 24 * 7 };
+
+const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
+// The same shapes the API validates: a link someone edited by hand degrades to "no filter" here
+// instead of to a 422 on every request the console makes.
+const USER_RE = /^(none|\d{1,10})$/;
+const EVENT_RE = /^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$/;
+const REQUEST_RE = /^[A-Za-z0-9._-]{8,64}$/;
+const SOURCE_RE = /^[a-z]{1,16}$/;
+const SOURCES_MAX = 8;
+const Q_MAX = 200;
+
+const pick = (value: string | null, re: RegExp, max = 64) =>
+ value && value.length <= max && re.test(value) ? value : null;
+
+export function logsParamsToState(params: URLSearchParams): LogsUrlState {
+ const group = params.get("lgrp");
+ const window = params.get("lwin");
+ const job = params.get("ljob");
+ return {
+ group: GROUPS.includes(group as LevelGroup) ? (group as LevelGroup) : DEFAULT_LOGS_STATE.group,
+ sources: (params.get("lsrc") ?? "")
+ .split(",")
+ .filter((s) => SOURCE_RE.test(s))
+ .slice(0, SOURCES_MAX),
+ user: pick(params.get("lu"), USER_RE),
+ event: pick(params.get("lev"), EVENT_RE),
+ requestId: pick(params.get("lreq"), REQUEST_RE),
+ jobId: job && /^\d{1,10}$/.test(job) && Number(job) > 0 ? Number(job) : null,
+ q: (params.get("lq") ?? "").slice(0, Q_MAX),
+ window: window && window in WINDOW_HOURS ? (window as LogWindow) : DEFAULT_LOGS_STATE.window,
+ };
+}
+
+/** The URL updates for a state: a filter at its default clears its key. */
+export function logsParamUpdates(state: LogsUrlState): Record {
+ return {
+ lgrp: state.group === DEFAULT_LOGS_STATE.group ? null : state.group,
+ lsrc: state.sources.length ? state.sources.join(",") : null,
+ lu: state.user,
+ lev: state.event,
+ lreq: state.requestId,
+ ljob: state.jobId === null ? null : String(state.jobId),
+ lq: state.q.trim() ? state.q : null,
+ lwin: state.window === DEFAULT_LOGS_STATE.window ? null : state.window,
+ };
+}
diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts
index d1a119cb..3c9e91bd 100644
--- a/frontend/src/lib/modules.ts
+++ b/frontend/src/lib/modules.ts
@@ -8,6 +8,7 @@ import {
ListVideo,
MessageSquare,
ScrollText,
+ FileSearch,
Settings,
SlidersHorizontal,
Tv,
@@ -108,6 +109,14 @@ const MODULES: readonly ModuleDef[] = [
system: true,
gate: isAdmin,
},
+ {
+ id: "logs",
+ labelKey: "header.account.logs",
+ titleKey: "adminLogs.title",
+ icon: FileSearch,
+ system: true,
+ gate: isAdmin,
+ },
];
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
diff --git a/frontend/src/lib/pageMeta.test.ts b/frontend/src/lib/pageMeta.test.ts
index eea55f63..62614de7 100644
--- a/frontend/src/lib/pageMeta.test.ts
+++ b/frontend/src/lib/pageMeta.test.ts
@@ -19,6 +19,7 @@ const EXPECTED: Record = {
config: "header.configuration",
users: "header.users",
audit: "audit.title",
+ logs: "adminLogs.title",
settings: "settings.title",
};
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index deee4b24..eb1a8223 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -90,6 +90,10 @@ export const qk = {
adminPendingAccess: () => ["admin-pending-access"] as const,
adminConfig: () => ["admin-config"] as const,
adminAudit: () => ["admin-audit"] as const,
+ // The console's filters are part of the key; `since` is not (it moves with the clock) — the
+ // window it came from is.
+ adminLogs: (filters: object) => ["admin-logs", filters] as const,
+ adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const,
adminStorage: () => ["admin-storage"] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts
index 871899be..179b41dc 100644
--- a/frontend/src/lib/urlState.ts
+++ b/frontend/src/lib/urlState.ts
@@ -129,6 +129,7 @@ const PAGES = [
"config",
"users",
"audit",
+ "logs",
"notifications",
"messages",
"downloads",