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:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user