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'
});