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" + /> + + + + +
+ + {(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",