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:
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("defaults to base 1000, because that is what Docker prints", () => {
|
||||
// The Disk panel exists to explain `docker system df`, which formats with
|
||||
// `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying
|
||||
// 28.0 GB for the same build cache reads as a bug in the panel.
|
||||
expect(formatBytes(28_000_000_000)).toBe("28.0 GB");
|
||||
expect(formatBytes(1_000)).toBe("1.0 KB");
|
||||
expect(formatBytes(1_500_000)).toBe("1.5 MB");
|
||||
expect(formatBytes(12_273_392_374)).toBe("12.3 GB");
|
||||
});
|
||||
|
||||
it("leaves whole bytes without a decimal point", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(999)).toBe("999 B");
|
||||
});
|
||||
|
||||
it("reproduces the Project Home convention exactly under `binary`", () => {
|
||||
// Three modules import `projects/home/format.ts#formatBytes`, which is now
|
||||
// this function. Its output had to be byte-identical or re-pointing it
|
||||
// would have quietly changed every file listing in the app.
|
||||
expect(formatBytes(1023, { binary: true })).toBe("1023 B");
|
||||
expect(formatBytes(1024, { binary: true })).toBe("1.0 KB");
|
||||
expect(formatBytes(1024 * 1024, { binary: true })).toBe("1.0 MB");
|
||||
expect(formatBytes(1024 * 1024 * 1024, { binary: true })).toBe("1.0 GB");
|
||||
expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB");
|
||||
});
|
||||
|
||||
it("reproduces the migration convention exactly by default", () => {
|
||||
// `migrationCopy.formatDataSize` is now a call to this, and its output is
|
||||
// asserted in MigrateContainerModal.test.tsx.
|
||||
expect(formatBytes(41_000_000)).toBe("41.0 MB");
|
||||
expect(formatBytes(3_800_000_000)).toBe("3.8 GB");
|
||||
});
|
||||
|
||||
it("labels binary units honestly when asked to", () => {
|
||||
expect(formatBytes(1024, { binary: true, iec: true })).toBe("1.0 KiB");
|
||||
expect(formatBytes(1024 ** 3, { binary: true, iec: true })).toBe("1.0 GiB");
|
||||
});
|
||||
|
||||
it("climbs to TB rather than showing five-digit gigabytes", () => {
|
||||
expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB");
|
||||
});
|
||||
|
||||
it("renders an em dash for a size the daemon did not compute", () => {
|
||||
// Docker reports -1 for "not calculated" on shared sizes and volume ref
|
||||
// counts. `NaN GB` in the middle of a table is worse than nothing.
|
||||
expect(formatBytes(-1)).toBe("—");
|
||||
expect(formatBytes(NaN)).toBe("—");
|
||||
expect(formatBytes(Infinity)).toBe("—");
|
||||
});
|
||||
|
||||
it("honours a requested precision", () => {
|
||||
expect(formatBytes(1_234_567_890, { precision: 2 })).toBe("1.23 GB");
|
||||
expect(formatBytes(1_234_567_890, { precision: 0 })).toBe("1 GB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesDelta", () => {
|
||||
it("signs a figure that is being added rather than measured", () => {
|
||||
// "Next commit adds +868.0 MB" — the sign is what makes it read as a cost
|
||||
// about to be incurred rather than a size already on disk.
|
||||
expect(formatBytesDelta(868_000_000)).toBe("+868.0 MB");
|
||||
expect(formatBytesDelta(0)).toBe("+0 B");
|
||||
});
|
||||
|
||||
it("does not sign an unknown", () => {
|
||||
expect(formatBytesDelta(-1)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesCeiling", () => {
|
||||
it("says 'up to', because a compaction's yield is a bound not a promise", () => {
|
||||
// Every other figure in the Disk panel is measured. This one cannot be
|
||||
// known until the rewrite runs, and rendering it through a separate
|
||||
// function is what stops it being read as a guarantee.
|
||||
expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB");
|
||||
});
|
||||
|
||||
it("refuses to imply a saving when there is no bound to give", () => {
|
||||
expect(formatBytesCeiling(0)).toBe("an unknown amount");
|
||||
expect(formatBytesCeiling(-1)).toBe("an unknown amount");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user