From 43c2a0d09cae124f4a54a66b117ce1b7c58f20fa Mon Sep 17 00:00:00 2001 From: fabi Date: Wed, 29 Jul 2026 19:48:06 +0200 Subject: [PATCH] feat(host): warn about low disk before it becomes unrecoverable Storage visibility existed in exactly one place: a passive Speicherauslastung widget on the ADMIN dashboard. A host who isn't the admin had no view of it, and nothing warned anyone. README carried "Low-disk alert (< 10 GB free)" under Planned since v1. Two things make this a safety net rather than a nice-to-have. postgres_data, media_data and exports_data are all Docker named volumes on ONE filesystem, so running out doesn't degrade a subsystem -- Postgres stops being able to write and the whole event goes down. And the keepsake needs room for two gallery-sized archives, which the export preflight can only ever refuse AFTER the release, when the event is over and every remedy is harder. So the threshold is not a fixed number alone. It fires on the 10 GB floor the README always named, OR on "you could not build the keepsake right now" -- the trigger a host can still act on, computed with the same arithmetic the preflight uses. Unknown free space is NOT low: it fails open like the upload quota and the preflight do, because a banner that cries wolf on an unreadable mount is a banner nobody reads. Carried on GET /host/event, which the dashboard already fetches on load and on every reload -- no new endpoint, no new poll. Rendered above everything else including the PIN-reset queue, and it names the consequence (the event, not just the download) rather than only the number. Also fixes the host page's formatBytes, which topped out at MB: 30 GB free would have rendered as "30720.0 MB", and a guest with 2 GB of uploads was already being shown that way in the user list. Tests: 5 unit on the threshold (including that plenty of free space is still low when the keepsake wouldn't fit -- the case a fixed threshold misses entirely), 3 e2e. The e2e drives it through `original_size_bytes` rather than a genuinely full disk: the estimate is pure SQL over that column, so overstating one row moves the accounting without touching a byte on disk. Co-Authored-By: Claude Opus 5 --- backend/src/handlers/host.rs | 86 +++++++++++++++++++ backend/src/services/export.rs | 16 +++- e2e/fixtures/db.ts | 14 ++++ e2e/specs/04-host/low-disk-warning.spec.ts | 97 ++++++++++++++++++++++ frontend/src/routes/host/+page.svelte | 41 ++++++++- 5 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 e2e/specs/04-host/low-disk-warning.spec.ts diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs index a4a73c5..d8e6aea 100644 --- a/backend/src/handlers/host.rs +++ b/backend/src/handlers/host.rs @@ -35,6 +35,32 @@ pub struct EventStatus { pub is_active: bool, pub uploads_locked: bool, pub export_released: bool, + /// Free space on the volume the keepsake is written to. `None` when the mount can't be + /// resolved — the UI hides the widget rather than rendering a confident zero. + pub disk_free_bytes: Option, + /// What a full keepsake build would need right now (both halves). + pub keepsake_required_bytes: u64, + /// Whether the host should be warned. See [`disk_is_low`]. + pub disk_low: bool, +} + +/// Absolute floor below which free space is worth surfacing regardless of gallery size — the +/// threshold the README has carried on the roadmap since v1. +const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000; + +/// Is free space low enough that the host needs to know? +/// +/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`, +/// `media_data` and `exports_data` are all Docker named volumes on one filesystem, so a full disk +/// does not degrade one subsystem — it stops Postgres writing and takes the event down. That is +/// what the absolute floor is for. +/// +/// The second trigger is the one that actually earns its place: the keepsake needs room for two +/// gallery-sized archives, and the only moment a host can do anything about that is BEFORE they +/// release. Warning at "you could not build the keepsake right now" turns a post-event dead end +/// into a decision someone can still make. +fn disk_is_low(free: u64, keepsake_required: u64) -> bool { + free < LOW_DISK_FLOOR_BYTES || free < keepsake_required } /// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators @@ -72,11 +98,29 @@ pub async fn get_event_status( .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; + // Measured on the EXPORT volume, not the media one: that is where the cliff is, and it is a + // distinct mount point even when both are backed by the same filesystem. The cached reading is + // right here — this is advisory, polled on every dashboard load, and a 15s-stale number costs + // nothing (unlike the export preflight, which reads uncached because it is about to write). + let free = state + .disk_cache + .snapshot(&state.config.export_path) + .map(|d| d.free); + let keepsake_required_bytes = + crate::services::export::keepsake_space_required(&state.pool, event.id) + .await + .unwrap_or(0); + Ok(Json(EventStatus { name: event.name, is_active: event.is_active, uploads_locked: event.uploads_locked_at.is_some(), export_released: event.export_released_at.is_some(), + disk_free_bytes: free, + keepsake_required_bytes, + // Unknown free space is NOT low. Fails open, exactly as the upload quota and the export + // preflight do: a scary banner on an unreadable mount would train the host to ignore it. + disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required_bytes)), })) } @@ -765,3 +809,45 @@ pub async fn release_gallery( Ok(StatusCode::NO_CONTENT) } + +#[cfg(test)] +mod tests { + use super::{LOW_DISK_FLOOR_BYTES, disk_is_low}; + + const GB: u64 = 1_000_000_000; + + #[test] + fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() { + assert!(!disk_is_low(40 * GB, 25 * GB)); + } + + #[test] + fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() { + // All three volumes share one filesystem, so running out doesn't degrade one subsystem — + // Postgres stops being able to write and the event goes down. A 1 GB gallery would clear + // the keepsake test comfortably; the floor is what catches this. + assert!(disk_is_low(5 * GB, GB)); + assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0)); + assert!(!disk_is_low(LOW_DISK_FLOOR_BYTES, 0)); + } + + #[test] + fn plenty_of_space_is_still_low_when_the_keepsake_would_not_fit() { + // THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near + // any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this + // before releasing; after releasing, they cannot. + assert!(disk_is_low(30 * GB, 66 * GB)); + } + + #[test] + fn the_keepsake_trigger_is_exact_at_the_boundary() { + assert!(!disk_is_low(66 * GB, 66 * GB), "exactly enough is enough"); + assert!(disk_is_low(66 * GB - 1, 66 * GB)); + } + + #[test] + fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() { + assert!(!disk_is_low(11 * GB, 0)); + assert!(disk_is_low(9 * GB, 0)); + } +} diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs index 51099a5..4cac9ab 100644 --- a/backend/src/services/export.rs +++ b/backend/src/services/export.rs @@ -1268,7 +1268,7 @@ fn is_superseded_archive( /// want, since being wrong low means ENOSPC halfway through. /// /// Matches [`query_uploads`]' visibility filter exactly, so hidden/banned uploads aren't counted. -async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> Result { +pub async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> Result { let (bytes,): (i64,) = sqlx::query_as( "SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint FROM upload u @@ -1302,6 +1302,20 @@ fn required_free_bytes(media_bytes: u64, armed: i64) -> u64 { needed.min(u64::MAX as u128) as u64 } +/// Free bytes a full keepsake build would need RIGHT NOW, both halves included. +/// +/// The same arithmetic the preflight uses, exposed so the host dashboard can warn BEFORE the +/// release rather than reporting a failure after it. The preflight can only ever say "this didn't +/// fit"; at that point the gallery is full, the event is over, and the remedies (ask guests to stop +/// uploading, grow the volume) are all much harder. Hard-codes both halves because that is what a +/// release arms. +pub async fn keepsake_space_required(pool: &PgPool, event_id: Uuid) -> Result { + Ok(required_free_bytes( + estimate_export_bytes(pool, event_id).await?, + 2, + )) +} + /// Refuse to start an export that cannot fit, with a reason the host can act on. /// /// Without this the failure mode is ENOSPC halfway through a multi-GB write, and the wreckage diff --git a/e2e/fixtures/db.ts b/e2e/fixtures/db.ts index 7db267d..223e9a1 100644 --- a/e2e/fixtures/db.ts +++ b/e2e/fixtures/db.ts @@ -105,6 +105,20 @@ export const db = { }); }, + /** + * Overstate an upload's recorded size. + * + * The keepsake size estimate and the low-disk threshold are pure SQL over + * `original_size_bytes` — no file is read — so this is the lever for driving "the keepsake + * would not fit" without a genuinely full disk. The bytes on disk are unchanged; only the + * accounting the warning reads from moves. + */ + async setUploadSizeBytes(uploadId: string, bytes: number) { + await withClient((c) => + c.query(`UPDATE upload SET original_size_bytes = $2 WHERE id = $1`, [uploadId, bytes]) + ); + }, + async setExportReleased(slug: string, released: boolean) { await withClient((c) => c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [ diff --git a/e2e/specs/04-host/low-disk-warning.spec.ts b/e2e/specs/04-host/low-disk-warning.spec.ts new file mode 100644 index 0000000..bdd17f8 --- /dev/null +++ b/e2e/specs/04-host/low-disk-warning.spec.ts @@ -0,0 +1,97 @@ +/** + * Regression guard — the host is warned about storage BEFORE it becomes unrecoverable. + * + * Storage visibility used to exist in exactly one place: a passive "Speicherauslastung" widget on + * the ADMIN dashboard. A host who isn't the admin had no view of it at all, and nothing anywhere + * warned anyone. README listed a low-disk alert under "Planned (v1.x)". + * + * Two things make that a safety net rather than a nice-to-have: + * + * - `postgres_data`, `media_data` and `exports_data` are all Docker named volumes on ONE + * filesystem. A full disk doesn't degrade a subsystem; Postgres stops being able to write and + * the whole event goes down. + * - The keepsake needs room for TWO gallery-sized archives (both write their media + * `Compression::Stored`; `Memories.zip` streams the original for every video and every image + * at or under 5 MB). The export preflight can refuse cleanly, but only AFTER the release — + * when the event is over, the gallery is full, and every remedy is harder. + * + * So the threshold is deliberately NOT a fixed number alone. It fires on an absolute floor (10 GB, + * the figure the README always carried) OR on "you could not build the keepsake right now", which + * is the trigger a host can still act on. + * + * These drive it through `original_size_bytes` rather than a genuinely full disk: the estimate is + * pure SQL over that column, so overstating one row moves the accounting the warning reads without + * touching a byte on disk. + */ +import { test, expect } from '../../fixtures/test'; +import { seedUpload } from '../../helpers/seed'; +import { BASE } from '../../helpers/env'; + +/** Comfortably larger than any disk this suite could run on. */ +const ABSURD_BYTES = 500_000_000_000_000; + +test.describe('Host — low-disk warning', () => { + test('a gallery too big to export warns the host, with the numbers', async ({ + page, + host, + guest, + signIn, + db, + }) => { + const g = await guest('BigShooter'); + const uploadId = await seedUpload(g.jwt); + await db.setUploadSizeBytes(uploadId, ABSURD_BYTES); + + await signIn(page, host); + await page.goto('/host'); + + const warning = page.getByTestId('low-disk-warning'); + await expect(warning, 'the host must be warned before releasing').toBeVisible({ + timeout: 15_000, + }); + // The actionable half: not just "low", but "the keepsake cannot be built". + await expect(warning).toContainText(/nicht.*erstellt werden/i); + // And the consequence that makes it urgent — the event, not just the download. + await expect(warning).toContainText(/gesamte Event/i); + }); + + test('the API reports the requirement and the verdict together', async ({ host, guest, db }) => { + const g = await guest('BigShooter2'); + const uploadId = await seedUpload(g.jwt); + await db.setUploadSizeBytes(uploadId, ABSURD_BYTES); + + const res = await fetch(`${BASE}/api/v1/host/event`, { + headers: { Authorization: `Bearer ${host.jwt}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + disk_low: boolean; + disk_free_bytes: number | null; + keepsake_required_bytes: number; + }; + + expect(body.disk_low).toBe(true); + expect( + body.keepsake_required_bytes, + 'both halves are armed by a release, so the requirement covers two archives' + ).toBeGreaterThan(ABSURD_BYTES); + expect(body.disk_free_bytes).not.toBeNull(); + expect(body.keepsake_required_bytes).toBeGreaterThan(body.disk_free_bytes!); + }); + + test('an ordinary gallery shows no warning at all', async ({ page, host, guest, signIn }) => { + // The mirror that keeps the above honest. A warning that is always on is a warning nobody + // reads — and it would sit at the very top of the dashboard, above the PIN-reset queue. + const g = await guest('NormalShooter'); + await seedUpload(g.jwt); + + await signIn(page, host); + await page.goto('/host'); + + // Wait for the dashboard to actually be loaded before asserting on an absence. + await expect(page.getByRole('heading', { name: 'Host-Dashboard' })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByTestId('low-disk-warning')).toHaveCount(0); + }); +}); diff --git a/frontend/src/routes/host/+page.svelte b/frontend/src/routes/host/+page.svelte index f485927..e142a4e 100644 --- a/frontend/src/routes/host/+page.svelte +++ b/frontend/src/routes/host/+page.svelte @@ -28,6 +28,9 @@ is_active: boolean; uploads_locked: boolean; export_released: boolean; + disk_free_bytes: number | null; + keepsake_required_bytes: number; + disk_low: boolean; } interface PinResetRequest { @@ -395,7 +398,11 @@ function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + // GB matters here now that this also renders free disk and keepsake size — the previous + // version topped out at MB, so 30 GB free read as "30720.0 MB" (and a guest with 2 GB of + // uploads was already being rendered the same way in the user list). + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } @@ -507,6 +514,38 @@ {error} {:else if event} + + {#if event.disk_low && event.disk_free_bytes !== null} +
+

Speicherplatz wird knapp

+

+ Noch {formatBytes(event.disk_free_bytes)} frei. + {#if event.keepsake_required_bytes > event.disk_free_bytes} + Für das Keepsake werden derzeit ca. + {formatBytes(event.keepsake_required_bytes)} benötigt — es kann + momentan nicht erstellt werden. + {/if} +

+

+ Schaffe Speicher frei oder vergrößere den Datenträger. Wenn der Datenträger vollläuft, + fällt das gesamte Event aus — nicht nur der Download. +

+
+ {/if} + {#if pinResetRequests.length > 0}