diff --git a/server/src/index.ts b/server/src/index.ts index 0eaa64e..f81865d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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"); diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index f257b46..d7a21d6 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -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); +})); diff --git a/server/src/services/logRetention.ts b/server/src/services/logRetention.ts new file mode 100644 index 0000000..4323a79 --- /dev/null +++ b/server/src/services/logRetention.ts @@ -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 { + // 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), + }; +} diff --git a/server/src/services/logRetentionScheduler.ts b/server/src/services/logRetentionScheduler.ts new file mode 100644 index 0000000..e50386c --- /dev/null +++ b/server/src/services/logRetentionScheduler.ts @@ -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 { + 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 | null = null; + +/** (Re)arms the periodic purge timer per the current settings. Call again after settings change. */ +export async function scheduleLogRetentionPurge(): Promise { + 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 { + 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(); +} diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index 40e3c6f..df37a96 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -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)[]; diff --git a/web/src/App.tsx b/web/src/App.tsx index 0bc4413..e265aeb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c5b3060..13ef188 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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; integrationColors: Record; display: DisplaySettings; + logRetention: LogRetentionSettings; } export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; @@ -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" }), }, }; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 86717a5..e09928d 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -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() { diff --git a/web/src/pages/settings/LogSettings.tsx b/web/src/pages/settings/LogSettings.tsx new file mode 100644 index 0000000..ce5f50b --- /dev/null +++ b/web/src/pages/settings/LogSettings.tsx @@ -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(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(null); + const [purging, setPurging] = useState(false); + const [purgeResult, setPurgeResult] = useState<{ diagDeleted: number; auditDeleted: number } | null>(null); + const [purgeError, setPurgeError] = useState(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
Loading…
; + + return ( + <> +
+

Logs

+
+ {saveError && {saveError}} + {saved && ✓ Settings saved} + +
+
+ + {loadError &&
{loadError}
} + +
+
+
+
+

Automatic purge

+
+
+

+ 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. +

+ +
+
+ +
+ setRetentionDays(Number(e.target.value))} + /> + days +
+
+
+ + +
+
+
+
+
+ +
+
+
+

Purge now

+
+
+

+ Runs a purge immediately using the retention period above, regardless of whether automatic + purging is enabled. +

+ {purgeError &&
{purgeError}
} + {purgeResult && ( +
+ Deleted {purgeResult.diagDeleted} diagnostic log and {purgeResult.auditDeleted} audit log + {" "}entries. +
+ )} +
+
+ +
+
+
+
+ + ); +}