- 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>
71 lines
5.7 KiB
JavaScript
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]) };
|
|
}
|