// 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)); const aiReady = Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_BASE_URL); add("AI runs", aiReady ? (jobFails.length ? "warn" : "ok") : "warn", !aiReady ? "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]) }; }