feat(diagnostics): the admin log console
Admin → Diagnostics reads the server's stored log: one list, newest first, where a line opens in place to its full record (time, source and logger, account, request and job ids, version, message, attributes, stack). A request or job id narrows the list to that request or job with every other filter off, shown as a removable chip. Filters: level group, source, account, event, period and a free-text search; all of them live in the URL (`l*` keys, which setPage clears like the Plex and Channels keys), so a filtered view is a link and Back restores it. "Live" re-reads the newest page every few seconds while the tab is visible; paging into older rows turns it off. Copy puts the loaded lines on the clipboard as JSON lines. The facet counts now honour every filter but their own, as the feed's do: a request pivot narrows them, and a picked level does not zero its siblings. A value in use stays offered even when the counts drop it. "Saját napló" and the console draw a line with one shared component, `LogLine`, whose phone layout gives the message its own full-width line (sharing the code's line left it a few characters). The level groups moved to lib/log.ts for both. The console's own reads are logged at DEBUG on success, like the healthcheck: at INFO its live tail filled the log with itself.
This commit is contained in:
@@ -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<string, unknown> | 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<string, string>;
|
||||
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<string, string>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function logParams(query: LogQuery, extra: Record<string, string | number | null> = {}): 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<string, string | number | null | undefined> = {
|
||||
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<AuditListResult> => 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<LogPage> =>
|
||||
req(`/api/admin/logs${logParams(query, { before })}`),
|
||||
adminLogFacets: (query: LogQuery): Promise<LogFacets> =>
|
||||
req(`/api/admin/logs/facets${logParams(query)}`),
|
||||
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
||||
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
||||
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
|
||||
|
||||
@@ -31,6 +31,17 @@ export const SEVERITY: Record<Level, number> = {
|
||||
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<Exclude<LevelGroup, "all">, readonly Level[]> = {
|
||||
error: ["ERROR", "CRITICAL"],
|
||||
warning: ["WARNING"],
|
||||
info: ["INFO"],
|
||||
detail: ["DEBUG", "TRACE"],
|
||||
};
|
||||
|
||||
export interface LogEvent {
|
||||
ts: string;
|
||||
level: Level;
|
||||
|
||||
@@ -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<string, string | null>) =>
|
||||
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"] });
|
||||
});
|
||||
});
|
||||
@@ -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<LogWindow, number> = { "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<string, string | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ const EXPECTED: Record<Page, string> = {
|
||||
config: "header.configuration",
|
||||
users: "header.users",
|
||||
audit: "audit.title",
|
||||
logs: "adminLogs.title",
|
||||
settings: "settings.title",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,6 +129,7 @@ const PAGES = [
|
||||
"config",
|
||||
"users",
|
||||
"audit",
|
||||
"logs",
|
||||
"notifications",
|
||||
"messages",
|
||||
"downloads",
|
||||
|
||||
Reference in New Issue
Block a user