Fix what review found in the Disk section
Safety: - `destroy`'s rollback-pin arm took a tag over IPC and interpolated it straight into an image reference it then removed. `tag: "latest"` named the project's live snapshot, deleted under a dialog saying "rollback pin". It is the one destructive variant carrying a free-form string, so it now goes through `parse_rollback_tag`. - The compaction's scratch container was named `triple-c-scrub-*`, which is what the scrub reclaim bucket hunts and force-removes. A reclaim from a second window would have destroyed the container a running compaction was about to commit. It gets `triple-c-compact-*`, swept at the start of the next compaction rather than from a bucket anything else can fire. - Deleting a home or config volume only refused a *running* container, but a stopped one still pins its volumes — the resting state of every project ever started — so the user typed the project name and met a raw 409. The container is now removed first and `loses` says so. Correctness: - The compaction Dockerfile emitted no `LABEL`, so the flattened intermediate could never match the sweep's `dangling` + `triple-c.managed` filter that three cleanup paths rely on. Verified on Docker 29.7.2 that the label lands on the final stage, the build still yields one layer, and untagging the staging tag after the commit leaves the committed snapshot intact and startable. - `snapshot_commit_layers` silently meant something else when `triple-c.base-image-id` was absent — the normal case for a pre-label project — counting the base's own layers and letting a never-recreated project qualify for compaction. `base_lineage_known` now carries that, the column says "unknown", and the plan does not offer the rewrite. - `destroy` returned a `ReclaimResult` wearing a `ReclaimTarget` that named work it had not done (a home-volume deletion came back as `OrphanVolume`). Split into `target` / `destroyed`, exactly one set. - `formatBytes` ran `toFixed` after the divide loop, so 999,999 rendered as "1000.0 KB" — in the app's only byte formatter, in a panel full of near-boundary sizes. - `is_base_image_reference` split on the first colon, so a registry port ate the repo name. UI: - `snapshot_above_base_bytes: null` — deliberately unmeasurable — rendered as "0 B", the one guessed number in the table. - Layer count was flagged by colour alone; it now says "stacked". - The tick list survived a reclaim, so the same call could be re-fired at objects that no longer existed. The plan is dropped after any action and the panel says the totals predate it. - `setReport` landed before the plan call was awaited, so a plan failure rendered fresh totals above the previous scan's rows. - Both confirmation modals unmounted before awaiting, making the entire busy path dead code during multi-second work. - `buildx du` failures silently showed `docker system df`'s under-reported build-cache figure with no explanation. - Tooltip text reached no assistive tech, so two headers announced as "Help"; hardcoded input id; error-toned glyph in warning-toned panels; `sweepOrphanedSnapshots` and `clearOutcome` had no callers. - Four docstrings claimed things the code did not do, and two tests were named for behaviour they did not assert. Tests: 513 frontend (was 502), 370 Rust (was 365). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -45,6 +45,24 @@ describe("formatBytes", () => {
|
||||
expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB");
|
||||
});
|
||||
|
||||
it("promotes the unit when rounding lands on a whole step", () => {
|
||||
// `toFixed` runs after the divide loop, so a value just under a boundary
|
||||
// rounds up into a unit the loop had already ruled out. This is the app's
|
||||
// only byte formatter and the panel is full of near-boundary sizes.
|
||||
expect(formatBytes(999_999)).toBe("1.0 MB");
|
||||
expect(formatBytes(999_999_999)).toBe("1.0 GB");
|
||||
expect(formatBytes(999_999_999_999)).toBe("1.0 TB");
|
||||
expect(formatBytes(1_048_575, { binary: true })).toBe("1.0 MB");
|
||||
|
||||
// Just below the rounding threshold it must NOT promote.
|
||||
expect(formatBytes(999_949)).toBe("999.9 KB");
|
||||
expect(formatBytes(999_400, { precision: 0 })).toBe("999 KB");
|
||||
|
||||
// The top unit has nowhere to go: it renders a whole step rather than
|
||||
// running off the end of the unit array.
|
||||
expect(formatBytes(999_999_999_999_999_999)).toBe("1000.0 PB");
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* The one byte formatter.
|
||||
*
|
||||
* Before this existed the app had four of them — `projects/home/format.ts`,
|
||||
* 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.
|
||||
* unit labels and the precision. The first two now delegate here.
|
||||
*
|
||||
* The other two deliberately do not, yet: `UpdateDialog` renders KB at
|
||||
* `toFixed(0)`, so re-pointing it would change what a download size reads as,
|
||||
* and neither is on the Disk panel's path. They are the remaining copies.
|
||||
*
|
||||
* ## Why the default is base 1000
|
||||
*
|
||||
@@ -14,8 +18,12 @@
|
||||
* 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:
|
||||
* Both existing conventions are preserved for every size either call site can
|
||||
* realistically produce — a file size or a payload size, i.e. a non-negative
|
||||
* finite number below a terabyte. Outside that range this deliberately differs
|
||||
* from what it replaced: a negative or `NaN` input now renders `—` rather than
|
||||
* `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of
|
||||
* stopping there.
|
||||
*
|
||||
* - `{ }` → `41.0 MB` (decimal, what migration used)
|
||||
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
|
||||
@@ -56,6 +64,17 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st
|
||||
value /= step;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
// **Promote again if rounding pushed the value back up to a whole step.**
|
||||
// `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then
|
||||
// renders as "1000.0 KB" — a unit the loop had already decided against. The
|
||||
// same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575
|
||||
// → "1024.0 KB" in binary).
|
||||
if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) {
|
||||
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]}`
|
||||
@@ -73,7 +92,7 @@ export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): s
|
||||
}
|
||||
|
||||
/**
|
||||
* `up to 12.3 GB` / `nothing` — for a bound rather than a measurement.
|
||||
* `up to 12.3 GB` — 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.
|
||||
|
||||
+12
-2
@@ -836,8 +836,14 @@ export interface ProjectDiskRow {
|
||||
/** Bytes shared with another image — almost always the base. */
|
||||
snapshot_shared_bytes: number;
|
||||
/** Layers stacked above the base image: **one per container recreation**.
|
||||
* This is the number that explains why a snapshot grows. */
|
||||
* This is the number that explains why a snapshot grows — but only when
|
||||
* `base_lineage_known` is true. Otherwise it counts the base's layers too. */
|
||||
snapshot_commit_layers: number;
|
||||
/** Whether the base image this snapshot descends from could be identified.
|
||||
* False is the normal case for a project created before the
|
||||
* `triple-c.base-image-id` label existed; the layer count must not be
|
||||
* presented as a recreation count then. */
|
||||
base_lineage_known: boolean;
|
||||
/** Bytes those layers account for. `null` when the base image is gone and
|
||||
* the split cannot be measured — never a guess. */
|
||||
snapshot_above_base_bytes: number | null;
|
||||
@@ -989,7 +995,11 @@ export interface ReclaimPlan {
|
||||
}
|
||||
|
||||
export interface ReclaimResult {
|
||||
target: ReclaimTarget;
|
||||
/** The reclaim target this reports on, or `null` when it reports a destroy.
|
||||
* Exactly one of `target` / `destroyed` is ever set — a destroy used to come
|
||||
* back wearing a `ReclaimTarget` that named work it had not done. */
|
||||
target: ReclaimTarget | null;
|
||||
destroyed: DestructiveTarget | null;
|
||||
ok: boolean;
|
||||
freed_bytes: number;
|
||||
/** What was projected beforehand, for the one action that projects. */
|
||||
|
||||
Reference in New Issue
Block a user