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
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import TypedConfirmModal from "./TypedConfirmModal";
const onConfirm = vi.fn();
const onCancel = vi.fn();
function renderModal(props: Partial<React.ComponentProps<typeof TypedConfirmModal>> = {}) {
render(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
{...props}
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
return {
input: screen.getByLabelText(/Type/),
confirm: screen.getByRole("button", { name: "Delete config volume" }),
};
}
beforeEach(() => vi.clearAllMocks());
describe("TypedConfirmModal", () => {
it("is a real dialog, from the Modal primitive", () => {
renderModal();
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
});
it("keeps the confirm button shut until the name is typed exactly", () => {
const { input, confirm } = renderModal();
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "wh" } });
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "whp" } });
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
expect(onConfirm).toHaveBeenCalledWith("whp");
});
it("is case-sensitive, because Api and api are different projects", () => {
// This gate is the only thing between a misclick on a sorted table of
// numbers and a project's transcripts, so a near-miss is a miss.
const { input, confirm } = renderModal({ expected: "Api" });
fireEvent.change(input, { target: { value: "api" } });
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "Api" } });
expect(confirm).toBeEnabled();
});
it("forgives surrounding whitespace from a paste", () => {
const { input, confirm } = renderModal();
fireEvent.change(input, { target: { value: " whp " } });
expect(confirm).toBeEnabled();
});
it("announces the gate's state in words rather than only by the button fill", () => {
const { input } = renderModal();
expect(screen.getByRole("status")).toHaveTextContent(
"Waiting for the exact project name.",
);
fireEvent.change(input, { target: { value: "whp" } });
expect(screen.getByRole("status")).toHaveTextContent("Name matches.");
});
it("spells out what is lost, from the caller's copy", () => {
renderModal();
expect(screen.getByText("Everything goes.")).toBeInTheDocument();
});
it("locks itself while the deletion is running", () => {
render(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
busy
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
// The confirm button reports the work in a word rather than only going
// grey, so it is found by its busy label, not its idle one.
expect(screen.getByLabelText(/Type/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
});
it("cancels without confirming", () => {
renderModal();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
it("cannot be satisfied by an empty box when there is no name to type", () => {
const { confirm } = renderModal({ expected: "" });
expect(confirm).toBeDisabled();
});
});
+113
View File
@@ -0,0 +1,113 @@
import { useRef, useState, type ReactNode } from "react";
import Modal from "./Modal";
import Button from "./Button";
import { inputClass } from "./Field";
interface Props {
title: string;
/** What must be typed, verbatim, before the confirm button enables. */
expected: string;
/** The verb on the confirm button. Repeat the action — never "OK". */
confirmLabel: string;
/** What is about to be lost, in full. */
children: ReactNode;
onConfirm: (typed: string) => void;
onCancel: () => void;
busy?: boolean;
}
/**
* The confirmation gate for something that has no other copy.
*
* ## Why this exists when `ConfirmResetModal` already did
*
* Reset and Remove are reached from a project's own overflow menu, one project
* at a time, by a user who went looking for them. The Disk panel lists every
* project's volumes side by side in a table of numbers, sorted by size — which
* is exactly the layout that invites a misclick on the wrong row. A two-button
* dialog does not survive that, because the thing being confirmed (*which*
* project) is the thing the user got wrong.
*
* Typing the name fixes the failure mode rather than adding friction to it: the
* gate is not "are you sure", it is "name the project you mean".
*
* The comparison is `expected.trim() === typed.trim()` and **case-sensitive** —
* mirroring `confirmation_matches` in `docker/disk.rs`, which is the check that
* actually holds, since this one is only a UI affordance. The backend refuses a
* mismatch on its own.
*/
export default function TypedConfirmModal({
title,
expected,
confirmLabel,
children,
onConfirm,
onCancel,
busy = false,
}: Props) {
const [typed, setTyped] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const matches = expected.trim().length > 0 && typed.trim() === expected.trim();
return (
<Modal
title={title}
onClose={onCancel}
widthClassName="w-[30rem]"
initialFocusRef={inputRef}
dismissible={!busy}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button
size="md"
onClick={() => onConfirm(typed)}
disabled={!matches || busy}
className={
matches && !busy
? "bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
: "bg-[var(--bg-tertiary)] text-[var(--text-disabled)] border border-[var(--border-color)]"
}
>
{busy ? "Working…" : confirmLabel}
</Button>
</>
}
>
<div className="space-y-3 text-[13px] text-[var(--text-secondary)]">
{children}
<div>
<label
htmlFor="typed-confirm-input"
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
>
Type <strong className="font-mono">{expected}</strong> to confirm
</label>
<input
id="typed-confirm-input"
ref={inputRef}
value={typed}
onChange={(e) => setTyped(e.target.value)}
disabled={busy}
autoComplete="off"
spellCheck={false}
className={`${inputClass} font-mono`}
/>
{/* Announced rather than only coloured — the gate's state has to be
readable without relying on the button's fill. */}
<p role="status" aria-live="polite" className="mt-1.5 text-xs">
{matches ? (
<span className="text-[var(--text-secondary)]">Name matches.</span>
) : (
<span className="text-[var(--text-disabled)]">
Waiting for the exact project name.
</span>
)}
</p>
</div>
</div>
</Modal>
);
}