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:
1 parent
af3f7e77d2
commit
10d123b18a
9 files changed
+281
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)[];
|
||||
|
||||
Reference in new issue
Block a user