metatroncubeswdev ca42c781fa Admin: audit log and system health
- Every successful change in the dashboard is logged (who, what, when) with passwords, secrets and tokens masked;
  shown with the publish log in plain English, filter by period, client, person, search
- System health: publishing/scheduler switches, overdue or failed posts, stale numbers refreshes,
  connections needing reconnect or expiring, email, AI runs and API key, last backup, data size
- docker-compose mounts ./backups read-only so health can see the last backup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:24:33 -04:00

71 lines
5.7 KiB
JavaScript

// System health for admins: is everything that runs in the background actually running?
import fs from "node:fs";
import path from "node:path";
import { PROJECT_ROOT } from "../../connector/src/env.js";
import * as clients from "./clients.js";
import * as metrics from "./metrics.js";
import * as jobs from "./jobs.js";
import * as notify from "./notify.js";
import * as sched from "../../connector/src/schedule.js";
import * as extra from "../../connector/src/platforms/index.js";
const HOUR = 3600000;
function dirSize(dir) {
let total = 0;
const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else { try { total += fs.statSync(p).size; } catch { /* gone */ } } } };
try { walk(dir); } catch { return null; }
return total;
}
/** A list of checks: { area, status: ok|warn|bad, title, detail }. */
export function checks() {
const out = [];
const add = (area, status, title, detail = "") => out.push({ area, status, title, detail });
// Publishing
const pubOn = process.env.ALLOW_PUBLISH === "true", schedOn = process.env.SCHEDULER_ENABLED !== "false";
add("Publishing", pubOn && schedOn ? "ok" : "warn", pubOn && schedOn ? "Publishing and the scheduler are on" : `Publishing ${pubOn ? "on" : "OFF (ALLOW_PUBLISH)"} · scheduler ${schedOn ? "on" : "OFF (SCHEDULER_ENABLED)"}`);
const posts = sched.listPosts({});
const stuck = posts.filter((p) => p.status === "approved" && !p.retryAt && Date.now() - new Date(p.publishAt) > 15 * 60000);
add("Publishing", stuck.length ? "bad" : "ok", stuck.length ? `${stuck.length} approved post${stuck.length === 1 ? "" : "s"} past due and not sent` : "No overdue posts", stuck.slice(0, 5).map((p) => `${p.clientName || p.client} · ${p.localTime}`).join(" · "));
const failed = posts.filter((p) => ["failed", "partial"].includes(p.status) && Date.now() - new Date(p.publishedAt || p.publishAt) < 7 * 24 * HOUR);
add("Publishing", failed.length ? "warn" : "ok", failed.length ? `${failed.length} failed or partly published in the last 7 days` : "No failed posts in the last 7 days");
// Numbers refresh
const all = clients.listClients().filter((c) => c.pageId || c.igId);
const stale = all.filter((c) => { const s = metrics.readSnapshot(c.slug); return !s || Date.now() - new Date(s.fetchedAt) > 36 * HOUR; });
add("Numbers", stale.length ? "warn" : "ok", stale.length ? `${stale.length} of ${all.length} clients not refreshed in 36 hours` : `All ${all.length} linked clients refreshed in the last 36 hours`, stale.map((c) => c.name).join(", "));
const snapErr = all.map((c) => [c, metrics.readSnapshot(c.slug)]).filter(([, s]) => s?.errors?.length);
if (snapErr.length) add("Numbers", "warn", `${snapErr.length} client${snapErr.length === 1 ? "" : "s"} had errors in the last refresh`, snapErr.map(([c, s]) => `${c.name}: ${s.errors[0]}`).join(" · ").slice(0, 400));
// Connections
const conns = extra.vault.listConnections({});
const broken = conns.filter((c) => c.status !== "connected");
add("Connections", broken.length ? "bad" : "ok", broken.length ? `${broken.length} connection${broken.length === 1 ? "" : "s"} need reconnecting` : conns.length ? `${conns.length} platform connection${conns.length === 1 ? "" : "s"} working` : "No platform connections yet (Connections page)", broken.map((c) => `${c.client} ${c.platform}: ${c.lastError || c.status}`).join(" · ").slice(0, 400));
const soon = conns.filter((c) => c.refreshExpiresAt && new Date(c.refreshExpiresAt) - Date.now() < 7 * 24 * HOUR);
if (soon.length) add("Connections", "warn", `${soon.length} sign-in${soon.length === 1 ? "" : "s"} expire within 7 days`, soon.map((c) => `${c.client} ${c.platform} (${c.refreshExpiresAt.slice(0, 10)})`).join(" · "));
// Email
add("Email", notify.emailConfigured() ? "ok" : "warn", notify.emailConfigured() ? `Email set up (${process.env.SMTP_HOST})` : "Email not set up: notifications only appear in the bell", notify.emailConfigured() ? "" : "Add SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM to .env.");
// AI runs
const recent = jobs.listJobs({}).filter((j) => Date.now() - new Date(j.createdAt) < 7 * 24 * HOUR);
const jobFails = recent.filter((j) => ["failed", "interrupted"].includes(j.status));
add("AI runs", process.env.ANTHROPIC_API_KEY ? (jobFails.length ? "warn" : "ok") : "warn",
!process.env.ANTHROPIC_API_KEY ? "No Anthropic API key: AI runs (audits, weekly plans) can't start" : jobFails.length ? `${jobFails.length} AI run${jobFails.length === 1 ? "" : "s"} failed this week` : `AI runs working (${recent.length} this week)`,
jobFails.slice(0, 3).map((j) => `${j.type} ${j.slug || ""}: ${j.error || j.status}`).join(" · "));
// Backups and disk
const bdir = path.join(PROJECT_ROOT, "backups");
let latest = null;
try { latest = fs.readdirSync(bdir).filter((f) => /^quorla-.*\.tar\.gz$/.test(f)).map((f) => ({ f, t: fs.statSync(path.join(bdir, f)).mtimeMs })).sort((a, b) => b.t - a.t)[0] || null; } catch { /* not mounted */ }
add("Backups", !latest ? "warn" : Date.now() - latest.t > 30 * HOUR ? "bad" : "ok",
!latest ? "Can't see any backups" : `Last backup ${Math.round((Date.now() - latest.t) / HOUR)} h ago`,
!latest ? "Run deploy/backup.sh daily with cron and mount ./backups in docker-compose (read-only)." : latest.f);
const size = dirSize(path.join(PROJECT_ROOT, "data"));
if (size != null) add("Storage", size > 20 * 1024 ** 3 ? "warn" : "ok", `Data folder uses ${(size / 1024 ** 3).toFixed(2)} GB`, "Uploaded media lives here; old published media can be archived.");
const order = { bad: 0, warn: 1, ok: 2 };
return { at: new Date().toISOString(), version: process.env.QUORLA_VERSION || null, checks: out.sort((a, b) => order[a.status] - order[b.status]) };
}