Compare commits

..

4 Commits

Author SHA1 Message Date
fabi
a4a4e46c53 docs: add a restore procedure, fix the backup cadence, and correct quota_tolerance
Four things, all found by the same question: what does an operator standing at the
venue actually need?

A RESTORE PROCEDURE. There was none anywhere, and a backup you have never restored
isn't a backup. Two hazards worth writing down: media must be extracted preserving
ownership (the app runs as uid 100 / gid 101, and a root-owned restore makes every
upload fail with EACCES surfacing as a generic 500), and the app must be STOPPED
first, because migrations run on boot and a live pool will fight the restore.

Both the backup and the restore commands were run against the real stack before being
written down, which caught two that would have failed:

  - The plain `pg_dump` did not restore: `psql` aborted on `ERROR: schema
    "_sqlx_test" already exists`. pg_dump emits no DROPs without --clean --if-exists,
    so the documented dump could only ever be restored into an empty database. Fixed
    at the source (the dump is now self-cleaning) and verified end to end: 16 tables
    back, exit 0.
  - `--same-owner` does not exist in BusyBox tar, which is what `alpine` ships, so
    the extract aborted before unpacking anything. `--numeric-owner` plus the
    explicit chown, verified to land 100:101.

BACKUP CADENCE. "Weekly offsite" is the wrong shape when every irreplaceable byte is
created in one eight-hour window and nobody can retake a wedding. The backup that
matters runs that night, and again after the release so the keepsake is captured.
Also: take the DB dump and the media tarball back to back, or you get rows pointing
at files the dump doesn't know about.

quota_tolerance WAS DOCUMENTED AS SOMETHING IT ISN'T. .env.example called it "fraction
of disk that triggers the low-storage warning". It is the multiplier in
`floor(free_disk * tolerance / active_uploaders)` -- so an operator who wants "warn me
later" and sets 0.95 is actually authorising guests to fill 95% of the disk, moving
the fixed point from 43% to ~49% and eating the export headroom. The admin UI labelled
it "Toleranz (0-1)" with no explanation at all, which invites exactly that reading;
it is now "Speicher-Anteil für Gäste" with the formula in the hint. Wrong docs on a
tuning knob are worse than no docs.

SIZING. New section with the arithmetic: three volumes on one filesystem, the quota
fixed point at tolerance/(1+tolerance), and the fact the 80 GB baseline does not cover
the keepsake -- both archives are built concurrently and each is roughly a second copy
of every original. Provision ~3x expected media, or give exports its own volume.

Also ticks the low-disk alert off the roadmap, since it now exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:51:48 +02:00
fabi
e6e8a52d87 Merge branch 'feat/low-disk-warning' 2026-07-29 19:48:06 +02:00
fabi
43c2a0d09c 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>
2026-07-29 19:48:06 +02:00
fabi
6818cabf91 Merge branch 'fix/reclaim-deleted-originals' 2026-07-29 19:42:03 +02:00
8 changed files with 406 additions and 8 deletions

View File

@@ -54,8 +54,26 @@ EXPORT_PATH=/exports
# max image size 20 MB # max image size 20 MB
# max video size 500 MB # max video size 500 MB
# estimated guests 100 # estimated guests 100
# quota tolerance 0.75 (fraction of disk that triggers the low-storage warning) # quota tolerance 0.75 (see below — NOT a warning threshold)
# Adjust these in the admin UI before the event if needed. # Adjust these in the admin UI before the event if needed.
#
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
# which anything warns you:
#
# per_user_limit = floor(free_disk * quota_tolerance / active_uploaders)
#
# It is recomputed against LIVE free space on every upload, so it self-throttles: guests
# converge on a fixed point at tolerance/(1+tolerance) of the free space you started
# with — 43% at 0.75, i.e. ~30 GB of a fresh 70 GB.
#
# Raising it therefore AUTHORISES GUESTS TO FILL MORE OF THE DISK. Setting 0.95 in the
# belief that it means "warn me later" moves the fixed point to ~49% and eats the
# headroom the keepsake needs — and the keepsake needs a lot, because Gallery.zip and
# Memories.zip are each roughly a second copy of every original (both store media
# uncompressed). Budget for media + 2x media, or move exports to their own volume.
#
# 0.75 is the tested default. Lower it if the box is tight; raise it only if you have
# provisioned export headroom separately.
# ── Workers ─────────────────────────────────────────────────────────────────── # ── Workers ───────────────────────────────────────────────────────────────────
# Number of parallel image/video compression workers. Default 2. This is the main # Number of parallel image/video compression workers. Default 2. This is the main

