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,86 @@
|
||||
/**
|
||||
* The one byte formatter.
|
||||
*
|
||||
* Before this existed the app had four of them — `projects/home/format.ts`,
|
||||
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
|
||||
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
|
||||
* unit labels and the precision. They are now expressed in terms of this.
|
||||
*
|
||||
* ## Why the default is base 1000
|
||||
*
|
||||
* The Disk panel exists to explain what `docker system df` reports, and Docker
|
||||
* formats every size it prints with `units.HumanSize`, which is **base 1000**.
|
||||
* A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the
|
||||
* same build cache would read as a bug in the panel. So decimal is the default
|
||||
* and binary is opt-in, rather than the other way round.
|
||||
*
|
||||
* Both existing conventions are preserved exactly, so re-pointing the old
|
||||
* call sites changed no rendered string:
|
||||
*
|
||||
* - `{ }` → `41.0 MB` (decimal, what migration used)
|
||||
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
||||
* labels, what Project Home used
|
||||
* — technically a misnomer, but
|
||||
* it is the app's convention and
|
||||
* changing it is not this
|
||||
* feature's business)
|
||||
* - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly)
|
||||
*/
|
||||
|
||||
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
/** Divide by 1024 instead of 1000. */
|
||||
binary?: boolean;
|
||||
/** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */
|
||||
iec?: boolean;
|
||||
/** Decimal places above `B`. Bytes are always whole. */
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string {
|
||||
const { binary = false, iec = false, precision = 1 } = options;
|
||||
|
||||
// A negative or non-finite size is a bug upstream, not something to render as
|
||||
// `NaN GB` in the middle of a table. Docker reports -1 for "not computed",
|
||||
// and that is the case this actually catches.
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "—";
|
||||
|
||||
const step = binary ? 1024 : 1000;
|
||||
const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS;
|
||||
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= step && unit < units.length - 1) {
|
||||
value /= step;
|
||||
unit += 1;
|
||||
}
|
||||
// Whole bytes never get a decimal point: `512 B`, not `512.0 B`.
|
||||
return unit === 0
|
||||
? `${Math.round(bytes)} ${units[0]}`
|
||||
: `${value.toFixed(precision)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than
|
||||
* measured. Used for "next commit adds …", which is the number that explains
|
||||
* why a snapshot grows.
|
||||
*/
|
||||
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
|
||||
const formatted = formatBytes(bytes, options);
|
||||
return formatted === "—" ? formatted : `+${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `up to 12.3 GB` / `nothing` — for a bound rather than a measurement.
|
||||
*
|
||||
* The Disk panel is careful about this distinction: every figure it shows is
|
||||
* measured except a compaction's yield, which cannot be known until it runs.
|
||||
* Rendering that one through a different function is what stops it being read
|
||||
* as a promise.
|
||||
*/
|
||||
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
|
||||
return `up to ${formatBytes(bytes, options)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user