Backend - A run's outcome write sits outside the job's own try: a database blip after a run that succeeded no longer records it as failed (the row waits for the next start's sweep). - An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does). - The Scheduler's ad-hoc row says running while any backfill runs, not only the newest. - recent_runs reads each job's last runs off its index instead of ranking the whole table. - An interval change is stored with its audit row in one transaction, then rescheduled. - One API instance per database is the stated contract (.env.example, jobruns). - Turning full history on for a channel already complete records no run. - Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are test-held to the enums; the account filter is one helper for the four activity reads. - An audit row says how many of its request's log lines are still kept. Frontend - An "All" period reaches every row still kept (runs and admin actions outlive 90 days). - Back/Forward is followed in place by the server log: a closing dialog no longer turns Live off; a pivot still opens a fresh log. - Runs moved over from the audit log read their one-liner in the reader's language. - Filter choices or accounts that failed to load say so beside the filters, with a retry. - An old server-log link keeps its account, text and range; a remembered retired page opens its tab. - A request whose lines are gone says so instead of linking to an empty list. - The log lines and the history rows share one row frame, and the server log one list frame with the other tabs; an empty list is announced; each day's quota is readable.
429 lines
18 KiB
TypeScript
429 lines
18 KiB
TypeScript
// The admin surface: discovery purge, system configuration, users & roles, the audit log, the
|
|
// diagnostics console's server log, the scheduler + maintenance controls, 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 Invite {
|
|
id: number;
|
|
email: string;
|
|
status: "pending" | "approved" | "denied";
|
|
note: string | null;
|
|
requested_at: string | null;
|
|
decided_at: string | null;
|
|
decided_by: string | null;
|
|
/** An account already exists for this address, so the entry can't be deleted from here — deleting
|
|
* it would revoke that user's access (is_allowed reads this row). Delete the user instead. */
|
|
has_account: boolean;
|
|
/** The address stays allow-listed by the server's env ALLOWED_EMAILS / ADMIN_EMAILS, so removing
|
|
* this row would NOT take access away. The delete confirmation says so. */
|
|
still_allowed_by_env: boolean;
|
|
/** When the admin last invited this person, or null if never. Adding an address to the whitelist
|
|
* is deliberately silent — it opens a door rather than knocking — so telling them is a separate
|
|
* act, and this is how the admin sees they have already done it. */
|
|
invited_at: string | null;
|
|
/** The language this person asked us in, or null when nobody ever told us — a row the admin
|
|
* whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI
|
|
* language, so the guess is visible and correctable before anything is sent. */
|
|
lang: string | null;
|
|
}
|
|
|
|
/** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the
|
|
* one thing the client cannot work out for itself. */
|
|
export interface PendingAccessRow extends AdminUserRow {
|
|
/** The address is already allowed and the account is merely inactive, so Activate finishes a
|
|
* decision the admin has already made. False when there is no approved invite behind it —
|
|
* activating then would make an account that signs in with a password but is turned away by
|
|
* Google, so the whitelist box is the honest move instead. */
|
|
can_activate: boolean;
|
|
}
|
|
|
|
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
|
export interface ConfigItem {
|
|
key: string;
|
|
type: "int" | "str" | "bool" | "choice";
|
|
group: string;
|
|
secret: boolean;
|
|
min: number | null;
|
|
max: number | null;
|
|
is_set: boolean; // a DB override row exists (else using the env/config default)
|
|
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
|
|
choices: string[]; // a "choice" key's options (the backend Literal); [] for any other type
|
|
value?: string | number | boolean; // non-secret: effective value
|
|
default?: string | number | boolean; // non-secret: env/config default
|
|
default_is_set?: boolean; // secret: whether an env default exists
|
|
}
|
|
export interface SystemConfigData {
|
|
groups: Record<string, ConfigItem[]>;
|
|
/** The page's headings, in order, each with its groups in order; every group is in one. */
|
|
sections: { id: string; groups: string[] }[];
|
|
secrets_manageable: boolean;
|
|
}
|
|
|
|
// Admin Users & roles page.
|
|
export interface AdminUserRow {
|
|
id: number;
|
|
email: string;
|
|
display_name: string | null;
|
|
role: string; // "user" | "admin"
|
|
is_active: boolean;
|
|
is_suspended: boolean;
|
|
email_verified: boolean;
|
|
has_password: boolean;
|
|
/** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only
|
|
* account) from "we took it away" — different problems needing different help. */
|
|
password_revoked_at: string | null;
|
|
has_google: boolean;
|
|
/** The Google account's address, ONLY when it differs from the account address. */
|
|
google_email: string | null;
|
|
created_at: string | null;
|
|
/** Last sign-in (Google or password). Null = not since 0.88 (no backfill). */
|
|
last_login_at: string | null;
|
|
}
|
|
|
|
/** The account panel's view of why someone can or cannot get in. */
|
|
export interface AdminUserAccess extends AdminUserRow {
|
|
/** The address passes the invite gate — so Google sign-in will be let through, not filed as a
|
|
* fresh access request. */
|
|
is_allowed: boolean;
|
|
can_activate: boolean;
|
|
/** Whether this instance has working mail at all. Decides whether the two mail-shaped recovery
|
|
* actions are offered or shown as unavailable — rather than failing after the click. */
|
|
email_configured: boolean;
|
|
}
|
|
|
|
export interface AuditRow {
|
|
id: number;
|
|
created_at: string | null;
|
|
/** 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;
|
|
/** How many of that request's log lines are still kept (they go after days, the row after months). */
|
|
log_lines: 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;
|
|
/** A failure the job named (`progress.JobFailed`): its reason, and the values it is said with. */
|
|
failure: { failure: 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
|
|
* 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;
|
|
run_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;
|
|
run_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,
|
|
run_id: query.run_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}` : "";
|
|
}
|
|
|
|
/** `?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`:
|
|
* the process stopped mid-run (a deploy, a crash), and its next start closed the row. The values are
|
|
* held to the server's by backendIds.contract.test (through RUN_STATUSES, typed as `RunStatus`). */
|
|
export type RunOutcome = "ok" | "error" | "skipped" | "interrupted";
|
|
export type RunStatus = RunOutcome | "running";
|
|
|
|
/** One past run, as the row's history dots show it. */
|
|
export interface RunBrief {
|
|
id: number;
|
|
status: RunOutcome | "running";
|
|
trigger: "scheduled" | "manual" | "ad_hoc";
|
|
started_at: string;
|
|
finished_at: string | null;
|
|
}
|
|
|
|
/** The fields every Scheduler row has from its runs: how the newest ended one went, and the few
|
|
* before it. */
|
|
export interface RunFields {
|
|
status: RunOutcome | null;
|
|
last_started: string | null;
|
|
last_finished: string | null;
|
|
last_result: string | null; // the English one-liner — the fallback for a job with no `result`
|
|
// The last result as data: counts, a `skipped` / `reason` word, a nested dict per phase —
|
|
// named in the reader's language by lib/jobResult.
|
|
result: JobResult | null;
|
|
last_error: string | null;
|
|
last_trigger: RunBrief["trigger"] | null;
|
|
last_run_id: number | null;
|
|
quota_units: number | null;
|
|
/** The newest runs, newest first (at most three). */
|
|
recent: RunBrief[];
|
|
}
|
|
|
|
export interface SchedulerJob extends RunFields {
|
|
id: string;
|
|
interval_minutes: number;
|
|
next_run: string | null;
|
|
running: boolean;
|
|
// Live progress while running (null when idle or for jobs that don't report it).
|
|
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
|
|
progress: { current: number; total: number | null; phase: string | null } | null;
|
|
}
|
|
|
|
/** Work a user starts on one target (a channel's full history): no interval, no "run now". */
|
|
export interface SchedulerAdHoc extends RunFields {
|
|
id: string;
|
|
running: boolean;
|
|
target: string | null;
|
|
target_title: string | null;
|
|
}
|
|
|
|
export interface SchedulerStatus {
|
|
running_here: boolean;
|
|
enabled: boolean;
|
|
paused: boolean;
|
|
jobs: SchedulerJob[];
|
|
ad_hoc: SchedulerAdHoc[];
|
|
queue: {
|
|
channels_recent_pending: number;
|
|
channels_deep_pending: number;
|
|
deep_eta_seconds: number;
|
|
videos_pending_enrich: number;
|
|
videos_pending_shorts: number;
|
|
videos_live_refresh: number;
|
|
};
|
|
quota: {
|
|
used_today: number;
|
|
remaining_today: number;
|
|
daily_budget: number;
|
|
backfill_reserve: number;
|
|
};
|
|
maintenance: {
|
|
revalidate_batch: number;
|
|
revalidate_batch_default: number;
|
|
min: number;
|
|
max: number;
|
|
};
|
|
}
|
|
|
|
export const adminApi = {
|
|
// --- discovery / system configuration ---
|
|
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
|
|
purgeDiscovery: (): Promise<Record<string, number>> =>
|
|
req("/api/admin/purge-discovery", { method: "POST" }),
|
|
adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
|
|
setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
|
|
req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
|
|
resetConfig: (key: string): Promise<SystemConfigData> =>
|
|
req(`/api/admin/config/${key}`, { method: "DELETE" }),
|
|
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
|
req("/api/admin/config/test-email", { method: "POST" }),
|
|
/** The bug-report tracker: answers, takes issues, and is private. */
|
|
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
|
|
req("/api/admin/config/test-issues", { method: "POST" }),
|
|
// --- users & roles ---
|
|
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> =>
|
|
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 }) }),
|
|
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
|
|
req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
|
|
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
|
|
req(`/api/admin/users/${id}`, { method: "DELETE" }),
|
|
// --- scheduler + maintenance ---
|
|
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
|
updateSchedulerJob: (
|
|
jobId: string,
|
|
intervalMinutes: number,
|
|
): Promise<{ id: string; interval_minutes: number }> =>
|
|
req(`/api/admin/scheduler/jobs/${jobId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ interval_minutes: intervalMinutes }),
|
|
}),
|
|
runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
|
|
req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
|
|
runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
|
|
req("/api/admin/scheduler/run-all", { method: "POST" }),
|
|
updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
|
|
req("/api/admin/scheduler/maintenance", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
|
}),
|
|
// --- access invites ---
|
|
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
|
|
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),
|
|
// Let one in. Admin-only and explicit: it changes discoverability, not authority — the same thing
|
|
// was always possible by re-entering the address in the whitelist box.
|
|
activateUser: (id: number): Promise<AdminUserRow> =>
|
|
req(`/api/admin/users/${id}/activate`, { method: "POST" }, { quiet: true }),
|
|
// Re-send someone's confirmation link. Unlike the public resend this one says what happened,
|
|
// because the caller is already an authenticated admin.
|
|
resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> =>
|
|
req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }),
|
|
// What the account panel shows: the two computed gate answers, resolved for one user on open.
|
|
adminUserAccess: (id: number): Promise<AdminUserAccess> => req(`/api/admin/users/${id}/access`),
|
|
// Mint a one-time, no-email link that lets this account set a password (SEC-15 / SEC-16, and the
|
|
// everyday "no SMTP and someone forgot theirs"). Issuing does not revoke the current password.
|
|
createSetPasswordLink: (
|
|
id: number,
|
|
): Promise<{ ok: boolean; url: string; expires_at: string | null; email: string }> =>
|
|
req(`/api/admin/users/${id}/set-password-link`, { method: "POST" }, { quiet: true }),
|
|
adminInvites: (status?: string): Promise<Invite[]> =>
|
|
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
|
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
|
|
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
|
|
// Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so
|
|
// this is the deliberate act that actually reaches a person.
|
|
sendInvitation: (id: number, lang: string): Promise<Invite> =>
|
|
req(
|
|
`/api/admin/invites/${id}/invite`,
|
|
{ method: "POST", body: JSON.stringify({ lang }) },
|
|
{ quiet: true },
|
|
),
|
|
addInvite: (email: string) =>
|
|
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
|
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
|
|
* ALLOWED_EMAILS / ADMIN_EMAILS, i.e. removing the row did not actually revoke access. */
|
|
// quiet: a 409 here ("that address has an account") is a case the UI handles with a translated
|
|
// toast; without this the shared handler ALSO raises the server's English detail as a modal.
|
|
deleteInvite: (id: number): Promise<{ deleted: number; still_allowed_by_env: boolean }> =>
|
|
req(`/api/admin/invites/${id}`, { method: "DELETE" }, { quiet: true }),
|
|
};
|