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 { settingsRouter } from "./routes/settings.js";
|
||||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||||
|
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||||
|
|
||||||
warnIfAuthNotConfigured();
|
warnIfAuthNotConfigured();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
await initSecretExpiryScheduler();
|
await initSecretExpiryScheduler();
|
||||||
await initTailscaleKeyExpiryScheduler();
|
await initTailscaleKeyExpiryScheduler();
|
||||||
|
await initLogRetentionScheduler();
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const webDist = join(__dirname, "..", "..", "web", "dist");
|
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { recordAudit } from "../services/audit.js";
|
|||||||
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
||||||
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
||||||
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.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 { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
|
||||||
import { asyncHandler } from "../utils/asyncHandler.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) })
|
.object({ dateFormat: z.enum(["ymd", "dmy", "mdy"]), timeFormat: z.enum(["24h", "12h"]), pageSize: z.number().int().min(5).max(500) })
|
||||||
.partial()
|
.partial()
|
||||||
.optional(),
|
.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) => {
|
settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||||
@@ -84,6 +90,9 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
|||||||
await scheduleSecretExpiryCheck();
|
await scheduleSecretExpiryCheck();
|
||||||
await scheduleTailscaleKeyExpiryCheck();
|
await scheduleTailscaleKeyExpiryCheck();
|
||||||
}
|
}
|
||||||
|
if (parsed.data.logRetention) {
|
||||||
|
await scheduleLogRetentionPurge();
|
||||||
|
}
|
||||||
|
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
actor: req.currentUser!,
|
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) });
|
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;
|
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 {
|
export interface AppSettings {
|
||||||
gotify: GotifySettings;
|
gotify: GotifySettings;
|
||||||
ntfy: NtfySettings;
|
ntfy: NtfySettings;
|
||||||
@@ -66,6 +74,7 @@ export interface AppSettings {
|
|||||||
providerColors: ProviderColors;
|
providerColors: ProviderColors;
|
||||||
integrationColors: IntegrationColors;
|
integrationColors: IntegrationColors;
|
||||||
display: DisplaySettings;
|
display: DisplaySettings;
|
||||||
|
logRetention: LogRetentionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: AppSettings = {
|
const DEFAULTS: AppSettings = {
|
||||||
@@ -85,6 +94,7 @@ const DEFAULTS: AppSettings = {
|
|||||||
providerColors: {},
|
providerColors: {},
|
||||||
integrationColors: {},
|
integrationColors: {},
|
||||||
display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 },
|
display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 },
|
||||||
|
logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import NotificationSettings from "./pages/settings/NotificationSettings";
|
|||||||
import BadgeSettings from "./pages/settings/BadgeSettings";
|
import BadgeSettings from "./pages/settings/BadgeSettings";
|
||||||
import DisplaySettings from "./pages/settings/DisplaySettings";
|
import DisplaySettings from "./pages/settings/DisplaySettings";
|
||||||
import CacheSettings from "./pages/settings/CacheSettings";
|
import CacheSettings from "./pages/settings/CacheSettings";
|
||||||
|
import LogSettings from "./pages/settings/LogSettings";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
import { setDateTimeSettings } from "./utils/date";
|
import { setDateTimeSettings } from "./utils/date";
|
||||||
import { setPageSize } from "./utils/pageSize";
|
import { setPageSize } from "./utils/pageSize";
|
||||||
@@ -126,6 +127,7 @@ export default function App() {
|
|||||||
<Route path="badges" element={<BadgeSettings />} />
|
<Route path="badges" element={<BadgeSettings />} />
|
||||||
<Route path="display" element={<DisplaySettings />} />
|
<Route path="display" element={<DisplaySettings />} />
|
||||||
<Route path="cache" element={<CacheSettings />} />
|
<Route path="cache" element={<CacheSettings />} />
|
||||||
|
<Route path="logs" element={<LogSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ export interface DisplaySettings {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogRetentionSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
retentionDays: number;
|
||||||
|
intervalHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
gotify: GotifySettings;
|
gotify: GotifySettings;
|
||||||
ntfy: NtfySettings;
|
ntfy: NtfySettings;
|
||||||
@@ -162,6 +168,7 @@ export interface AppSettings {
|
|||||||
providerColors: Record<string, string>;
|
providerColors: Record<string, string>;
|
||||||
integrationColors: Record<string, string>;
|
integrationColors: Record<string, string>;
|
||||||
display: DisplaySettings;
|
display: DisplaySettings;
|
||||||
|
logRetention: LogRetentionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
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) }),
|
request<{ ok: true }>("/api/settings/test-smtp", { method: "POST", body: JSON.stringify(data) }),
|
||||||
testWebhook: (data: { url: string; secret?: string }) =>
|
testWebhook: (data: { url: string; secret?: string }) =>
|
||||||
request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }),
|
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" }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -5,6 +5,7 @@ const SUB_NAV = [
|
|||||||
{ to: "/settings/badges", label: "Badges" },
|
{ to: "/settings/badges", label: "Badges" },
|
||||||
{ to: "/settings/display", label: "Display" },
|
{ to: "/settings/display", label: "Display" },
|
||||||
{ to: "/settings/cache", label: "Cache" },
|
{ to: "/settings/cache", label: "Cache" },
|
||||||
|
{ to: "/settings/logs", label: "Logs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user