Add automatic retention purging for the Diagnostic and Audit logs

The diagnostic log already rings-buffer to 500 rows, but the audit
log had no cap at all and would grow forever. Adds an opt-in
age-based purge under Settings -> Logs: keep entries for N days,
checked on a configurable interval (hourly through monthly), plus a
manual "Purge now" button. Reuses the existing node-schedule-style
reschedule-on-settings-change pattern from the secret/Tailscale
expiry checkers, but as a plain setInterval since "how often" here is
an interval rather than a specific daily time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-19 02:22:32 +02:00
1 parent af3f7e77d2
commit 10d123b18a
9 files changed
+281

No files matched your search

+2
View File
@@ -23,11 +23,13 @@ import { integrationsRouter } from "./routes/integrations.js";
import { settingsRouter } from "./routes/settings.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
warnIfAuthNotConfigured();
await runMigrations();
await initSecretExpiryScheduler();
await initTailscaleKeyExpiryScheduler();
await initLogRetentionScheduler();
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDist = join(__dirname, "..", "..", "web", "dist");
+21
View File
@@ -5,6 +5,8 @@ import { recordAudit } from "../services/audit.js";
import { getSettings, updateSettings } from "../services/settingsStore.js";
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js";
import { purgeOldLogs } from "../services/logRetention.js";
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
import { asyncHandler } from "../utils/asyncHandler.js";
@@ -70,6 +72,10 @@ const updateSchema = z.object({
.object({ dateFormat: z.enum(["ymd", "dmy", "mdy"]), timeFormat: z.enum(["24h", "12h"]), pageSize: z.number().int().min(5).max(500) })
.partial()
.optional(),
logRetention: z
.object({ enabled: z.boolean(), retentionDays: z.number().int().min(1).max(3650), intervalHours: z.number().int().min(1).max(720) })
.partial()
.optional(),
});
settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
@@ -84,6 +90,9 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
await scheduleSecretExpiryCheck();
await scheduleTailscaleKeyExpiryCheck();
}
if (parsed.data.logRetention) {
await scheduleLogRetentionPurge();
}
await recordAudit({
actor: req.currentUser!,
@@ -151,3 +160,15 @@ settingsRouter.post("/test-webhook", requireRole("admin"), asyncHandler(async (r
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/purge-logs", requireRole("admin"), asyncHandler(async (req, res) => {
const { logRetention } = await getSettings();
const result = await purgeOldLogs(logRetention.retentionDays);
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "purge_logs",
detail: { retentionDays: logRetention.retentionDays, ...result },
});
res.json(result);
}));
+22
View File
@@ -0,0 +1,22 @@
import { lt, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { diagLog, auditLog } from "../db/schema.js";
export interface PurgeResult {
diagDeleted: number;
auditDeleted: number;
}
/** Deletes diagnostic and audit log entries older than `retentionDays`. */
export async function purgeOldLogs(retentionDays: number): Promise<PurgeResult> {
// Matches the "YYYY-MM-DD HH:MM:SS" format SQLite's own current_timestamp
// produces (used as the default for both tables' createdAt columns), so
// the string comparison in `lt` sorts correctly.
const cutoff = sql`datetime('now', ${`-${retentionDays} days`})`;
const diagResult = await db.delete(diagLog).where(lt(diagLog.createdAt, cutoff));
const auditResult = await db.delete(auditLog).where(lt(auditLog.createdAt, cutoff));
return {
diagDeleted: Number(diagResult.rowsAffected ?? 0),
auditDeleted: Number(auditResult.rowsAffected ?? 0),
};
}
@@ -0,0 +1,49 @@
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
import { purgeOldLogs } from "./logRetention.js";
const LAST_RUN_FLAG = "logRetentionLastRunAt";
async function runPurge(): Promise<void> {
const { logRetention } = await getSettings();
if (!logRetention.enabled) return;
const result = await purgeOldLogs(logRetention.retentionDays);
await setInternalFlag(LAST_RUN_FLAG, new Date().toISOString());
if (result.diagDeleted || result.auditDeleted) {
console.log(
`[logRetention] purged ${result.diagDeleted} diagnostic log and ${result.auditDeleted} audit log entries older than ${logRetention.retentionDays} days`,
);
}
}
let currentTimer: ReturnType<typeof setInterval> | null = null;
/** (Re)arms the periodic purge timer per the current settings. Call again after settings change. */
export async function scheduleLogRetentionPurge(): Promise<void> {
if (currentTimer) {
clearInterval(currentTimer);
currentTimer = null;
}
const { logRetention } = await getSettings();
if (!logRetention.enabled) {
console.log("[logRetention] automatic purge disabled");
return;
}
const intervalMs = logRetention.intervalHours * 60 * 60 * 1000;
currentTimer = setInterval(() => {
runPurge().catch((err) => console.error("[logRetention] purge failed:", err));
}, intervalMs);
console.log(`[logRetention] automatic purge scheduled every ${logRetention.intervalHours}h, keeping ${logRetention.retentionDays} days`);
}
/** Runs immediately at startup if a purge is overdue (e.g. the server was down past the interval), then arms the periodic timer. */
export async function initLogRetentionScheduler(): Promise<void> {
const { logRetention } = await getSettings();
if (logRetention.enabled) {
const lastRun = await getInternalFlag(LAST_RUN_FLAG);
const dueAt = lastRun ? new Date(lastRun).getTime() + logRetention.intervalHours * 60 * 60 * 1000 : 0;
if (Date.now() >= dueAt) {
await runPurge().catch((err) => console.error("[logRetention] purge failed:", err));
}
}
await scheduleLogRetentionPurge();
}
+10
View File
@@ -57,6 +57,14 @@ export interface DisplaySettings {
pageSize: number;
}
export interface LogRetentionSettings {
enabled: boolean;
/** Diagnostic log and audit log entries older than this are deleted. */
retentionDays: number;
/** How often the purge job runs, in hours. */
intervalHours: number;
}
export interface AppSettings {
gotify: GotifySettings;
ntfy: NtfySettings;
@@ -66,6 +74,7 @@ export interface AppSettings {
providerColors: ProviderColors;
integrationColors: IntegrationColors;
display: DisplaySettings;
logRetention: LogRetentionSettings;
}
const DEFAULTS: AppSettings = {
@@ -85,6 +94,7 @@ const DEFAULTS: AppSettings = {
providerColors: {},
integrationColors: {},
display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 },
logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 },
};
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
+2
View File
@@ -23,6 +23,7 @@ import NotificationSettings from "./pages/settings/NotificationSettings";
import BadgeSettings from "./pages/settings/BadgeSettings";
import DisplaySettings from "./pages/settings/DisplaySettings";
import CacheSettings from "./pages/settings/CacheSettings";
import LogSettings from "./pages/settings/LogSettings";
import AppShell from "./layout/AppShell";
import { setDateTimeSettings } from "./utils/date";
import { setPageSize } from "./utils/pageSize";
@@ -126,6 +127,7 @@ export default function App() {
<Route path="badges" element={<BadgeSettings />} />
<Route path="display" element={<DisplaySettings />} />
<Route path="cache" element={<CacheSettings />} />
<Route path="logs" element={<LogSettings />} />
</Route>
</Routes>
</AppShell>
+9
View File
@@ -153,6 +153,12 @@ export interface DisplaySettings {
pageSize: number;
}
export interface LogRetentionSettings {
enabled: boolean;
retentionDays: number;
intervalHours: number;
}
export interface AppSettings {
gotify: GotifySettings;
ntfy: NtfySettings;
@@ -162,6 +168,7 @@ export interface AppSettings {
providerColors: Record<string, string>;
integrationColors: Record<string, string>;
display: DisplaySettings;
logRetention: LogRetentionSettings;
}
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
@@ -792,5 +799,7 @@ export const api = {
request<{ ok: true }>("/api/settings/test-smtp", { method: "POST", body: JSON.stringify(data) }),
testWebhook: (data: { url: string; secret?: string }) =>
request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }),
purgeLogs: () =>
request<{ diagDeleted: number; auditDeleted: number }>("/api/settings/purge-logs", { method: "POST" }),
},
};
+1
View File
@@ -5,6 +5,7 @@ const SUB_NAV = [
{ to: "/settings/badges", label: "Badges" },
{ to: "/settings/display", label: "Display" },
{ to: "/settings/cache", label: "Cache" },
{ to: "/settings/logs", label: "Logs" },
];
export default function Settings() {
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import { api, type AppSettings } from "../../api/client";
const INTERVAL_OPTIONS: { value: number; label: string }[] = [
{ value: 1, label: "Every hour" },
{ value: 6, label: "Every 6 hours" },
{ value: 12, label: "Every 12 hours" },
{ value: 24, label: "Daily" },
{ value: 168, label: "Weekly" },
{ value: 720, label: "Monthly" },
];
export default function LogSettings() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [enabled, setEnabled] = useState(false);
const [retentionDays, setRetentionDays] = useState(90);
const [intervalHours, setIntervalHours] = useState(24);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [purging, setPurging] = useState(false);
const [purgeResult, setPurgeResult] = useState<{ diagDeleted: number; auditDeleted: number } | null>(null);
const [purgeError, setPurgeError] = useState<string | null>(null);
useEffect(() => {
api.settings
.get()
.then((res: { settings: AppSettings }) => {
setEnabled(res.settings.logRetention.enabled);
setRetentionDays(res.settings.logRetention.retentionDays);
setIntervalHours(res.settings.logRetention.intervalHours);
})
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoading(false));
}, []);
async function handleSave() {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
await api.settings.update({ logRetention: { enabled, retentionDays, intervalHours } });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function handlePurgeNow() {
if (!confirm(`Delete diagnostic and audit log entries older than ${retentionDays} days now?`)) return;
setPurging(true);
setPurgeError(null);
setPurgeResult(null);
try {
const result = await api.settings.purgeLogs();
setPurgeResult(result);
} catch (err) {
setPurgeError(err instanceof Error ? err.message : String(err));
} finally {
setPurging(false);
}
}
if (loading) return <div className="text-secondary">Loading…</div>;
return (
<>
<div className="d-flex align-items-center justify-content-between mb-3">
<h3 className="mb-0">Logs</h3>
<div className="d-flex align-items-center gap-2">
{saveError && <span className="text-danger small">{saveError}</span>}
{saved && <span className="text-success small">✓ Settings saved</span>}
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save Settings"}
</button>
</div>
</div>
{loadError && <div className="alert alert-danger">{loadError}</div>}
<div className="row row-cards">
<div className="col-md-7">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Automatic purge</h3>
</div>
<div className="card-body">
<p className="text-secondary">
Automatically deletes old entries from the Diagnostic Log and Audit Log so they don't grow
unbounded. The Diagnostic Log already keeps only its most recent 500 calls regardless of this
setting; this additionally purges by age, and is the only thing that trims the Audit Log.
</p>
<label className="form-check mb-3">
<input type="checkbox" className="form-check-input" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span className="form-check-label">Automatically purge old log entries</span>
</label>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">Keep entries for</label>
<div className="input-group">
<input
type="number"
className="form-control"
min={1}
max={3650}
value={retentionDays}
disabled={!enabled}
onChange={(e) => setRetentionDays(Number(e.target.value))}
/>
<span className="input-group-text">days</span>
</div>
</div>
<div className="col-md-6">
<label className="form-label">Purge frequency</label>
<select
className="form-select"
value={intervalHours}
disabled={!enabled}
onChange={(e) => setIntervalHours(Number(e.target.value))}
>
{INTERVAL_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-5">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Purge now</h3>
</div>
<div className="card-body">
<p className="text-secondary">
Runs a purge immediately using the retention period above, regardless of whether automatic
purging is enabled.
</p>
{purgeError && <div className="alert alert-danger mb-0">{purgeError}</div>}
{purgeResult && (
<div className="alert alert-success mb-0">
Deleted {purgeResult.diagDeleted} diagnostic log and {purgeResult.auditDeleted} audit log
{" "}entries.
</div>
)}
</div>
<div className="card-footer">
<button className="btn btn-outline-danger" onClick={handlePurgeNow} disabled={purging}>
{purging ? "Purging…" : "Purge now"}
</button>
</div>
</div>
</div>
</div>
</>
);
}