Scope log retention deletion to selected hosts, not the whole table

api/logretention no longer deletes wholesale by age alone: a new
GET /logs/retention/hosts lists every host with matching records (plus
any configured retention floor), and preview/delete now require an
explicit, non-empty host list -- there is no "omitted host means every
host" shortcut server-side. Store's count/delete statements are
host-scoped (host IN (...)); Handler.partitionHosts checks the floor
per host instead of one global max, so a floor on one host never
blocks acting on other hosts requested in the same call. A request
that ends up fully or partially blocked still returns 200 with
blocked_hosts explaining why, rather than rejecting the whole call.

Settings' Log retention section is a host picker now: checkboxes with
per-host counts and a "protected Nd" badge where a floor applies,
"select all/none", and a confirm panel that names exactly which hosts
will be affected and which were skipped and why.

Verified live against real ClickHouse/Postgres and in-browser: three
hosts seeded, one protected by a 90-day floor -- a scoped delete
correctly removed the two open hosts' records, left the protected
host's untouched, and the response/UI both named it as skipped. Also
fixed a real spacing bug in the result message caught during that
browser pass (an adjacent {expr}{#if} with no source whitespace
between them rendered with no space either).
This commit is contained in:
2026-08-21 15:32:05 -07:00
parent a20bb5d1c7
commit 087c52a64f
6 changed files with 742 additions and 199 deletions
+28 -6
View File
@@ -483,16 +483,38 @@ export function setUserRole(id: string, role: string): Promise<LocalUser> {
}
// --- log retention (owner/admin only, see api/logretention) -----------
// Deletion is host-scoped, not wholesale: a caller must name which
// hosts' logs to target (listRetentionHosts is how the UI discovers
// what to offer), and api/logretention never treats an omitted host
// list as "every host."
export type LogRetentionPreview = { count: number; cutoff: string };
export type LogRetentionDeleteResult = { deleted_count: number; cutoff: string };
export type BlockedHost = { host: string; protected_days: number };
export type RetentionHost = { host: string; count: number; protected_days?: number };
export type RetentionHostsResult = { hosts: RetentionHost[]; cutoff: string };
export type LogRetentionPreview = { count: number; cutoff: string; hosts: string[]; blocked_hosts?: BlockedHost[] };
export type LogRetentionDeleteResult = {
deleted_count: number;
cutoff: string;
deleted_hosts: string[];
blocked_hosts?: BlockedHost[];
};
export function previewLogDeletion(olderThanHours: number): Promise<LogRetentionPreview> {
return request(`/logs/retention/preview?older_than_hours=${olderThanHours}`, { credentials: 'include' });
function hostsQuery(hosts: string[]): string {
return hosts.map((h) => `host=${encodeURIComponent(h)}`).join('&');
}
export function deleteLogsOlderThan(olderThanHours: number): Promise<LogRetentionDeleteResult> {
return request(`/logs/retention?older_than_hours=${olderThanHours}`, {
export function listRetentionHosts(olderThanHours: number): Promise<RetentionHostsResult> {
return request(`/logs/retention/hosts?older_than_hours=${olderThanHours}`, { credentials: 'include' });
}
export function previewLogDeletion(olderThanHours: number, hosts: string[]): Promise<LogRetentionPreview> {
return request(`/logs/retention/preview?older_than_hours=${olderThanHours}&${hostsQuery(hosts)}`, {
credentials: 'include'
});
}
export function deleteLogsOlderThan(olderThanHours: number, hosts: string[]): Promise<LogRetentionDeleteResult> {
return request(`/logs/retention?older_than_hours=${olderThanHours}&${hostsQuery(hosts)}`, {
method: 'DELETE',
credentials: 'include'
});
+204 -21
View File
@@ -5,11 +5,13 @@
localAuthEnabled,
getLocalSession,
getCurrentSession,
listRetentionHosts,
previewLogDeletion,
deleteLogsOlderThan,
type AuthFeatures,
type LocalSession,
type CurrentSession,
type RetentionHost,
type LogRetentionPreview,
type LogRetentionDeleteResult
} from '$lib/api';
@@ -68,7 +70,10 @@
return !localAuthEnabled;
});
// --- log retention deletion ---
// --- log retention deletion -- host-scoped, not wholesale: a caller
// picks which hosts to target from what listRetentionHosts reports for
// the chosen age, same "select specific agents, not delete everything"
// requirement api/logretention's own parseHosts enforces server-side. ---
const retentionOptions: { label: string; hours: number }[] = [
{ label: '7 days', hours: 24 * 7 },
{ label: '30 days', hours: 24 * 30 },
@@ -77,18 +82,67 @@
{ label: '365 days', hours: 24 * 365 }
];
let retentionHours = $state(retentionOptions[1].hours);
let hostsLoading = $state(false);
let hosts = $state<RetentionHost[]>([]);
let hostsError = $state('');
let selectedHosts = $state<Set<string>>(new Set());
let previewing = $state(false);
let preview = $state<LogRetentionPreview | null>(null);
let deleting = $state(false);
let deleteResult = $state<LogRetentionDeleteResult | null>(null);
let retentionError = $state('');
// Reloads whenever retentionHours changes (including on mount, since
// $effect runs once immediately too) -- selection/preview/result all
// reset because they were scoped to the previous age's host list.
async function loadHosts(hours: number) {
hostsLoading = true;
hostsError = '';
preview = null;
deleteResult = null;
selectedHosts = new Set();
try {
const result = await listRetentionHosts(hours);
hosts = result.hosts;
} catch (e) {
hostsError = e instanceof Error ? e.message : String(e);
hosts = [];
} finally {
hostsLoading = false;
}
}
$effect(() => {
loadHosts(retentionHours);
});
function toggleHost(host: string) {
const next = new Set(selectedHosts);
if (next.has(host)) next.delete(host);
else next.add(host);
selectedHosts = next;
preview = null;
deleteResult = null;
}
function selectAllHosts() {
selectedHosts = new Set(hosts.map((h) => h.host));
preview = null;
}
function selectNoHosts() {
selectedHosts = new Set();
preview = null;
}
async function handlePreview() {
if (selectedHosts.size === 0) return;
previewing = true;
retentionError = '';
deleteResult = null;
try {
preview = await previewLogDeletion(retentionHours);
preview = await previewLogDeletion(retentionHours, [...selectedHosts]);
} catch (e) {
retentionError = e instanceof Error ? e.message : String(e);
} finally {
@@ -105,8 +159,16 @@
deleting = true;
retentionError = '';
try {
deleteResult = await deleteLogsOlderThan(retentionHours);
// preview.hosts, not [...selectedHosts] -- already excludes any
// host the preview found protected, so this never re-asks for a
// host the response just said would be skipped.
const result = await deleteLogsOlderThan(retentionHours, preview.hosts);
preview = null;
// loadHosts resets deleteResult as part of its "fresh state" load
// (stale counts/now-empty hosts shouldn't linger), so it runs
// before deleteResult is set here, not after.
await loadHosts(retentionHours);
deleteResult = result;
} catch (e) {
retentionError = e instanceof Error ? e.message : String(e);
} finally {
@@ -182,34 +244,87 @@
<section>
<h2>Log retention</h2>
<p class="note">
Permanently delete log records older than a chosen age. Visible to owners and admins only.
Permanently delete log records from specific hosts, older than a chosen age. Visible to owners and
admins only.
</p>
<div class="retention-controls">
<select bind:value={retentionHours} disabled={previewing || deleting}>
<select bind:value={retentionHours} disabled={hostsLoading || 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 hostsError}<p class="error">{hostsError}</p>{/if}
{#if hostsLoading}
<p class="muted">Loading hosts…</p>
{:else if hosts.length === 0}
<p class="note">No hosts have logs older than this.</p>
{:else}
<div class="host-picker">
<div class="host-picker-actions">
<button type="button" class="link" onclick={selectAllHosts} disabled={deleting}>Select all</button>
<button type="button" class="link" onclick={selectNoHosts} disabled={deleting}>Select none</button>
</div>
<ul class="host-list">
{#each hosts as h (h.host)}
<li>
<label>
<input
type="checkbox"
checked={selectedHosts.has(h.host)}
disabled={deleting}
onchange={() => toggleHost(h.host)}
/>
<span class="host-name">{h.host}</span>
<span class="host-count">{h.count.toLocaleString()} records</span>
{#if h.protected_days != null}
<span class="protected-badge">protected {h.protected_days}d</span>
{/if}
</label>
</li>
{/each}
</ul>
<button type="button" onclick={handlePreview} disabled={selectedHosts.size === 0 || previewing || deleting}>
{previewing
? 'Checking…'
: `Delete logs from ${selectedHosts.size} host${selectedHosts.size === 1 ? '' : 's'}`}
</button>
</div>
{/if}
{#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>
{#if preview.hosts.length > 0}
<p>
This will <strong>permanently delete {preview.count.toLocaleString()}</strong>
log record{preview.count === 1 ? '' : 's'} older than {formatCutoff(preview.cutoff)} from
{preview.hosts.length} host{preview.hosts.length === 1 ? '' : 's'} ({preview.hosts.join(', ')}).
This cannot be undone.
</p>
{:else}
<p>Every selected host is protected by a retention policy -- nothing to delete.</p>
{/if}
{#if preview.blocked_hosts?.length}
<p class="note">
Protected by a retention policy, skipped: {preview.blocked_hosts
.map((b) => `${b.host} (${b.protected_days}d)`)
.join(', ')}.
</p>
{/if}
<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 type="button" onclick={cancelPreview} disabled={deleting}>
{preview.hosts.length > 0 ? 'Cancel' : 'Close'}
</button>
{#if preview.hosts.length > 0}
<button type="button" class="danger" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Yes, delete permanently'}
</button>
{/if}
</div>
</div>
{/if}
@@ -218,7 +333,13 @@
<p class="note">
Deleted {deleteResult.deleted_count.toLocaleString()} log record{deleteResult.deleted_count === 1
? ''
: 's'} older than {formatCutoff(deleteResult.cutoff)}.
: 's'} older than {formatCutoff(deleteResult.cutoff)}
{#if deleteResult.deleted_hosts.length}from {deleteResult.deleted_hosts.join(', ')}{/if}.
{#if deleteResult.blocked_hosts?.length}
Skipped (protected): {deleteResult.blocked_hosts
.map((b) => `${b.host} (${b.protected_days}d)`)
.join(', ')}.
{/if}
</p>
{/if}
</section>
@@ -338,21 +459,83 @@
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.retention-controls button {
.host-picker {
margin-top: var(--space-3);
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.host-picker-actions {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.link {
background: none;
border: none;
padding: 0;
color: var(--color-accent);
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
.link:disabled {
cursor: default;
opacity: 0.6;
}
.host-list {
list-style: none;
margin: 0 0 var(--space-3);
padding: 0;
max-height: 14rem;
overflow-y: auto;
}
.host-list li {
border-bottom: 1px solid var(--color-border);
}
.host-list li:last-child {
border-bottom: none;
}
.host-list label {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-1);
font-size: var(--text-sm);
cursor: pointer;
}
.host-name {
font-family: var(--font-mono);
color: var(--color-text);
}
.host-count {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.protected-badge {
margin-left: auto;
font-size: var(--text-xs);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.05rem 0.4rem;
}
.host-picker > 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);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.retention-controls button:hover {
.host-picker > button:hover:not(:disabled) {
border-color: var(--color-border-strong);
}
.retention-controls button:disabled {
.host-picker > button:disabled {
cursor: default;
opacity: 0.6;
}