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>
This commit is contained in:
parent
958ec9a1b5
commit
ca42c781fa
70
dashboard/server/activity.js
Normal file
70
dashboard/server/activity.js
Normal file
@ -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);
|
||||
}
|
||||
70
dashboard/server/health.js
Normal file
70
dashboard/server/health.js
Normal file
@ -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]) };
|
||||
}
|
||||
@ -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); }));
|
||||
|
||||
@ -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 && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
|
||||
</div>
|
||||
<nav className="px-2 mt-2 space-y-0.5 flex-1">
|
||||
{NAV.map(({ to, label, icon: Icon, end }) => (
|
||||
{NAV.filter((n) => !n.admin || user.role === "admin").map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
@ -129,6 +132,8 @@ export default function App() {
|
||||
<Route path="/published" element={<Published />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/competitors" element={<Competitors />} />
|
||||
<Route path="/admin/health" element={<Health />} />
|
||||
<Route path="/admin/audit" element={<Audit />} />
|
||||
<Route path="/inbox" element={<Inbox />} />
|
||||
<Route path="/connections" element={<Connections />} />
|
||||
<Route path="/team" element={<Team />} />
|
||||
|
||||
97
dashboard/web/src/pages/Admin.jsx
Normal file
97
dashboard/web/src/pages/Admin.jsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CircleAlert, CircleCheck, History, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, fmtDateTime, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
|
||||
const ICON = { ok: CircleCheck, warn: TriangleAlert, bad: CircleAlert };
|
||||
const TONE = { ok: "text-ok", warn: "text-warn", bad: "text-bad" };
|
||||
|
||||
/** Admin → System health: everything that runs in the background, in one list. */
|
||||
export function Health() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const load = useCallback(() => { setBusy(true); return api("/api/admin/health").then(setData).catch(setError).finally(() => setBusy(false)); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const counts = data ? { bad: data.checks.filter((c) => c.status === "bad").length, warn: data.checks.filter((c) => c.status === "warn").length } : null;
|
||||
return (
|
||||
<div className="space-y-5 max-w-[960px]">
|
||||
<header className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">System health</h1>
|
||||
<p className="text-muted mt-0.5">{!data ? "Checking…" : counts.bad ? `${counts.bad} problem${counts.bad === 1 ? "" : "s"} to fix` : counts.warn ? `All running, ${counts.warn} thing${counts.warn === 1 ? "" : "s"} to look at` : "Everything is running"}{data ? ` · checked ${timeAgo(data.at)}` : ""}</p>
|
||||
</div>
|
||||
<Button icon={RefreshCw} loading={busy} onClick={load}>Check again</Button>
|
||||
</header>
|
||||
<ErrorNote error={error} />
|
||||
{!data ? <Spinner /> : (
|
||||
<Panel bodyClass="divide-y divide-line">
|
||||
{data.checks.map((c, i) => {
|
||||
const Icon = ICON[c.status];
|
||||
return (
|
||||
<div key={i} className="px-4 py-3 flex gap-3 items-start">
|
||||
<Icon className={cx("size-[18px] mt-0.5 shrink-0", TONE[c.status])} aria-label={c.status === "ok" ? "OK" : c.status === "warn" ? "Warning" : "Problem"} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13.5px]"><span className="text-muted text-[12px] uppercase tracking-wide mr-2">{c.area}</span>{c.title}</div>
|
||||
{c.detail && <div className="text-[12.5px] text-muted mt-0.5 break-words">{c.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Admin → Audit log: who did what, and what Quorla published. */
|
||||
export function Audit() {
|
||||
const [clients, setClients] = useState([]);
|
||||
const [f, setF] = useState({ days: 30, client: "", user: "", q: "" });
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [shown, setShown] = useState(100);
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(() => {}); }, []);
|
||||
const load = useCallback(() => {
|
||||
const q = new URLSearchParams({ days: String(f.days) });
|
||||
for (const k of ["client", "user", "q"]) if (f[k]) q.set(k, f[k]);
|
||||
setItems(null); setError(null);
|
||||
return api(`/api/admin/audit?${q}`).then((r) => setItems(r.items)).catch(setError);
|
||||
}, [f]);
|
||||
useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]);
|
||||
const users = [...new Set((items || []).map((i) => i.by))].sort();
|
||||
const name = (slug) => clients.find((c) => c.slug === slug)?.name || slug;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header>
|
||||
<h1 className="text-[22px] font-extrabold">Audit log</h1>
|
||||
<p className="text-muted mt-0.5">Every change made in Quorla and everything it published. Passwords and keys are never recorded.</p>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<select aria-label="Period" className="input h-9 w-auto" value={f.days} onChange={(e) => setF({ ...f, days: Number(e.target.value) })}>{[7, 30, 90].map((d) => <option key={d} value={d}>Last {d} days</option>)}</select>
|
||||
<select aria-label="Client" className="input h-9 w-auto" value={f.client} onChange={(e) => setF({ ...f, client: e.target.value })}><option value="">All clients</option>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select>
|
||||
<select aria-label="Person" className="input h-9 w-auto" value={f.user} onChange={(e) => setF({ ...f, user: e.target.value })}><option value="">Everyone</option>{[...new Set([f.user, ...users].filter(Boolean))].map((u) => <option key={u} value={u}>{u}</option>)}</select>
|
||||
<input aria-label="Search" className="input h-9 w-56" placeholder="Search…" value={f.q} onChange={(e) => setF({ ...f, q: e.target.value })} />
|
||||
</div>
|
||||
<ErrorNote error={error} />
|
||||
<Panel>
|
||||
{!items ? <Spinner /> : !items.length ? <Empty icon={History} title="Nothing in this view" /> : (
|
||||
<ul className="divide-y divide-line">
|
||||
{items.slice(0, shown).map((e, i) => (
|
||||
<li key={i} className="px-4 py-2.5 grid grid-cols-[150px_1fr] gap-3 text-[13px]">
|
||||
<span className="text-muted num" title={e.at}>{fmtDateTime(e.at)}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{e.by}</span> {e.text}
|
||||
{e.client && <span className="text-muted"> · {name(e.client)}</span>}
|
||||
{e.kind === "error" && <Badge tone="bad" className="ml-2">error</Badge>}
|
||||
{e.detail && <div className="text-[12px] text-faint truncate" title={e.detail}>{e.detail}</div>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{items && items.length > shown && <div className="border-t border-line p-3 text-center"><Button size="sm" onClick={() => setShown(shown + 100)}>Show more</Button></div>}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -19,6 +19,7 @@ services:
|
||||
- ./team:/app/team
|
||||
- ./reports:/app/reports
|
||||
- ./branding:/app/branding
|
||||
- ./backups:/app/backups:ro
|
||||
expose:
|
||||
- "4310"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user