feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// The activity page's view in the address bar: a link carries the tab and every filter, a hand-edited
|
||||
// value the API would refuse degrades to "no filter", a filter at its default leaves no key behind,
|
||||
// and a link to a page this one replaced opens that page's tab.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ACTIVITY_KEYS,
|
||||
activityParamsToState,
|
||||
activityParamUpdates,
|
||||
DEFAULT_ACTIVITY_STATE,
|
||||
sinceOf,
|
||||
type ActivityUrlState,
|
||||
} from "./activityUrlState";
|
||||
|
||||
const params = (updates: Record<string, string | null>) =>
|
||||
new URLSearchParams(Object.entries(updates).filter((e): e is [string, string] => e[1] !== null));
|
||||
|
||||
describe("activityUrlState", () => {
|
||||
it("round-trips the tab and every filter", () => {
|
||||
const state: ActivityUrlState = {
|
||||
tab: "admin",
|
||||
window: "30d",
|
||||
user: "none",
|
||||
q: "100% done",
|
||||
job: "channel_backfill",
|
||||
status: "interrupted",
|
||||
action: "config_set",
|
||||
};
|
||||
expect(activityParamsToState(params(activityParamUpdates(state)))).toEqual(state);
|
||||
});
|
||||
|
||||
it("leaves no key for a filter at its default, and opens on the runs of the last 7 days", () => {
|
||||
expect(
|
||||
Object.values(activityParamUpdates(DEFAULT_ACTIVITY_STATE)).every((v) => v === null),
|
||||
).toBe(true);
|
||||
expect([DEFAULT_ACTIVITY_STATE.tab, DEFAULT_ACTIVITY_STATE.window]).toEqual(["runs", "7d"]);
|
||||
});
|
||||
|
||||
it("drops a value the API would refuse instead of sending it", () => {
|
||||
const state = activityParamsToState(
|
||||
new URLSearchParams(
|
||||
"at=everything&aw=1y&au=me&rjob=Not-A-Job&rst=finished&aact=DROP%20TABLE",
|
||||
),
|
||||
);
|
||||
expect(state).toEqual(DEFAULT_ACTIVITY_STATE);
|
||||
});
|
||||
|
||||
it("opens the tab of the page a link still names", () => {
|
||||
expect(activityParamsToState(new URLSearchParams("page=audit")).tab).toBe("admin");
|
||||
expect(activityParamsToState(new URLSearchParams("page=logs")).tab).toBe("logs");
|
||||
// A tab the link names wins over the retired page's.
|
||||
expect(activityParamsToState(new URLSearchParams("page=logs&at=quota")).tab).toBe("quota");
|
||||
});
|
||||
|
||||
it("clears its own keys and the server log's on a page change", () => {
|
||||
expect(ACTIVITY_KEYS).toEqual(expect.arrayContaining(["at", "aw", "au", "aq", "rjob", "lreq"]));
|
||||
});
|
||||
|
||||
it("starts the window that many hours back", () => {
|
||||
const now = Date.UTC(2026, 9, 18, 12);
|
||||
expect(sinceOf("24h", now)).toBe("2026-10-17T12:00:00.000Z");
|
||||
expect(sinceOf("90d", now)).toBe("2026-07-20T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// The activity page's view in the address bar (`?page=activity&at=runs&aw=24h&rjob=backfill`), so a
|
||||
// filtered view is a link: which tab, the filters every tab shares (time range, account, text), and
|
||||
// each tab's own (the run list's job and status, the audit list's action). The server log's own
|
||||
// filters keep their `l…` keys (./logsUrlState). setPage clears all of them (ACTIVITY_KEYS); the page
|
||||
// re-mirrors them while it is open.
|
||||
import { LOGS_KEYS } from "./logsUrlState";
|
||||
|
||||
export type ActivityTab = "runs" | "admin" | "logs" | "quota";
|
||||
export type ActivityWindow = "1h" | "24h" | "7d" | "30d" | "90d";
|
||||
/** The run statuses the list can narrow to (the API's `RunStatus`). */
|
||||
export type RunStatusFilter = "running" | "ok" | "error" | "skipped" | "interrupted";
|
||||
|
||||
export interface ActivityUrlState {
|
||||
tab: ActivityTab;
|
||||
window: ActivityWindow;
|
||||
/** An account id, or `none` for what no account did (the background). */
|
||||
user: string | null;
|
||||
q: string;
|
||||
/** Runs: one job's runs. */
|
||||
job: string | null;
|
||||
status: RunStatusFilter | null;
|
||||
/** Admin actions: one action. */
|
||||
action: string | null;
|
||||
}
|
||||
|
||||
export const ACTIVITY_TABS: readonly ActivityTab[] = ["runs", "admin", "logs", "quota"];
|
||||
export const ACTIVITY_WINDOWS: readonly ActivityWindow[] = ["1h", "24h", "7d", "30d", "90d"];
|
||||
export const RUN_STATUSES: readonly RunStatusFilter[] = [
|
||||
"error",
|
||||
"interrupted",
|
||||
"skipped",
|
||||
"running",
|
||||
"ok",
|
||||
];
|
||||
|
||||
export const WINDOW_HOURS: Record<ActivityWindow, number> = {
|
||||
"1h": 1,
|
||||
"24h": 24,
|
||||
"7d": 24 * 7,
|
||||
"30d": 24 * 30,
|
||||
"90d": 24 * 90,
|
||||
};
|
||||
|
||||
export const DEFAULT_ACTIVITY_STATE: ActivityUrlState = {
|
||||
tab: "runs",
|
||||
window: "7d",
|
||||
user: null,
|
||||
q: "",
|
||||
job: null,
|
||||
status: null,
|
||||
action: null,
|
||||
};
|
||||
|
||||
const OWN_KEYS = ["at", "aw", "au", "aq", "rjob", "rst", "aact"] as const;
|
||||
export const ACTIVITY_KEYS = [...OWN_KEYS, ...LOGS_KEYS] as const;
|
||||
|
||||
// The pages this one replaced: a link or a bookmark to one opens its tab here (urlState's RETIRED
|
||||
// sends the page id itself here).
|
||||
const RETIRED_TAB: Readonly<Record<string, ActivityTab>> = { audit: "admin", logs: "logs" };
|
||||
|
||||
// The same shapes the API validates: a hand-edited link degrades to "no filter" here instead of to
|
||||
// a 422 on every request the page makes.
|
||||
const USER_RE = /^(none|\d{1,10})$/;
|
||||
const ID_RE = /^[a-z][a-z0-9_]*$/;
|
||||
const Q_MAX = 200;
|
||||
|
||||
const pick = (value: string | null, re: RegExp, max: number) =>
|
||||
value && value.length <= max && re.test(value) ? value : null;
|
||||
const oneOf = <T extends string>(value: string | null, list: readonly T[]): T | null =>
|
||||
list.includes(value as T) ? (value as T) : null;
|
||||
|
||||
export function activityParamsToState(params: URLSearchParams): ActivityUrlState {
|
||||
return {
|
||||
tab:
|
||||
oneOf(params.get("at"), ACTIVITY_TABS) ??
|
||||
RETIRED_TAB[params.get("page") ?? ""] ??
|
||||
DEFAULT_ACTIVITY_STATE.tab,
|
||||
window: oneOf(params.get("aw"), ACTIVITY_WINDOWS) ?? DEFAULT_ACTIVITY_STATE.window,
|
||||
user: pick(params.get("au"), USER_RE, 10),
|
||||
q: (params.get("aq") ?? "").slice(0, Q_MAX),
|
||||
job: pick(params.get("rjob"), ID_RE, 40),
|
||||
status: oneOf(params.get("rst"), RUN_STATUSES),
|
||||
action: pick(params.get("aact"), ID_RE, 48),
|
||||
};
|
||||
}
|
||||
|
||||
/** The URL updates for a state: a filter at its default clears its key. */
|
||||
export function activityParamUpdates(state: ActivityUrlState): Record<string, string | null> {
|
||||
const d = DEFAULT_ACTIVITY_STATE;
|
||||
return {
|
||||
at: state.tab === d.tab ? null : state.tab,
|
||||
aw: state.window === d.window ? null : state.window,
|
||||
au: state.user,
|
||||
aq: state.q.trim() ? state.q : null,
|
||||
rjob: state.job,
|
||||
rst: state.status,
|
||||
aact: state.action,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the window starts, as the API takes it. */
|
||||
export function sinceOf(window: ActivityWindow, now = Date.now()): string {
|
||||
return new Date(now - WINDOW_HOURS[window] * 3_600_000).toISOString();
|
||||
}
|
||||
@@ -95,18 +95,69 @@ export interface AdminUserAccess extends AdminUserRow {
|
||||
export interface AuditRow {
|
||||
id: number;
|
||||
created_at: string | null;
|
||||
actor: string | null; // email of the acting admin; null = System (scheduler/background)
|
||||
/** The acting admin (address in the page's `accounts`); null = the background. */
|
||||
actor_id: number | null;
|
||||
action: string; // canonical key, localized on the client via auditActions.<action>
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
summary: string | null;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
/** The request the change was made in — its log lines are one pivot away. Null on older rows. */
|
||||
request_id: string | null;
|
||||
}
|
||||
export interface AuditListResult {
|
||||
items: AuditRow[];
|
||||
total: number; // total rows in the log (may exceed `items` if capped)
|
||||
returned: number;
|
||||
|
||||
/** A page of a history list (audit log, runs), newest first; `next_cursor` continues it. */
|
||||
export interface HistoryPage<T> {
|
||||
items: T[];
|
||||
/** The accounts this page names, by id; an erased one is absent. */
|
||||
accounts: Record<string, string>;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
/** The filters every activity tab shares, as the APIs take them. */
|
||||
export interface SharedQuery {
|
||||
/** An account id, or `none` for what no account did. */
|
||||
user?: string;
|
||||
q?: string;
|
||||
since?: string;
|
||||
}
|
||||
|
||||
export interface AuditQuery extends SharedQuery {
|
||||
action?: string;
|
||||
}
|
||||
|
||||
/** One background run (`job_runs`), as the activity page lists it. */
|
||||
export interface RunRow {
|
||||
id: number;
|
||||
job: string;
|
||||
trigger: RunBrief["trigger"];
|
||||
actor_id: number | null;
|
||||
/** What a user's ad-hoc run worked on (a channel id), and that channel's title. */
|
||||
target: string | null;
|
||||
target_title: string | null;
|
||||
status: RunBrief["status"];
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
summary: string | null;
|
||||
result: JobResult | null;
|
||||
error: string | null;
|
||||
failure: { code: string; [key: string]: unknown } | null;
|
||||
quota_units: number;
|
||||
/** How many stored log lines the run left (WARNING and up only). */
|
||||
log_lines: number;
|
||||
}
|
||||
|
||||
export interface RunQuery extends SharedQuery {
|
||||
job?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** The job menu and the status chips, each counted under every filter but its own. */
|
||||
export interface RunFacets {
|
||||
jobs: LogFacet[];
|
||||
statuses: LogFacet[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's
|
||||
@@ -124,6 +175,7 @@ export interface ServerLogRow {
|
||||
request_id: string | null;
|
||||
user_id: number | null;
|
||||
job_id: number | null;
|
||||
run_id: number | null;
|
||||
debug_session_id: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
@@ -136,6 +188,7 @@ export interface LogQuery {
|
||||
event?: string;
|
||||
request_id?: string;
|
||||
job_id?: number;
|
||||
run_id?: number;
|
||||
q?: string;
|
||||
since?: string;
|
||||
}
|
||||
@@ -171,6 +224,7 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
|
||||
event: query.event,
|
||||
request_id: query.request_id,
|
||||
job_id: query.job_id,
|
||||
run_id: query.run_id,
|
||||
q: query.q,
|
||||
since: query.since,
|
||||
...extra,
|
||||
@@ -182,6 +236,16 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
/** `?a=1&b=x` from the set values (an empty string or null is left out), or "" when none is. */
|
||||
function queryString(values: object): string {
|
||||
const p = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(values) as [string, unknown][]) {
|
||||
if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
|
||||
}
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export type JobResult = { [key: string]: number | string | JobResult };
|
||||
|
||||
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
|
||||
@@ -277,7 +341,14 @@ export const adminApi = {
|
||||
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
|
||||
req("/api/admin/config/test-issues", { method: "POST" }),
|
||||
// --- users & roles ---
|
||||
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
||||
adminAudit: (query: AuditQuery, before: string | null): Promise<HistoryPage<AuditRow>> =>
|
||||
req(`/api/admin/audit${queryString({ ...query, before })}`),
|
||||
adminAuditFacets: (query: AuditQuery): Promise<{ actions: LogFacet[] }> =>
|
||||
req(`/api/admin/audit/facets${queryString(query)}`),
|
||||
adminRuns: (query: RunQuery, before: string | null): Promise<HistoryPage<RunRow>> =>
|
||||
req(`/api/admin/runs${queryString({ ...query, before })}`),
|
||||
adminRunFacets: (query: RunQuery): Promise<RunFacets> =>
|
||||
req(`/api/admin/runs/facets${queryString(query)}`),
|
||||
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||
// --- diagnostics console ---
|
||||
adminLogs: (query: LogQuery, before: string | null): Promise<LogPage> =>
|
||||
|
||||
@@ -18,12 +18,13 @@ export interface AdminQuotaRow {
|
||||
}
|
||||
|
||||
export interface AdminQuota {
|
||||
range_days: number;
|
||||
rows: AdminQuotaRow[];
|
||||
daily: { day: string; total: number }[];
|
||||
}
|
||||
|
||||
export const quotaApi = {
|
||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||
/** Spend from `since` to now, optionally for one account (`none` = the background). */
|
||||
adminQuota: (since: string, user: string | null): Promise<AdminQuota> =>
|
||||
req(`/api/quota/admin?${new URLSearchParams({ since, ...(user ? { user } : {}) }).toString()}`),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// An audit row whose server summary is a fixed English line ("log_level = DEBUG", "rss_poll → 15
|
||||
// min/run", "Background sync paused") reads in the reader's language, rebuilt from the row's
|
||||
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import i18n, { setLanguage } from "../i18n";
|
||||
import type { AuditRow } from "./api";
|
||||
import { auditText } from "./auditText";
|
||||
|
||||
afterEach(() => {
|
||||
setLanguage("en");
|
||||
});
|
||||
|
||||
const row = (over: Partial<AuditRow>): AuditRow => ({
|
||||
id: 1,
|
||||
created_at: null,
|
||||
actor_id: null,
|
||||
action: "sync_pause",
|
||||
target_type: null,
|
||||
target_id: null,
|
||||
summary: null,
|
||||
before: null,
|
||||
after: null,
|
||||
request_id: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
function details(r: AuditRow): string {
|
||||
setLanguage("hu");
|
||||
const t = i18n.getFixedT("hu");
|
||||
const { head, sub } = auditText(t, r, "hu");
|
||||
return [head, sub].filter(Boolean).join(" | ");
|
||||
}
|
||||
|
||||
describe("an audit row's details", () => {
|
||||
it("name a config change's field and values", () => {
|
||||
const set = row({
|
||||
action: "config_set",
|
||||
target_type: "config",
|
||||
target_id: "plex_enabled",
|
||||
summary: "plex_enabled = True",
|
||||
before: { value: false },
|
||||
after: { value: true },
|
||||
});
|
||||
expect(details(set)).toMatch(/ \| Ki → Be$/);
|
||||
expect(details(set)).not.toContain("plex_enabled");
|
||||
const reset = row({
|
||||
action: "config_reset",
|
||||
target_type: "config",
|
||||
target_id: "download_upload_max_bytes",
|
||||
summary: "download_upload_max_bytes → default",
|
||||
before: { value: 5242880 },
|
||||
});
|
||||
expect(details(reset)).toMatch(/ \| 5\s242\s880 → alapérték$/);
|
||||
const secret = row({
|
||||
action: "config_set",
|
||||
target_type: "config",
|
||||
target_id: "youtube_api_key",
|
||||
summary: "youtube_api_key = (secret updated)",
|
||||
});
|
||||
expect(details(secret)).toMatch(/ \| titok frissítve/);
|
||||
});
|
||||
|
||||
it("tell a secret's reset from its update, and name a choice", () => {
|
||||
const reset = row({
|
||||
action: "config_reset",
|
||||
target_type: "config",
|
||||
target_id: "tmdb_api_key",
|
||||
summary: "tmdb_api_key → default",
|
||||
});
|
||||
expect(details(reset)).toMatch(/ \| titok visszaállítva/);
|
||||
const choice = row({
|
||||
action: "config_set",
|
||||
target_type: "config",
|
||||
target_id: "plex_transcode_hw",
|
||||
before: { value: "auto" },
|
||||
after: { value: "on" },
|
||||
});
|
||||
expect(details(choice)).toMatch(/ \| Automatikus → GPU$/);
|
||||
});
|
||||
|
||||
it("name an interval, a batch and a count in the reader's language", () => {
|
||||
expect(
|
||||
details(
|
||||
row({
|
||||
action: "scheduler_interval",
|
||||
target_type: "job",
|
||||
target_id: "rss_poll",
|
||||
summary: "rss_poll → 15 min/run",
|
||||
after: { interval_minutes: 15 },
|
||||
}),
|
||||
),
|
||||
).toBe("RSS-lekérdezés (új feltöltések) | 15 percenként");
|
||||
expect(
|
||||
details(row({ action: "maintenance_tune", after: { revalidate_batch: 50000 } })),
|
||||
).toMatch(/^Futásonként újraellenőrzött videók \| 50\s000$/);
|
||||
expect(
|
||||
details(
|
||||
row({ action: "audit_clear", summary: "Cleared 12 audit entries", after: { cleared: 12 } }),
|
||||
),
|
||||
).not.toContain("Cleared");
|
||||
});
|
||||
|
||||
it("add nothing to a pause, whose action already says it", () => {
|
||||
expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe("");
|
||||
});
|
||||
|
||||
it("keep a summary that is data", () => {
|
||||
expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe(
|
||||
"someone@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AuditRow, JobResult } from "./api";
|
||||
import { formatNumber } from "./format";
|
||||
import { jobResultText } from "./jobResult";
|
||||
|
||||
function fmt(v: unknown): string {
|
||||
return v == null || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
/** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a
|
||||
* choice is named as the Configuration page names it. */
|
||||
function configValue(t: TFunction, key: string, v: unknown, lang: string): string {
|
||||
if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off");
|
||||
if (typeof v === "number") return formatNumber(v, lang);
|
||||
if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v });
|
||||
return fmt(v);
|
||||
}
|
||||
|
||||
/** A row's `after` as a job-style result (counts), or null when it holds none. */
|
||||
function counts(after: Record<string, unknown>): JobResult | null {
|
||||
const out: JobResult = {};
|
||||
for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** The details of the rows whose server `summary` is a fixed English line, rebuilt from the row's
|
||||
* data in the reader's language: a config change names the field and its values, an interval or batch change its new value, a count-bearing
|
||||
* action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or
|
||||
* an older row without the data); the generic summary + change rendering applies. */
|
||||
function localDetails(
|
||||
t: TFunction,
|
||||
row: AuditRow,
|
||||
lang: string,
|
||||
): { head: string | null; sub: string | null } | undefined {
|
||||
const after = row.after ?? {};
|
||||
const before = row.before ?? {};
|
||||
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
|
||||
const key = row.target_id;
|
||||
const head = t(`config.fields.${key}.label`, key);
|
||||
const reset = row.action === "config_reset";
|
||||
// A secret's value is never logged: there is no before/after to show.
|
||||
if (!row.before && !row.after)
|
||||
return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") };
|
||||
const to = reset ? t("audit.default") : configValue(t, key, after.value, lang);
|
||||
return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` };
|
||||
}
|
||||
if (
|
||||
row.action === "scheduler_interval" &&
|
||||
row.target_id &&
|
||||
typeof after.interval_minutes === "number"
|
||||
)
|
||||
return {
|
||||
head: t(`scheduler.jobs.${row.target_id}`, row.target_id),
|
||||
sub: t("scheduler.everyMin", { count: after.interval_minutes }),
|
||||
};
|
||||
if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number")
|
||||
return {
|
||||
head: t("scheduler.maintenance.batchLabel"),
|
||||
sub: formatNumber(after.revalidate_batch, lang),
|
||||
};
|
||||
if (row.action === "audit_clear" || row.action === "discovery_purge") {
|
||||
const result = counts(after);
|
||||
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
|
||||
}
|
||||
if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
|
||||
function changeText(row: AuditRow): string | null {
|
||||
const { before, after } = row;
|
||||
if (before && after) {
|
||||
return Object.keys(after)
|
||||
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
|
||||
.join(", ");
|
||||
}
|
||||
if (after) {
|
||||
return Object.entries(after)
|
||||
.map(([k, v]) => `${k}: ${fmt(v)}`)
|
||||
.join(", ");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An audit row in the reader's language: `head` names what changed (a config field, a job; else
|
||||
* the server's summary), `sub` the change itself (before → after, a count). Either may be null. */
|
||||
export function auditText(
|
||||
t: TFunction,
|
||||
row: AuditRow,
|
||||
lang: string,
|
||||
): { head: string | null; sub: string | null } {
|
||||
const local = localDetails(t, row, lang);
|
||||
return local ?? { head: row.summary, sub: changeText(row) };
|
||||
}
|
||||
|
||||
export function auditActionLabel(t: TFunction, action: string): string {
|
||||
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||
return t(`auditActions.${action}`, action);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
|
||||
request_id: null,
|
||||
user_id: 1,
|
||||
job_id: null,
|
||||
run_id: null,
|
||||
debug_session_id: "0123456789abcdef",
|
||||
version: null,
|
||||
...over,
|
||||
|
||||
@@ -17,12 +17,10 @@ describe("logsUrlState", () => {
|
||||
const state: LogsUrlState = {
|
||||
group: "error",
|
||||
sources: ["worker"],
|
||||
user: "none",
|
||||
event: "download.job.failed",
|
||||
requestId: "req-abcdef12",
|
||||
jobId: 42,
|
||||
q: "100% done",
|
||||
window: "7d",
|
||||
runId: 7,
|
||||
};
|
||||
expect(logsParamsToState(params(logsParamUpdates(state)))).toEqual(state);
|
||||
});
|
||||
@@ -33,9 +31,7 @@ describe("logsUrlState", () => {
|
||||
|
||||
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",
|
||||
),
|
||||
new URLSearchParams("lgrp=loud&lsrc=api,WORKER,x y&lev=Not.A.Code&lreq=short&ljob=0&lrun=-1"),
|
||||
);
|
||||
expect(state).toEqual({ ...DEFAULT_LOGS_STATE, sources: ["api"] });
|
||||
});
|
||||
|
||||
@@ -1,67 +1,56 @@
|
||||
// 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.
|
||||
// The server-log tab's own filters in the address bar (`?page=activity&at=logs&lgrp=error&lreq=…`),
|
||||
// so a filtered view — a request's lines, say — is a link. The filters every activity tab shares
|
||||
// (time range, account, text) live in ./activityUrlState. Keys are namespaced (`l…`) like the Plex
|
||||
// and Channels ones; setPage clears them (via ACTIVITY_KEYS) and the tab re-mirrors them while 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;
|
||||
/** A background run's lines (`job_runs.id`). */
|
||||
runId: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOGS_STATE: LogsUrlState = {
|
||||
group: "all",
|
||||
sources: [],
|
||||
user: null,
|
||||
event: null,
|
||||
requestId: null,
|
||||
jobId: null,
|
||||
q: "",
|
||||
window: "24h",
|
||||
runId: null,
|
||||
};
|
||||
|
||||
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 };
|
||||
export const LOGS_KEYS = ["lgrp", "lsrc", "lev", "lreq", "ljob", "lrun"] as const;
|
||||
|
||||
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;
|
||||
const positive = (value: string | null) =>
|
||||
value && /^\d{1,10}$/.test(value) && Number(value) > 0 ? Number(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,
|
||||
jobId: positive(params.get("ljob")),
|
||||
runId: positive(params.get("lrun")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,11 +59,9 @@ export function logsParamUpdates(state: LogsUrlState): Record<string, string | n
|
||||
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,
|
||||
lrun: state.runId === null ? null : String(state.runId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Home,
|
||||
ListVideo,
|
||||
ScrollText,
|
||||
FileSearch,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
@@ -129,23 +128,14 @@ const MODULES: readonly ModuleDef[] = [
|
||||
gate: isAdmin,
|
||||
},
|
||||
{
|
||||
id: "audit",
|
||||
labelKey: "header.account.audit",
|
||||
railKey: "nav.rail.audit",
|
||||
titleKey: "audit.title",
|
||||
id: "activity",
|
||||
labelKey: "header.account.activity",
|
||||
railKey: "nav.rail.activity",
|
||||
titleKey: "activity.title",
|
||||
icon: ScrollText,
|
||||
system: true,
|
||||
gate: isAdmin,
|
||||
},
|
||||
{
|
||||
id: "logs",
|
||||
labelKey: "header.account.logs",
|
||||
railKey: "nav.rail.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
|
||||
|
||||
@@ -17,8 +17,7 @@ const EXPECTED: Record<Page, string> = {
|
||||
scheduler: "header.scheduler",
|
||||
config: "header.configuration",
|
||||
users: "header.users",
|
||||
audit: "audit.title",
|
||||
logs: "adminLogs.title",
|
||||
activity: "activity.title",
|
||||
settings: "settings.title",
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("qk — single-id and scalar keys", () => {
|
||||
expect(qk.downloadLinks(5)).toEqual(["download-links", 5]);
|
||||
expect(qk.dlQuota(3)).toEqual(["dl-quota", 3]);
|
||||
expect(qk.storyboard(5)).toEqual(["storyboard", 5]);
|
||||
expect(qk.adminQuota(30)).toEqual(["admin-quota", 30]);
|
||||
expect(qk.adminQuota({ window: "7d" })).toEqual(["admin-quota", { window: "7d" }]);
|
||||
expect(qk.plexPeopleCards("both", ["Actor"])).toEqual(["plex-people-cards", "both", ["Actor"]]);
|
||||
});
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ export const qk = {
|
||||
adminInvites: () => ["admin-invites"] as const,
|
||||
adminPendingAccess: () => ["admin-pending-access"] as const,
|
||||
adminConfig: () => ["admin-config"] as const,
|
||||
adminAudit: () => ["admin-audit"] as const,
|
||||
adminAudit: (filters: object) => ["admin-audit", filters] as const,
|
||||
adminAuditFacets: (filters: object) => ["admin-audit-facets", filters] as const,
|
||||
adminRuns: (filters: object) => ["admin-runs", filters] as const,
|
||||
adminRunFacets: (filters: object) => ["admin-run-facets", filters] 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,
|
||||
@@ -105,7 +108,7 @@ export const qk = {
|
||||
debugSessionPreview: (id: string, text: string) =>
|
||||
["debug-sessions", id, "preview", text] as const,
|
||||
adminDownloads: () => ["admin-downloads"] as const,
|
||||
adminQuota: (days: number) => ["admin-quota", days] as const,
|
||||
adminQuota: (filters: object) => ["admin-quota", filters] as const,
|
||||
|
||||
// --- plex ---
|
||||
// The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys
|
||||
|
||||
@@ -64,12 +64,10 @@ export const LS = {
|
||||
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
||||
channelNames: "siftlode.channelNames",
|
||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
|
||||
// statsTab below.
|
||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab
|
||||
// below.
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
configTab: "siftlode.configTab",
|
||||
auditTable: "siftlode.auditTable",
|
||||
statsTab: "siftlode.statsTab",
|
||||
adminUsersTab: "siftlode.adminUsersTab",
|
||||
filterCollapsed: "siftlode.filterCollapsed",
|
||||
playlist: "siftlode.playlist",
|
||||
|
||||
@@ -128,8 +128,7 @@ const PAGES = [
|
||||
"scheduler",
|
||||
"config",
|
||||
"users",
|
||||
"audit",
|
||||
"logs",
|
||||
"activity",
|
||||
"notifications",
|
||||
"files",
|
||||
"plex",
|
||||
@@ -148,6 +147,10 @@ function isPage(p: string | null | undefined): p is Page {
|
||||
const RETIRED: Readonly<Record<string, Page>> = {
|
||||
// The download queue became the tray; what it downloaded lives in Files.
|
||||
downloads: "files",
|
||||
// The audit log and the server log became tabs of the activity page (activityUrlState opens the
|
||||
// right tab from the old page id).
|
||||
audit: "activity",
|
||||
logs: "activity",
|
||||
};
|
||||
|
||||
/** A page id as stored or linked: a live page, a retired page's successor, else null. */
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { HistoryPage } from "./api";
|
||||
|
||||
/** A history list read a page at a time, newest first (the server's keyset paging): the rows loaded
|
||||
* so far, the accounts they name, and whether there is an older page to ask for. While a filter
|
||||
* change loads, the previous filter's rows stay on screen (dimmed by the caller) and no older page is
|
||||
* offered — the cursor on screen belongs to the rows of the old filter. */
|
||||
export function useHistoryList<T>(
|
||||
queryKey: readonly unknown[],
|
||||
fetchPage: (before: string | null) => Promise<HistoryPage<T>>,
|
||||
) {
|
||||
const list = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: ({ pageParam }) => fetchPage(pageParam),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const rows = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||
const accounts = useMemo(
|
||||
() =>
|
||||
Object.assign({}, ...(list.data?.pages.map((p) => p.accounts) ?? [])) as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
[list.data],
|
||||
);
|
||||
return { list, rows, accounts, hasOlder: !list.isPlaceholderData && list.hasNextPage };
|
||||
}
|
||||
|
||||
export type HistoryList<T> = ReturnType<typeof useHistoryList<T>>;
|
||||
Reference in New Issue
Block a user