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:
2026-08-23 09:39:54 -07:00
co-authored by Claude Opus 5
parent bb41275cea
commit 77ef2291d7
20 changed files with 6098 additions and 20 deletions
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useDiskUsage } from "./useDiskUsage";
import type { DiskUsageReport } from "../lib/types";
const getDockerDiskUsage = vi.fn();
const listReclaimable = vi.fn();
const reclaim = vi.fn();
const destroyProjectDiskObject = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
getDockerDiskUsage: () => getDockerDiskUsage(),
listReclaimable: (report: DiskUsageReport) => listReclaimable(report),
reclaim: (targets: unknown) => reclaim(targets),
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
destroyProjectDiskObject(target, confirmation),
sweepOrphanedSnapshots: vi.fn(),
}));
const report = (scanned_at: string): DiskUsageReport =>
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
const plan = { items: [], destructive: [], store_error: null };
beforeEach(() => {
vi.clearAllMocks();
listReclaimable.mockResolvedValue(plan);
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
});
describe("useDiskUsage", () => {
it("holds no report until a scan is asked for", () => {
const { result } = renderHook(() => useDiskUsage());
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(getDockerDiskUsage).not.toHaveBeenCalled();
});
it("scans, then plans off the same report rather than scanning again", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
expect(listReclaimable).toHaveBeenCalledWith(report("first"));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.plan).toEqual(plan);
});
it("lets the newest scan win when two are in flight", async () => {
// A user pressing Scan twice can have two `df()` calls outstanding, and
// the second is not necessarily the slower one. A stale response must not
// overwrite a fresher one.
let resolveFirst: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage
.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveFirst = r;
}),
)
.mockResolvedValueOnce(report("second"));
const { result } = renderHook(() => useDiskUsage());
let firstScan: Promise<void> = Promise.resolve();
act(() => {
firstScan = result.current.scan();
});
await act(async () => {
await result.current.scan();
});
expect(result.current.report?.scanned_at).toBe("second");
// The slow first scan lands afterwards and is discarded.
await act(async () => {
resolveFirst(report("first"));
await firstScan;
});
expect(result.current.report?.scanned_at).toBe("second");
expect(result.current.scanning).toBe(false);
});
it("passes the ticked targets straight through", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
expect(reclaim).toHaveBeenCalledWith([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
it("does not call the backend for an empty selection", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([]);
});
expect(reclaim).not.toHaveBeenCalled();
});
it("does not re-scan after a reclaim", async () => {
// Another `df()` costs seconds, and the outcome already carries measured
// bytes for every target. A user who wants fresh totals asks for them.
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
});
it("clears the previous outcome when a new scan starts", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 });
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.outcome?.total_freed_bytes).toBe(42);
await act(async () => {
await result.current.scan();
});
expect(result.current.outcome).toBeNull();
});
it("forwards the typed confirmation verbatim", async () => {
destroyProjectDiskObject.mockResolvedValue({
target: { kind: "dangling_snapshots" },
ok: true,
freed_bytes: 100,
projected_bytes: null,
message: "gone",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp");
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "config_volume", project_id: "p1" },
"whp",
);
expect(result.current.outcome?.total_freed_bytes).toBe(100);
});
it("surfaces a failure rather than leaving a stale report on screen", async () => {
getDockerDiskUsage.mockRejectedValue("daemon unreachable");
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
expect(result.current.scanning).toBe(false);
});
});
+114
View File
@@ -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 };
}