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

+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)[];