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 <noreply@anthropic.com>
This commit is contained in:
@@ -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`, [
|
||||
|
||||
97
e2e/specs/04-host/low-disk-warning.spec.ts
Normal file
97
e2e/specs/04-host/low-disk-warning.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user