125
README.md
View File

@@ -34,7 +34,6 @@ A guest scans the QR code on their way in, types their name, and is immediately
### Planned (v1.x) ### Planned (v1.x)
- Individual file download button - Individual file download button
- Low-disk alert (< 10 GB free)
- Event banner / cover image - Event banner / cover image
- Chunked resumable upload for large videos - Chunked resumable upload for large videos
- Host-curated story highlights - Host-curated story highlights
@@ -231,6 +230,45 @@ so a host takedown or a ban actually revokes access to the bytes.
--- ---
## Sizing the disk
`postgres_data`, `media_data` and `exports_data` are all Docker named volumes under
`/var/lib/docker/volumes`, so **they share one filesystem**. Filling it does not
degrade one subsystem — Postgres stops being able to write and the whole event goes
down.
Uploads are self-limiting. `per_user_limit = free_disk × quota_tolerance ÷
active_uploaders` is recomputed against live free space on every upload, so guests
converge on a fixed point at `tolerance / (1 + tolerance)` of the free space you
started with — **43%** at the default 0.75. On an 80 GB box with ~70 GB free after
the OS and images, media settles at ~30 GB and stops.
**The keepsake is what the 80 GB baseline does not cover.** `Gallery.zip` and
`Memories.zip` are built concurrently and each is roughly a second copy of every
original: both write their media `Compression::Stored`, and `Memories.zip` streams the
untouched original for every video and for every image at or under 5 MB. So a release
wants room for **two more copies of the gallery** on top of the gallery itself.
| Stage | Used | Free (80 GB box) |
|---|---|---|
| Fresh box (OS + images) | ~10 GB | ~70 GB |
| Guests reach the quota fixed point | ~40 GB | ~40 GB |
| Host releases → both archives | ~100 GB | **ENOSPC** |
Two ways to size for it:
- **Provision ~3× your expected media** on one volume (media + two archives), or
- **give `exports_data` its own volume** so a full export cannot reach Postgres, and
size that one at ~2× expected media.
This is no longer silent. The export refuses up front with the two numbers rather than
hitting ENOSPC halfway through a multi-GB write, a rebuild reclaims the superseded
generation before it starts (so peak is one generation, not two), and the host
dashboard warns as soon as the keepsake would not fit — which is the only point at
which anyone can still do something about it.
---
## Backup ## Backup
There are **three** things to back up, and they live in three different places. There are **three** things to back up, and they live in three different places.
@@ -242,9 +280,12 @@ never exported into an operator's shell — so every command below runs through
```bash ```bash
# 1. Database snapshot. Runs pg_dump inside the db container (the app image has no # 1. Database snapshot. Runs pg_dump inside the db container (the app image has no
# postgres client), reading credentials from the compose environment. # postgres client), reading credentials from the compose environment.
# --clean --if-exists makes the dump SELF-CLEANING: without it the restore below
# aborts on the first "already exists" against a database that has ever booted,
# which is every database you would actually want to restore over.
mkdir -p ./backups mkdir -p ./backups
docker compose exec -T db \ docker compose exec -T db \
sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \ sh -c 'pg_dump --clean --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB"' \
| gzip > ./backups/db_$(date +%Y-%m-%d).sql.gz | gzip > ./backups/db_$(date +%Y-%m-%d).sql.gz
# 2. Uploaded media (originals + derivatives) out of the named volume. # 2. Uploaded media (originals + derivatives) out of the named volume.
@@ -261,7 +302,7 @@ docker run --rm \
-v eventsnap_exports_data:/src:ro -v "$PWD/backups":/backup \ -v eventsnap_exports_data:/src:ro -v "$PWD/backups":/backup \
alpine tar czf /backup/exports_$(date +%Y-%m-%d).tar.gz -C /src . alpine tar czf /backup/exports_$(date +%Y-%m-%d).tar.gz -C /src .
# Weekly offsite sync of the three artefacts above. # Offsite sync of the three artefacts above.
rsync -az ./backups/ user@storagebox.example.com:backup/eventsnap/ rsync -az ./backups/ user@storagebox.example.com:backup/eventsnap/
``` ```
@@ -274,6 +315,82 @@ from a directory called `eventsnap`. Confirm yours with `docker volume ls`.
> stops it being reachable except through the ticket-gated download handler. > stops it being reachable except through the ticket-gated download handler.
> Backing up only the media volume therefore loses every generated keepsake. > Backing up only the media volume therefore loses every generated keepsake.
### When to run it
**A nightly cron is the wrong shape for this app.** Every irreplaceable byte is
created inside one eight-hour window, and nobody can retake a wedding. Run the three
commands above:
1. **The night of the event**, once uploads have stopped. This is the backup that
matters; everything else is a formality.
2. **After the host releases the gallery**, so the generated keepsake is captured too.
3. Weekly thereafter, until the event is archived and torn down.
Take the DB dump and the media tarball **back to back**, without uploads in flight
between them. Upload rows reference files by path — a database from 22:00 and a media
volume from 23:00 gives you rows pointing at files the dump doesn't know about, and
rows whose files aren't in the tarball. Locking uploads from the host dashboard first
(**Uploads sperren**) makes the pair genuinely consistent.
---
## Restore
An untested backup is not a backup. Run this once against a scratch host **before**
the event — it is roughly ten minutes, and it is the only way to find out that your
tarball is empty or your dump is truncated while that is still a small problem.
```bash
# 0. Stop the app FIRST. Migrations run on boot and a live pool will fight the
# restore — a booting app against a half-restored schema can leave the migration
# table and the schema disagreeing, which is its own recovery problem.
# Leave `db` running: the dump is restored through it.
docker compose stop app caddy
# 1. Database. The dump carries its own DROPs (step 1 of Backup), so this replaces
# rather than collides. A dump taken WITHOUT --clean --if-exists will abort here
# on the first "already exists" — restore that one into a fresh empty database
# instead.
gunzip -c ./backups/db_2026-07-29.sql.gz \
| docker compose exec -T db \
sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" --set ON_ERROR_STOP=1'
# 2. Media. NOTE the `--numeric-owner` and the chown: the app runs as a
# NON-ROOT user (uid 100, gid 101 — `addgroup -S app && adduser -S app`), and a
# restore that lands root-owned files makes every upload fail with EACCES deep in
# the write path, surfacing to the guest as a generic 500 with nothing in the UI
# to suggest permissions. The explicit chown is what guarantees it — BusyBox tar
# (which is what `alpine` ships) has no --same-owner, and restores ownership only
# because it runs as root here.
docker run --rm \
-v eventsnap_media_data:/dst -v "$PWD/backups":/backup:ro \
alpine sh -c 'tar xzf /backup/media_2026-07-29.tar.gz -C /dst \
--numeric-owner && chown -R 100:101 /dst'
# 3. Exports. Same volume-name caveat, same ownership rules.
docker run --rm \
-v eventsnap_exports_data:/dst -v "$PWD/backups":/backup:ro \
alpine sh -c 'tar xzf /backup/exports_2026-07-29.tar.gz -C /dst \
--numeric-owner && chown -R 100:101 /dst'
# 4. Back up. Migrations run, then export recovery re-arms any keepsake whose file
# didn't come back with the volume.
docker compose up -d app caddy
docker compose logs -f app # watch for "migrations applied"
# 5. Verify — all three, not just the first.
curl -fsS https://DOMAIN/health && echo # → ok
# … then sign in as host and confirm the feed renders images (proves the media
# volume restored AND is readable by uid 100), and that the keepsake downloads.
```
If the media volume restored but images 404 while the feed lists them, the paths are
there and the bytes aren't — check `docker compose exec app ls -ln /media/originals`
and confirm both the files and the `100:101` ownership.
The restore is deliberately **not** automated. It is rare, destructive, and the one
operation where a script that half-works is worse than a checklist someone reads.
--- ---
## Running the backend test suite ## Running the backend test suite
@@ -348,7 +465,7 @@ Open:
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired) - [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md) - [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
- [ ] Individual file download button per post - [ ] Individual file download button per post
- [ ] Low-disk alert (< 10 GB free) - [x] Low-disk alert — host dashboard warns below 10 GB free, or whenever the keepsake would not fit
- [ ] Event banner / cover image - [ ] Event banner / cover image
- [ ] Chunked resumable upload for files > 100 MB - [ ] Chunked resumable upload for files > 100 MB
- [ ] Shared Tailwind config between main app and export-viewer - [ ] Shared Tailwind config between main app and export-viewer

View File

@@ -35,6 +35,32 @@ pub struct EventStatus {
pub is_active: bool, pub is_active: bool,
pub uploads_locked: bool, pub uploads_locked: bool,
pub export_released: 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<u64>,
/// 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 /// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
@@ -72,11 +98,29 @@ pub async fn get_event_status(
.await? .await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; .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 { Ok(Json(EventStatus {
name: event.name, name: event.name,
is_active: event.is_active, is_active: event.is_active,
uploads_locked: event.uploads_locked_at.is_some(), uploads_locked: event.uploads_locked_at.is_some(),
export_released: event.export_released_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) 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));
}
}

View File

@@ -1268,7 +1268,7 @@ fn is_superseded_archive(
/// want, since being wrong low means ENOSPC halfway through. /// want, since being wrong low means ENOSPC halfway through.
/// ///
/// Matches [`query_uploads`]' visibility filter exactly, so hidden/banned uploads aren't counted. /// Matches [`query_uploads`]' visibility filter exactly, so hidden/banned uploads aren't counted.
async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> Result<u64> { pub async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> Result<u64> {
let (bytes,): (i64,) = sqlx::query_as( let (bytes,): (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint "SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint
FROM upload u 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 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<u64> {
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. /// 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 /// Without this the failure mode is ENOSPC halfway through a multi-GB write, and the wreckage

View File

@@ -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) { async setExportReleased(slug: string, released: boolean) {
await withClient((c) => await withClient((c) =>
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [ c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [

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

View File

@@ -105,7 +105,20 @@
kind: 'bool', kind: 'bool',
hint: 'Reserviert für künftige Anzahl-Limits.' hint: 'Reserviert für künftige Anzahl-Limits.'
}, },
{ key: 'quota_tolerance', label: 'Toleranz (01)', kind: 'number' }, {
key: 'quota_tolerance',
label: 'Speicher-Anteil für Gäste (01)',
kind: 'number',
// "Toleranz (01)" with no hint invited exactly the wrong reading — that a higher
// number means "warn me later". It is the multiplier in
// `floor(freier Speicher × Anteil / aktive Uploader)`, so raising it authorises
// guests to fill MORE of the disk, not less.
hint:
'Anteil des freien Speichers, den alle Gäste zusammen belegen dürfen: ' +
'Limit = freier Speicher × Anteil ÷ aktive Uploader. Kein Warnschwellenwert — ' +
'ein höherer Wert gibt MEHR Speicher frei. Das Keepsake braucht zusätzlich ' +
'etwa das Doppelte der Mediengröße; 0,75 ist der getestete Standard.'
},
{ key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' } { key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' }
] ]
}, },

View File

@@ -28,6 +28,9 @@
is_active: boolean; is_active: boolean;
uploads_locked: boolean; uploads_locked: boolean;
export_released: boolean; export_released: boolean;
disk_free_bytes: number | null;
keepsake_required_bytes: number;
disk_low: boolean;
} }
interface PinResetRequest { interface PinResetRequest {
@@ -395,7 +398,11 @@
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 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`;
} }
</script> </script>
@@ -507,6 +514,38 @@
{error} {error}
</div> </div>
{:else if event} {:else if event}
<!-- ── Speicherwarnung ─────────────────────────────────────────────
Above everything else on purpose. All three volumes (postgres_data, media_data,
exports_data) sit on one filesystem, so running out doesn't degrade a subsystem —
it stops Postgres writing and takes the event down. And the keepsake needs room
for TWO gallery-sized archives, which is only actionable BEFORE the release: the
export preflight can say "this didn't fit", but by then the event is over and the
remedies are all much harder.
Only the admin dashboard had any storage visibility at all, and a host is often
not the admin. `disk_low` fails closed to "not low" on an unreadable mount, so
this cannot cry wolf. -->
{#if event.disk_low && event.disk_free_bytes !== null}
<div
class="rounded-xl border border-red-300 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30"
data-testid="low-disk-warning"
>
<h2 class="font-semibold text-red-900 dark:text-red-200">Speicherplatz wird knapp</h2>
<p class="mt-1 text-sm text-red-800 dark:text-red-300">
Noch <strong>{formatBytes(event.disk_free_bytes)}</strong> frei.
{#if event.keepsake_required_bytes > event.disk_free_bytes}
Für das Keepsake werden derzeit ca.
<strong>{formatBytes(event.keepsake_required_bytes)}</strong> benötigt — es kann
momentan <strong>nicht</strong> erstellt werden.
{/if}
</p>
<p class="mt-1.5 text-xs text-red-700 dark:text-red-400">
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.
</p>
</div>
{/if}
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── --> <!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
{#if pinResetRequests.length > 0} {#if pinResetRequests.length > 0}
<div <div