Add owner/admin-only log retention deletion to Settings

New api/logretention package: GET /logs/retention/preview and
DELETE /logs/retention, both gated to RoleAdmin (Owner satisfies it
too), issue purpose-built parameterized statements against ClickHouse's
logs table (a count and a synchronous ALTER TABLE ... DELETE mutation)
rather than routing through querylang/executor's SELECT-only SQLRunner.

Settings gets a new "Log retention" section, visible only to an owner
or admin, that previews how many records a chosen age cutoff would
remove before showing an explicit confirm/cancel panel -- no delete
happens without that second step.

Scoped to core's single-tenant ClickHouse table; enterprise/'s
per-tenant routing and Tantivy's lack of a bulk-delete primitive are
disclosed gaps in api/logretention/store.go's doc comment, not silently
assumed to already work.
This commit is contained in:
2026-08-21 14:49:18 -07:00
parent 864e68253a
commit 787def06fd
6 changed files with 676 additions and 1 deletions
+16
View File
@@ -482,6 +482,22 @@ export function setUserRole(id: string, role: string): Promise<LocalUser> {
});
}
// --- log retention (owner/admin only, see api/logretention) -----------
export type LogRetentionPreview = { count: number; cutoff: string };
export type LogRetentionDeleteResult = { deleted_count: number; cutoff: string };
export function previewLogDeletion(olderThanHours: number): Promise<LogRetentionPreview> {
return request(`/logs/retention/preview?older_than_hours=${olderThanHours}`, { credentials: 'include' });
}
export function deleteLogsOlderThan(olderThanHours: number): Promise<LogRetentionDeleteResult> {
return request(`/logs/retention?older_than_hours=${olderThanHours}`, {
method: 'DELETE',
credentials: 'include'
});
}
// --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence';
+237 -1
View File
@@ -1,5 +1,18 @@
<script lang="ts">
import { getAuthFeatures, enterpriseAuthBase, localAuthEnabled, type AuthFeatures } from '$lib/api';
import {
getAuthFeatures,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
getCurrentSession,
previewLogDeletion,
deleteLogsOlderThan,
type AuthFeatures,
type LocalSession,
type CurrentSession,
type LogRetentionPreview,
type LogRetentionDeleteResult
} from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
@@ -13,6 +26,104 @@
}
load();
// --- log retention gating (owner/admin only, see api/logretention) ---
let localSession = $state<LocalSession | 'disabled' | null>(null);
let currentSession = $state<CurrentSession | null>(null);
$effect(() => {
if (localAuthEnabled) getLocalSession().then((s) => (localSession = s));
});
$effect(() => {
if (enterpriseAuthBase) getCurrentSession().then((s) => (currentSession = s));
});
// A deployment with neither enterprise SSO nor local auth configured
// has no session concept at all -- same "Phase 0-3 default-open"
// posture RequireRole's nil-authorizer no-op gives the server side
// (see api/logretention/handler.go), so nothing is hidden here
// either.
//
// localAuthEnabled is checked first, not enterpriseAuthBase --
// enterpriseAuthBase only means enterprise-auth is *deployed and
// reachable* (e.g. for a "switch tenant" link), not that SSO is
// what's actually authenticating this browser session. main.go picks
// ENTERPRISE_AUTH_URL over LOCAL_AUTH_ENABLED for which *server-side*
// authorizer is live, but that's an independent, server-only choice
// -- a deployment can (and this repo's own production deployment
// does) run enterprise-auth alongside a local-auth-only api, so a
// real signed-in local session must win here even though
// enterpriseAuthBase is also set. Falls through to the enterprise
// session only if local auth isn't what actually resolved a session
// for this browser.
const canManageRetention = $derived.by(() => {
if (localAuthEnabled) {
const s = localSession;
if (s !== null && s !== 'disabled') {
return s.role === 'owner' || s.role === 'admin';
}
}
if (enterpriseAuthBase) {
const s = currentSession;
return s !== null && (s.role === 'owner' || s.role === 'admin');
}
return !localAuthEnabled;
});
// --- log retention deletion ---
const retentionOptions: { label: string; hours: number }[] = [
{ label: '7 days', hours: 24 * 7 },
{ label: '30 days', hours: 24 * 30 },
{ label: '90 days', hours: 24 * 90 },
{ label: '180 days', hours: 24 * 180 },
{ label: '365 days', hours: 24 * 365 }
];
let retentionHours = $state(retentionOptions[1].hours);
let previewing = $state(false);
let preview = $state<LogRetentionPreview | null>(null);
let deleting = $state(false);
let deleteResult = $state<LogRetentionDeleteResult | null>(null);
let retentionError = $state('');
async function handlePreview() {
previewing = true;
retentionError = '';
deleteResult = null;
try {
preview = await previewLogDeletion(retentionHours);
} catch (e) {
retentionError = e instanceof Error ? e.message : String(e);
} finally {
previewing = false;
}
}
function cancelPreview() {
preview = null;
}
async function confirmDelete() {
if (!preview) return;
deleting = true;
retentionError = '';
try {
deleteResult = await deleteLogsOlderThan(retentionHours);
preview = null;
} catch (e) {
retentionError = e instanceof Error ? e.message : String(e);
} finally {
deleting = false;
}
}
function formatCutoff(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
const themeOptions: { value: Theme; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Default' },
{ value: 'light', label: 'Light', hint: '' },
@@ -67,6 +178,52 @@
{/if}
</section>
{#if canManageRetention}
<section>
<h2>Log retention</h2>
<p class="note">
Permanently delete log records older than a chosen age. Visible to owners and admins only.
</p>
<div class="retention-controls">
<select bind:value={retentionHours} disabled={previewing || deleting}>
{#each retentionOptions as opt (opt.hours)}
<option value={opt.hours}>Older than {opt.label}</option>
{/each}
</select>
<button type="button" onclick={handlePreview} disabled={previewing || deleting}>
{previewing ? 'Checking…' : 'Delete logs…'}
</button>
</div>
{#if retentionError}<p class="error">{retentionError}</p>{/if}
{#if preview}
<div class="confirm-panel">
<p>
This will <strong>permanently delete {preview.count.toLocaleString()}</strong>
log record{preview.count === 1 ? '' : 's'} older than {formatCutoff(preview.cutoff)}.
This cannot be undone.
</p>
<div class="confirm-actions">
<button type="button" onclick={cancelPreview} disabled={deleting}>Cancel</button>
<button type="button" class="danger" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Yes, delete permanently'}
</button>
</div>
</div>
{/if}
{#if deleteResult}
<p class="note">
Deleted {deleteResult.deleted_count.toLocaleString()} log record{deleteResult.deleted_count === 1
? ''
: 's'} older than {formatCutoff(deleteResult.cutoff)}.
</p>
{/if}
</section>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if features.sso_configured}
@@ -160,4 +317,83 @@
.option.selected .option-hint {
color: var(--color-text);
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.retention-controls {
display: flex;
gap: var(--space-2);
align-items: center;
margin-top: var(--space-3);
}
.retention-controls select {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.retention-controls button {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.retention-controls button:hover {
border-color: var(--color-border-strong);
}
.retention-controls button:disabled {
cursor: default;
opacity: 0.6;
}
.confirm-panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-3);
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-danger);
border-radius: var(--radius-md);
}
.confirm-panel p {
font-size: var(--text-sm);
color: var(--color-text);
}
.confirm-actions {
display: flex;
gap: var(--space-2);
}
.confirm-actions button {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-sm);
cursor: pointer;
}
.confirm-actions button:not(.danger) {
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.confirm-actions button.danger {
color: var(--color-bg);
background: var(--color-danger);
border: none;
}
.confirm-actions button:disabled {
cursor: default;
opacity: 0.6;
}
</style>