From ca42c781fae6e1d99c03ac1bb0c5fb61a67cd9ef Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 10:24:33 -0400 Subject: [PATCH] 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 --- dashboard/server/activity.js | 70 ++++++++++++++++++++++ dashboard/server/health.js | 70 ++++++++++++++++++++++ dashboard/server/index.js | 7 +++ dashboard/web/src/App.jsx | 9 ++- dashboard/web/src/pages/Admin.jsx | 97 +++++++++++++++++++++++++++++++ docker-compose.yml | 1 + 6 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 dashboard/server/activity.js create mode 100644 dashboard/server/health.js create mode 100644 dashboard/web/src/pages/Admin.jsx diff --git a/dashboard/server/activity.js b/dashboard/server/activity.js new file mode 100644 index 0000000..5adfa12 --- /dev/null +++ b/dashboard/server/activity.js @@ -0,0 +1,70 @@ +// Activity log: every change someone makes in the dashboard (POST/PUT/PATCH/DELETE that succeeded), +// with secrets masked. Read together with the queue's publish log on the Audit page. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "./config.js"; +import { PROJECT_ROOT } from "../../connector/src/env.js"; + +const FILE = path.join(DATA_DIR, "activity.jsonl"); +const PUBLISH_LOG = path.join(PROJECT_ROOT, "data", "publish-log.jsonl"); +const SECRET = /pass(word)?|secret|token|api[_-]?key|authorization|cookie/i; +const MAX_BYTES = 20 * 1024 * 1024; + +function mask(v, depth = 0) { + if (depth > 4 || v == null) return v; + if (typeof v === "string") return v.length > 300 ? `${v.slice(0, 300)}…` : v; + if (Array.isArray(v)) return v.slice(0, 20).map((x) => mask(x, depth + 1)); + if (typeof v === "object") return Object.fromEntries(Object.entries(v).slice(0, 40).map(([k, x]) => [k, SECRET.test(k) ? "•••" : mask(x, depth + 1)])); + return v; +} + +/** Express middleware: log successful writes once the response is sent. */ +export function middleware(req, res, next) { + if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return next(); + res.on("finish", () => { + if (res.statusCode >= 400 || !req.user) return; + // Skip noise: marking notifications read, uploads (body is the file). + if (/\/api\/notifications\/read$|\/upload$/.test(req.path)) return; + const entry = { at: new Date().toISOString(), by: req.user.name || req.user.email, userId: req.user.id, method: req.method, path: req.originalUrl.split("?")[0], body: mask(req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body) ? req.body : undefined), status: res.statusCode }; + try { + if (fs.existsSync(FILE) && fs.statSync(FILE).size > MAX_BYTES) fs.renameSync(FILE, FILE.replace(/\.jsonl$/, `-${Date.now()}.jsonl`)); + fs.appendFileSync(FILE, JSON.stringify(entry) + "\n"); + } catch (e) { console.error("Activity log failed", e.message); } + }); + next(); +} + +const readLines = (f) => { try { return fs.readFileSync(f, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } }; + +// Plain-English description of an API call. +const WHAT = [ + [/^POST \/api\/schedule\/[^/]+\/approve$/, "approved a post"], [/^POST \/api\/schedule\/[^/]+\/cancel$/, "cancelled a post"], + [/^POST \/api\/schedule\/[^/]+\/retry$/, "retried a post"], [/^POST \/api\/schedule\/[^/]+\/send-to-client$/, "sent a post to the client"], + [/^POST \/api\/schedule$/, "created a draft post"], [/^PATCH \/api\/schedule\//, "edited a post"], + [/^POST \/api\/library\/bulk-schedule$/, "bulk-scheduled library items"], [/^POST \/api\/library\/[^/]+\/review$/, "reviewed library work"], + [/^POST \/api\/library\/[^/]+\/schedule$/, "scheduled library work"], [/^POST \/api\/library$/, "uploaded work to the library"], [/^POST \/api\/library\/[^/]+\/archive$/, "archived library work"], + [/^(POST|PATCH|DELETE) \/api\/plan/, "changed the content plan"], [/^POST \/api\/calendar\/move$/, "moved a calendar item"], + [/^(POST|PATCH|DELETE) \/api\/users/, "changed a team member"], [/^POST \/api\/me\/password$/, "changed their password"], + [/^(PUT|DELETE) \/api\/platforms\//, "changed a platform app"], [/^(PATCH|DELETE) \/api\/connections\//, "changed a connection"], [/^POST \/api\/connections\/[^/]+\/check$/, "checked a connection"], + [/^(POST|PATCH|DELETE) \/api\/portal\//, "changed a client approval link"], [/^POST \/api\/inbox\/(reviews\/)?reply$/, "replied to a comment or review"], [/^POST \/api\/inbox\/hide$/, "hid or unhid a comment"], + [/^(POST|DELETE) \/api\/competitors/, "changed tracked competitors"], [/^PUT \/api\/brandkit\//, "edited a brand kit"], [/^PUT \/api\/bio\//, "edited a link-in-bio page"], + [/^POST \/api\/clients\/[^/]+\/monthly\/email$/, "emailed a monthly report"], [/^POST \/api\/clients\/[^/]+\/monthly\/build$/, "created a monthly report"], + [/^POST \/api\/jobs/, "started an AI run"], [/^POST \/api\/team\/tasks/, "changed an Odoo task"], +]; +const describe = (e) => WHAT.find(([re]) => re.test(`${e.method} ${e.path}`))?.[1] || `${e.method} ${e.path.replace(/^\/api\//, "")}`; +const clientOf = (e) => e.body?.client || e.path.match(/\/(?:clients|portal|brandkit|bio|competitors)\/([a-z0-9-]+)/)?.[1] || null; + +/** Activity + publishing events, newest first. */ +export function list({ days = 30, user, client, q, limit = 300 } = {}) { + const since = new Date(Date.now() - days * 86400000).toISOString(); + const acts = readLines(FILE).filter((e) => e.at >= since).map((e) => ({ at: e.at, by: e.by, kind: "action", text: describe(e), client: clientOf(e), detail: e.body ? JSON.stringify(e.body).slice(0, 400) : "" })); + const pubs = readLines(PUBLISH_LOG).filter((e) => e.at >= since).map((e) => ({ + at: e.at, by: e.by || "Quorla", kind: e.action === "failed" ? "error" : "publish", client: e.client || null, + text: e.action === "published" ? `published to ${e.platform}` : e.action === "failed" ? `couldn't publish to ${e.platform}` : e.action === "waiting_for_quota" ? `waiting for ${e.platform}'s daily limit` : e.action.replace(/_/g, " "), + detail: e.error || e.result?.permalink || "", + })); + const needle = String(q || "").toLowerCase(); + return [...acts, ...pubs] + .filter((e) => (!user || e.by === user) && (!client || e.client === client) && (!needle || `${e.text} ${e.detail} ${e.by}`.toLowerCase().includes(needle))) + .sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit); +} diff --git a/dashboard/server/health.js b/dashboard/server/health.js new file mode 100644 index 0000000..d40d79c --- /dev/null +++ b/dashboard/server/health.js @@ -0,0 +1,70 @@ +// 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]) }; +} diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 371062c..b896156 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -14,6 +14,8 @@ import * as notify from "./notify.js"; import * as notifyEvents from "./notify-events.js"; import * as monthly from "./monthly.js"; import * as bio from "./bio.js"; +import * as activity from "./activity.js"; +import * as health from "./health.js"; import * as odoo from "./odoo.js"; import * as jobs from "./jobs.js"; import * as docs from "./docs.js"; @@ -128,6 +130,7 @@ app.get("/oauth/:platform/callback", async (req, res) => { // ---------- everything below needs a session ---------- app.use("/api", requireAuth); +app.use("/api", activity.middleware); // ---------- your account (everyone) ---------- app.patch("/api/me", h(async (req) => ({ user: users.renameSelf(req.user.id, req.body?.name) }))); @@ -358,6 +361,10 @@ app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.c app.get("/api/bio/:client", h(async (req) => ({ page: bio.get(req.params.client), stats: bio.stats(req.params.client, Number(req.query.days) || 30) }))); app.put("/api/bio/:client", requireAdmin, h(async (req) => ({ page: bio.save(req.params.client, req.body || {}), stats: bio.stats(req.params.client) }))); +// ---------- admin: audit log and system health ---------- +app.get("/api/admin/audit", requireAdmin, h(async (req) => ({ items: activity.list({ days: Number(req.query.days) || 30, user: req.query.user || undefined, client: req.query.client || undefined, q: req.query.q || undefined }) }))); +app.get("/api/admin/health", requireAdmin, h(async () => health.checks())); + // ---------- notifications ---------- app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id))); app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); })); diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index 491ff13..ec7a838 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; -import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords } from "lucide-react"; +import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords, Activity, History } from "lucide-react"; import { api } from "./api.js"; import { cx, Spinner } from "./ui.jsx"; import Login from "./pages/Login.jsx"; @@ -14,6 +14,7 @@ import Published from "./pages/Published.jsx"; import Calendar from "./pages/Calendar.jsx"; import Competitors from "./pages/Competitors.jsx"; import NotificationBell from "./Notifications.jsx"; +import { Audit, Health } from "./pages/Admin.jsx"; import Inbox from "./pages/Inbox.jsx"; import Library from "./pages/Library.jsx"; import Connections from "./pages/Connections.jsx"; @@ -32,6 +33,8 @@ const NAV = [ { to: "/connections", label: "Connections", icon: Plug }, { to: "/team", label: "Team", icon: Users2 }, { to: "/runs", label: "AI runs", icon: Sparkles }, + { to: "/admin/health", label: "System health", icon: Activity, admin: true }, + { to: "/admin/audit", label: "Audit log", icon: History, admin: true }, { to: "/settings", label: "Settings", icon: Settings2 }, ]; @@ -48,7 +51,7 @@ function Shell({ children }) { {agency &&
{agency}
}