Add a Disk section: see where the bytes went, and get them back
Every recreation runs `docker commit`, which stacks a layer and never rewrites one, and 24 conditions in `container_needs_recreation` trigger a recreation. Prevention landed earlier on this branch; this is the half a user can act on. The per-project table leads with the two numbers that explain the mechanism rather than just the total: how many commit layers a snapshot has stacked above its base, and what the container's writable layer will add at the next commit. Backend (`docker/disk.rs`, commands in `docker_commands.rs`): - `get_docker_disk_usage` — one `df()` joined against the project store, behind an explicit Scan button because it walks the whole daemon. - `list_reclaimable` / `reclaim` — classified buckets with measured bytes, planned off the existing report so re-planning costs no second scan. - `destroy_project_disk_object` — one object, typed confirmation. - `sweep_orphaned_snapshots` — exposed, so its report is finally visible. Safety is structural: `reclaim` takes `ReclaimTarget`, which has no variant that can name a live project's data. Destructive work is a separate type reached only through `destroy`. No unfiltered prune is called anywhere, and nothing outside a `triple-c*` name or `triple-c.*` label is touched. Orphan detection subtracts ids from the project store and consults nothing else. From the daemon's side an idle live project and a deleted one are indistinguishable — volumes present, no container, no image — so inferring from container or image absence would offer a live project's credentials and transcripts for deletion. A store that loaded empty from an existing `projects.json` is treated as a failed load, not as "no projects", because `ProjectsStore::new()` recovers from a corrupt file by starting empty. Three things verified against a live Docker 29.7.2 rather than assumed: - Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which keeps every byte inside the daemon; bollard's import buffers a whole image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer synthetic came out 45.7 MB/1 layer. Image config does not survive, so it is replayed via create+commit, which round-trips a multi-line env var that a Dockerfile `ENV` could not. - Flattening breaks base-layer sharing, so the result carries its own copy of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a 4.72 GB shared base — compacting those costs ~4 GB. The bound now subtracts that penalty, such projects are not offered at all, and the run compares unique bytes and abandons a rewrite that would grow. - `docker builder prune` reports `Total:`, not `Total reclaimed space:`, so the first parser scored every prune as freeing nothing. The Windows/WSL2 note is mandatory and its copy lives in Rust beside the tests that pin it: pruning frees space inside `ext4.vhdx`, which never shrinks on its own, so C: does not change until the disk is compacted. Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and `projects/home/format.ts` and `migrationCopy.ts` now delegate to it with byte-identical output. Base 1000 by default, matching what Docker prints. Tests: 502 frontend (was 453), 365 Rust (was 322). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -54,3 +54,80 @@ pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String>
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disk
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The disk view's IPC surface. It lives here rather than in a module of its own
|
||||
// for the same reason `check_image_exists` does: these are thin shims over
|
||||
// `crate::docker`, and the logic they call is in `docker/disk.rs` where it can
|
||||
// be unit-tested without a daemon.
|
||||
|
||||
/// Measure where the daemon's bytes have gone.
|
||||
///
|
||||
/// **Expensive on purpose.** This is `GET /system/df` plus an `image_history`
|
||||
/// per distinct image, and `df()` walks every image, container and volume on
|
||||
/// the daemon to compute shared-layer sizes. On a 100 GB store that is seconds.
|
||||
/// The frontend must keep it behind an explicit Scan button — never on panel
|
||||
/// open, never on a timer.
|
||||
#[tauri::command]
|
||||
pub async fn get_docker_disk_usage(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::DiskUsageReport, String> {
|
||||
let projects = state.projects_store.list();
|
||||
docker::disk::scan(&projects).await
|
||||
}
|
||||
|
||||
/// Everything that could be reclaimed, each with its measured cost.
|
||||
///
|
||||
/// Takes the report from [`get_docker_disk_usage`] rather than re-measuring, so
|
||||
/// a user who re-plans after ticking a box does not pay for a second `df()`.
|
||||
#[tauri::command]
|
||||
pub async fn list_reclaimable(
|
||||
report: docker::disk::DiskUsageReport,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::ReclaimPlan, String> {
|
||||
let projects = state.projects_store.list();
|
||||
docker::disk::list_reclaimable(&projects, &report).await
|
||||
}
|
||||
|
||||
/// Run the ticked targets and report what each one actually freed.
|
||||
///
|
||||
/// `ReclaimTarget` cannot express a destructive action — that is a different
|
||||
/// type, reached only through [`destroy_project_disk_object`] with a typed
|
||||
/// confirmation — so there is no selection a user can build here that deletes a
|
||||
/// live project's data.
|
||||
#[tauri::command]
|
||||
pub async fn reclaim(
|
||||
targets: Vec<docker::disk::ReclaimTarget>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::ReclaimOutcome, String> {
|
||||
let projects = state.projects_store.list();
|
||||
Ok(docker::disk::reclaim(&targets, &projects).await)
|
||||
}
|
||||
|
||||
/// Delete one object that has no other copy, against a typed confirmation of
|
||||
/// the project's name.
|
||||
///
|
||||
/// Deliberately one target per call: this is never part of a bulk action.
|
||||
#[tauri::command]
|
||||
pub async fn destroy_project_disk_object(
|
||||
target: docker::disk::DestructiveTarget,
|
||||
confirmation: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<docker::disk::ReclaimResult, String> {
|
||||
let projects = state.projects_store.list();
|
||||
docker::disk::destroy(&target, &confirmation, &projects).await
|
||||
}
|
||||
|
||||
/// Run the orphaned-snapshot sweep on demand and return its report.
|
||||
///
|
||||
/// The sweep already runs at startup, after every recreation and after a
|
||||
/// migration settles, but every one of those callers throws the report away —
|
||||
/// so a user has never been able to see that 11.9 GB of superseded images were
|
||||
/// found and left because a stopped container still pinned them.
|
||||
#[tauri::command]
|
||||
pub async fn sweep_orphaned_snapshots() -> Result<docker::SnapshotSweepReport, String> {
|
||||
Ok(docker::sweep_orphaned_snapshots().await)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user