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,114 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import type {
|
||||
DestructiveTarget,
|
||||
DiskUsageReport,
|
||||
ReclaimOutcome,
|
||||
ReclaimPlan,
|
||||
ReclaimTarget,
|
||||
} from "../lib/types";
|
||||
|
||||
/**
|
||||
* State for the Disk section.
|
||||
*
|
||||
* ## Why nothing here runs on mount
|
||||
*
|
||||
* A scan is `GET /system/df`, which walks every image, container and volume on
|
||||
* the daemon and computes shared-layer sizes. On a 100 GB store that is
|
||||
* seconds. `AccordionSection` unmounts its body when collapsed, so a
|
||||
* `useEffect` scan would re-run every single time the user opened the section.
|
||||
* The scan is therefore a `scan()` the Scan button calls and nothing else, and
|
||||
* the result lives in this hook rather than in the component so that reopening
|
||||
* the section shows the last result instead of paying again.
|
||||
*
|
||||
* ## The generation guard
|
||||
*
|
||||
* A user who hits Scan twice can have two `df()` calls in flight, and they can
|
||||
* land out of order — the second one is not necessarily slower. Every async
|
||||
* write checks it is still the newest before it lands, the same pattern
|
||||
* `useContainerMigration` uses.
|
||||
*/
|
||||
export interface DiskUsageState {
|
||||
report: DiskUsageReport | null;
|
||||
plan: ReclaimPlan | null;
|
||||
/** A scan is in flight. */
|
||||
scanning: boolean;
|
||||
/** A reclaim or a destroy is in flight. */
|
||||
working: boolean;
|
||||
error: string | null;
|
||||
/** The outcome of the last reclaim, kept on screen until the next scan. */
|
||||
outcome: ReclaimOutcome | null;
|
||||
scan: () => Promise<void>;
|
||||
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
|
||||
destroy: (target: DestructiveTarget, confirmation: string) => Promise<void>;
|
||||
clearOutcome: () => void;
|
||||
}
|
||||
|
||||
export function useDiskUsage(): DiskUsageState {
|
||||
const [report, setReport] = useState<DiskUsageReport | null>(null);
|
||||
const [plan, setPlan] = useState<ReclaimPlan | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
|
||||
const generation = useRef(0);
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
const mine = ++generation.current;
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
// The previous outcome describes a state that no longer holds once a new
|
||||
// scan starts, so it goes rather than sitting beside fresh numbers.
|
||||
setOutcome(null);
|
||||
try {
|
||||
const next = await commands.getDockerDiskUsage();
|
||||
if (generation.current !== mine) return;
|
||||
setReport(next);
|
||||
// Planning is cheap and always wanted: the classification is what makes
|
||||
// the numbers actionable, and it reuses the report rather than scanning
|
||||
// again.
|
||||
const nextPlan = await commands.listReclaimable(next);
|
||||
if (generation.current !== mine) return;
|
||||
setPlan(nextPlan);
|
||||
} catch (e) {
|
||||
if (generation.current !== mine) return;
|
||||
setError(String(e));
|
||||
} finally {
|
||||
if (generation.current === mine) setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runReclaim = useCallback(async (targets: ReclaimTarget[]) => {
|
||||
if (targets.length === 0) return;
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.reclaim(targets);
|
||||
setOutcome(result);
|
||||
// Deliberately no automatic re-scan. It costs another `df()`, and the
|
||||
// outcome already reports measured bytes for every target — a user who
|
||||
// wants the new totals asks for them.
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => {
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
||||
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearOutcome = useCallback(() => setOutcome(null), []);
|
||||
|
||||
return { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy, clearOutcome };
|
||||
}
|
||||
Reference in New Issue
Block a user