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",