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,168 @@
import OverflowMenu from "../ui/OverflowMenu";
import Tooltip from "../ui/Tooltip";
import StatusIndicator from "../ui/StatusIndicator";
import { formatBytes, formatBytesDelta } from "../../lib/formatBytes";
import type { DestructiveItem, ProjectDiskRow } from "../../lib/types";
interface Props {
rows: ProjectDiskRow[];
/** Per-project destructive objects, keyed off the same rows. */
destructive: DestructiveItem[];
onDestroy: (item: DestructiveItem) => void;
}
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
function cell(bytes: number, present: boolean) {
return present ? formatBytes(bytes) : "—";
}
/**
* The per-project table — the mental model users actually have of this app.
*
* ## Why "Layers" is a column and not a detail
*
* A total tells a user their disk is full. The layer count tells them *why*:
* every container recreation runs `docker commit`, a commit stacks a layer and
* never rewrites one, and 24 different settings changes trigger a recreation.
* A project sitting at 14 layers has paid for fourteen full copies of whatever
* changed, and no total on its own ever says that.
*
* "Next commit adds" is the same fact from the other end: it is the container's
* writable layer, i.e. exactly what the *next* recreation will bake in
* permanently. Seeing 868 MB there is what makes Compact worth doing before the
* next settings change rather than after it.
*/
export default function DiskProjectTable({ rows, destructive, onDestroy }: Props) {
if (rows.length === 0) {
return (
<p className="text-xs text-[var(--text-secondary)]">
No projects to account for.
</p>
);
}
return (
// Wide content scrolls inside its own container; the panel itself must
// never scroll sideways.
<div className="overflow-x-auto">
<table className="w-full text-[13px] border-collapse">
<caption className="sr-only">
Disk used by each project, largest first
</caption>
<thead>
<tr className="text-left text-xs text-[var(--text-secondary)]">
<th scope="col" className="font-medium py-1.5 pr-3">
Project
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Snapshot
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Layers
<Tooltip text="Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted." />
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Next commit adds
<Tooltip text="The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that." />
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Home vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Config vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Total
</th>
<th scope="col" className="font-medium py-1.5 pl-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const mine = destructive.filter((d) => d.project_id === row.project_id);
return (
<tr
key={row.project_id}
className="border-t border-[var(--border-color)] align-top"
data-testid={`disk-row-${row.project_id}`}
>
<th scope="row" className="font-normal py-1.5 pr-3 text-[var(--text-primary)]">
<div className="flex items-center gap-1.5">
<span className="truncate max-w-[10rem]">{row.project_name}</span>
{row.migrating && (
<StatusIndicator
tone="busy"
label="Migrating"
className="text-[11px]"
/>
)}
</div>
<span className="block text-[11px] text-[var(--text-secondary)] font-mono truncate max-w-[12rem]">
{row.project_id}
</span>
</th>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.snapshot_above_base_bytes ?? 0, row.snapshot_exists)}
{row.snapshot_exists && (
<span className="block text-[11px] text-[var(--text-secondary)]">
{/* The base is shared by every project, so charging it to
each row would show the same 4.7 GB eight times. The
headline figure is what is unique to this project;
the total is here for anyone reconciling against
`docker images`. */}
{formatBytes(row.snapshot_bytes)} with base
</span>
)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums">
{row.snapshot_exists ? (
<span
className={
row.snapshot_commit_layers > 5
? "text-[var(--warning)]"
: "text-[var(--text-primary)]"
}
>
{row.snapshot_commit_layers}
</span>
) : (
"—"
)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{row.container_exists
? formatBytesDelta(row.container_writable_bytes)
: "—"}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.home_volume_bytes, row.home_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.config_volume_bytes, row.config_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap text-[var(--text-primary)] font-medium">
{formatBytes(row.total_bytes)}
</td>
<td className="py-1.5 pl-3">
{mine.length > 0 && (
<OverflowMenu
label={`Delete ${row.project_name} data`}
items={mine.map((item) => ({
label: `Delete ${item.label.toLowerCase()} (${formatBytes(item.bytes)})…`,
onSelect: () => onDestroy(item),
danger: true,
disabled: item.blocked !== null,
}))}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,510 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
import DiskSettings from "./DiskSettings";
import type {
DiskUsageReport,
ProjectDiskRow,
ReclaimItem,
ReclaimPlan,
ReclaimTarget,
} 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: ReclaimTarget[]) => reclaim(targets),
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
destroyProjectDiskObject(target, confirmation),
sweepOrphanedSnapshots: vi.fn(async () => ({})),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
project_id: "p-whp",
project_name: "whp",
snapshot_image: "triple-c-snapshot-p-whp:latest",
snapshot_exists: true,
snapshot_bytes: 12_273_392_374,
snapshot_shared_bytes: 3_832_425_659,
snapshot_commit_layers: 14,
snapshot_above_base_bytes: 8_440_966_715,
container_exists: true,
container_running: false,
container_writable_bytes: 868_000_000,
home_volume_bytes: 4_860_000_000,
home_volume_present: true,
config_volume_bytes: 427_000_000,
config_volume_present: true,
total_bytes: 14_595_966_715,
migrating: false,
...over,
});
const report = (over: Partial<DiskUsageReport> = {}): DiskUsageReport => ({
scanned_at: "2026-08-23T10:00:00Z",
projects: [row()],
base_images: [
{
reference: "ghcr.io/shadowdao/triple-c-sandbox:latest",
bytes: 4_724_062_366,
shared_bytes: 4_723_860_396,
containers: 2,
is_labelled_base: true,
},
],
base_images_bytes: 4_724_062_366,
orphan_image_bytes: 11_900_000_000,
orphan_image_count: 3,
orphan_volumes: [],
orphan_volume_bytes: 0,
orphan_volumes_unavailable: null,
build_cache: {
total_bytes: 28_000_000_000,
reclaimable_bytes: 28_000_000_000,
stale_bytes: 20_000_000_000,
source: "buildx du",
cli_error: null,
},
images_total_bytes: 104_500_000_000,
containers_total_bytes: 7_497_000_000,
volumes_total_bytes: 72_890_000_000,
triple_c_total_bytes: 116_000_000_000,
host: {
docker_root_dir: "/var/lib/docker",
operating_system: "Docker Desktop",
is_docker_desktop: true,
is_windows_host: false,
vhdx_applies: false,
vhdx_note: "",
vhdx_fix: [],
vhdx_fix_gui: "",
},
...over,
});
const item = (over: Partial<ReclaimItem> = {}): ReclaimItem => ({
target: { kind: "dangling_snapshots" },
safety: "safe",
daemon_wide: false,
label: "Superseded snapshot layers (3 images)",
detail: "Untagged images left behind by past container recreations.",
bytes: 11_900_000_000,
bytes_are_exact: true,
bytes_floor: null,
blocked: null,
...over,
});
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
items: [item()],
destructive: [],
store_error: null,
...over,
});
async function renderAndScan() {
render(<DiskSettings />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
}
beforeEach(() => {
vi.clearAllMocks();
getDockerDiskUsage.mockResolvedValue(report());
listReclaimable.mockResolvedValue(plan());
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
});
// ---------------------------------------------------------------------------
describe("DiskSettings", () => {
it("never scans until the button is pressed", async () => {
// `df()` walks the whole daemon and takes seconds on a large store, and
// AccordionSection unmounts its body when collapsed — so a scan on mount
// would re-run every time the section was opened.
render(<DiskSettings />);
await act(async () => {
await Promise.resolve();
});
expect(getDockerDiskUsage).not.toHaveBeenCalled();
expect(screen.getByText(/never done for you/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
});
it("says it is scanning in words, not only in colour", async () => {
let resolve: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValue(
new Promise<DiskUsageReport>((r) => {
resolve = r;
}),
);
render(<DiskSettings />);
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
expect(screen.getByText("Scanning")).toBeInTheDocument();
await act(async () => {
resolve(report());
});
await waitFor(() => expect(screen.getByText(/^Scanned /)).toBeInTheDocument());
});
it("shows the layer count and the cost of the next commit", async () => {
// The two numbers that explain the growth mechanism. A total alone never
// says why the disk filled up.
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("14")).toBeInTheDocument();
expect(within(projectRow).getByText("+868.0 MB")).toBeInTheDocument();
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
});
it("charges the shared base to the globals, not to every project row", async () => {
// The base is one 4.7 GB image every project descends from. Counting it per
// row would show it eight times and make the column meaningless.
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument();
expect(within(projectRow).getByText(/12\.3 GB with base/)).toBeInTheDocument();
});
it("plans from the report it already has rather than scanning twice", async () => {
await renderAndScan();
await waitFor(() => expect(listReclaimable).toHaveBeenCalledTimes(1));
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
expect(listReclaimable).toHaveBeenCalledWith(expect.objectContaining({ projects: expect.any(Array) }));
});
// -------------------------------------------------------------------------
// Selection plumbing
// -------------------------------------------------------------------------
it("sends exactly the ticked targets and nothing else", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item(),
item({
target: { kind: "migration_staging" },
label: "Migration staging files",
bytes: 500_000_000,
}),
],
}),
);
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
const boxes = screen.getAllByRole("checkbox");
await act(async () => {
fireEvent.click(boxes[1]);
});
expect(screen.getByText(/1 selected, 500\.0 MB/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]);
});
it("cannot reclaim with nothing ticked", async () => {
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
expect(screen.getByRole("button", { name: "Reclaim" })).toBeDisabled();
expect(screen.getByText("Nothing ticked.")).toBeInTheDocument();
});
it("refuses to tick a blocked item", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [item({ blocked: "A base-image migration is in flight for this project." })],
}),
);
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
const box = screen.getByRole("checkbox");
expect(box).toBeDisabled();
expect(
screen.getByText("A base-image migration is in flight for this project."),
).toBeInTheDocument();
});
it("keeps semi-safe work out of the one-button bucket", async () => {
// Compaction is a rewrite and cache clearing costs a re-download. Neither
// may be swept up by a Reclaim press aimed at the free wins.
listReclaimable.mockResolvedValue(
plan({
items: [
item(),
item({
target: { kind: "compact_snapshot", project_id: "p-whp" },
safety: "semi_safe",
label: "Compact whp's snapshot",
bytes: 5_100_000_000,
bytes_are_exact: false,
bytes_floor: 0,
}),
],
}),
);
await renderAndScan();
const safe = await screen.findByTestId("disk-safe-bucket");
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
expect(within(safe).queryByText(/Compact whp/)).not.toBeInTheDocument();
const semi = screen.getByTestId("disk-semi-bucket");
expect(within(semi).getByText("Compact whp's snapshot")).toBeInTheDocument();
expect(within(semi).queryByRole("checkbox")).not.toBeInTheDocument();
});
it("marks a compaction's yield as a bound, never as a measurement", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "compact_snapshot", project_id: "p-whp" },
safety: "semi_safe",
label: "Compact whp's snapshot",
bytes: 5_100_000_000,
bytes_are_exact: false,
bytes_floor: 0,
}),
],
}),
);
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
expect(within(semi).getByText("up to 5.1 GB")).toBeInTheDocument();
});
it("says out loud when an action reaches the whole daemon", async () => {
// The user's daemon also holds unrelated postgres and site-builder work,
// and a build-cache prune takes their warm cache with ours.
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "build_cache", all: true },
daemon_wide: true,
label: "Build cache, all of it",
bytes: 28_000_000_000,
}),
],
}),
);
await renderAndScan();
const safe = await screen.findByTestId("disk-safe-bucket");
expect(within(safe).getByText("whole daemon")).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Orphan copy — the correction that matters most
// -------------------------------------------------------------------------
it("says what a 'no matching project' volume is derived from", async () => {
// An idle live project has volumes, no container and possibly no image —
// indistinguishable from a deleted one unless you consult the project
// store. The copy must not invite the inference that made that mistake.
getDockerDiskUsage.mockResolvedValue(
report({
orphan_volumes: [
{
name: "triple-c-home-gone",
project_id: "gone",
bytes: 900_000,
role: "home",
created_at: "2026-03-14T09:00:00Z",
},
],
orphan_volume_bytes: 900_000,
}),
);
await renderAndScan();
const globals = await screen.findByTestId("disk-globals");
expect(
within(globals).getByText(/Volumes with no matching project in Triple-C/),
).toBeInTheDocument();
// The sentence is split by an <em>, so match on the container's text.
expect(globals.textContent).toMatch(/is not inferred from a project being stopped/i);
expect(globals.textContent).toMatch(
/A project you have not opened in a while has no container and no snapshot either, and that is normal/i,
);
});
it("explains a suppressed orphan list instead of showing an empty one", async () => {
// With the project store unreadable every project's volumes look
// unclaimed. Showing nothing is right; showing nothing *silently* is not.
getDockerDiskUsage.mockResolvedValue(
report({
orphan_volumes: [],
orphan_volumes_unavailable:
"projects.json could not be read, so there is no way to tell an orphaned volume from a live project's.",
}),
);
await renderAndScan();
const banner = await screen.findByTestId("disk-store-error");
expect(within(banner).getByText("Could not read the project list")).toBeInTheDocument();
expect(within(banner).getByText(/no way to tell/)).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Windows / WSL2
// -------------------------------------------------------------------------
it("spells out that pruning will not shrink C: on Docker Desktop for Windows", async () => {
getDockerDiskUsage.mockResolvedValue(
report({
host: {
docker_root_dir: "/var/lib/docker",
operating_system: "Docker Desktop",
is_docker_desktop: true,
is_windows_host: true,
vhdx_applies: true,
vhdx_note: "Docker Desktop keeps this daemon inside ext4.vhdx on C:.",
vhdx_fix: ["wsl --shutdown", 'Optimize-VHD -Path "…docker_data.vhdx" -Mode Full'],
vhdx_fix_gui: "Docker Desktop → Settings → Resources → Advanced → Clean up / Purge data",
},
}),
);
await renderAndScan();
const note = await screen.findByTestId("disk-vhdx-note");
expect(
within(note).getByText("Reclaiming here will not shrink your C: drive"),
).toBeInTheDocument();
expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument();
expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument();
expect(within(note).getByText(/Purge data/)).toBeInTheDocument();
});
it("keeps the vhdx note off a host it does not apply to", async () => {
await renderAndScan();
await screen.findByTestId("disk-globals");
expect(screen.queryByTestId("disk-vhdx-note")).not.toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Destructive path
// -------------------------------------------------------------------------
it("needs the project name typed before it will delete a config volume", async () => {
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "config_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Claude config volume",
loses: "The Claude login credential, plugins, and EVERY conversation transcript.",
bytes: 427_000_000,
blocked: null,
},
],
}),
);
destroyProjectDiskObject.mockResolvedValue({
target: { kind: "orphan_volume", name: "triple-c-claude-config-p-whp" },
ok: true,
freed_bytes: 427_000_000,
projected_bytes: null,
message: "Removed volume.",
});
await renderAndScan();
await screen.findByTestId("disk-row-p-whp");
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: /Delete claude config volume/ }));
});
const dialog = screen.getByRole("dialog");
const confirm = within(dialog).getByRole("button", { name: "Delete claude config volume" });
expect(confirm).toBeDisabled();
expect(within(dialog).getByText(/EVERY conversation transcript/)).toBeInTheDocument();
// The wrong name does not open the gate.
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "who" } });
expect(confirm).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
expect(confirm).toBeEnabled();
await act(async () => {
fireEvent.click(confirm);
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "config_volume", project_id: "p-whp" },
"whp",
);
});
it("never routes a destructive object through the bulk Reclaim button", async () => {
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "home_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Home volume",
loses: "Shell history, dotfiles, toolchains.",
bytes: 4_860_000_000,
blocked: null,
},
],
}),
);
await renderAndScan();
const safe = await screen.findByTestId("disk-safe-bucket");
// One tick, for the dangling images — the home volume is not in this list
// at any price.
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
expect(within(safe).queryByText(/Home volume/)).not.toBeInTheDocument();
});
it("reports what was actually freed against what was projected", async () => {
reclaim.mockResolvedValue({
results: [
{
target: { kind: "compact_snapshot", project_id: "p-whp" },
ok: true,
freed_bytes: 5_100_000_000,
projected_bytes: 7_000_000_000,
message: "Rewrote the snapshot into a single layer.",
},
],
total_freed_bytes: 5_100_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(within(outcome).getByText("Reclaimed 5.1 GB")).toBeInTheDocument();
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
});
it("surfaces a scan failure rather than showing stale numbers", async () => {
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
render(<DiskSettings />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
expect(screen.getByRole("alert")).toHaveTextContent(/no such host/);
});
});
@@ -0,0 +1,512 @@
import { useState } from "react";
import Button from "../ui/Button";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
import Modal from "../ui/Modal";
import TypedConfirmModal from "../ui/TypedConfirmModal";
import DiskProjectTable from "./DiskProjectTable";
import { useDiskUsage } from "../../hooks/useDiskUsage";
import { formatBytes, formatBytesCeiling } from "../../lib/formatBytes";
import type { DestructiveItem, ReclaimItem, ReclaimTarget } from "../../lib/types";
/** A stable key for a target, so ticks survive a re-plan. */
function targetKey(target: ReclaimTarget): string {
return JSON.stringify(target);
}
/**
* Where the disk went, and how to get it back.
*
* ## Why the scan is a button
*
* `getDockerDiskUsage` is `GET /system/df`, which walks every image, container
* and volume on the daemon computing shared-layer sizes — seconds on a 100 GB
* store, and the only call that produces those numbers at all. So nothing here
* runs on open, on a timer, or on a re-render.
*
* ## Why the buckets are separated the way they are
*
* Safe work (dangling images, ownerless pins, build cache, volumes whose
* project id is not in the project store) gets one list of ticks and one
* button, because none of it can lose anything a user has. Note what the last
* of those is derived from: membership in Triple-C's own project list, never
* "this project has no container" — an idle live project looks exactly like a
* deleted one from the daemon's side, and mistaking the two would delete
* credentials and transcripts. Semi-safe work (compaction, cache clearing) is a rewrite or a
* re-download and is confirmed one at a time. Destructive work — a live
* project's volumes, its snapshot, a live rollback pin — is not in either list:
* it is reached only from that project's own row, behind a typed confirmation,
* and the backend refuses it in bulk by taking a different type entirely.
*/
export default function DiskSettings() {
const { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy } =
useDiskUsage();
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
const selected = safeItems.filter(
(i) => i.blocked === null && ticked.has(targetKey(i.target)),
);
const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0);
const toggle = (item: ReclaimItem) => {
setTicked((prev) => {
const next = new Set(prev);
const key = targetKey(item.target);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
const statusLabel = scanning
? "Scanning"
: report
? `Scanned ${new Date(report.scanned_at).toLocaleTimeString()}`
: "Not scanned";
return (
<div className="space-y-4 text-[13px]">
{/* --- Why this section exists ------------------------------------- */}
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
Every time a container is recreated, Triple-C commits it and a commit{" "}
<strong className="text-[var(--text-primary)]">stacks a new layer</strong> rather
than rewriting the old one. Deleting a file afterwards writes a whiteout; the
bytes underneath stay forever. Twenty-four different settings changes trigger a
recreation, so a project can quietly accumulate a dozen multi-gigabyte layers it
no longer uses any of.
</p>
{/* --- Scan --------------------------------------------------------- */}
<div className="flex items-center gap-3 flex-wrap">
<Button variant="primary" size="md" onClick={scan} disabled={scanning}>
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
</Button>
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
<span className="text-xs text-[var(--text-secondary)]">
Reads the whole Docker store; takes a few seconds on a large one.
</span>
</div>
{error && (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
)}
{!report && !scanning && (
<p className="text-xs text-[var(--text-secondary)]">
Nothing has been measured yet. Scanning is the only thing here that costs
anything, so it is never done for you.
</p>
)}
{report && (
<>
{/* --- Windows / WSL2, mandatory when it applies ----------------- */}
{report.host.vhdx_applies && (
<section
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
data-testid="disk-vhdx-note"
>
<StatusIndicator
tone="error"
label="Reclaiming here will not shrink your C: drive"
className="text-xs"
/>
<p className="text-xs text-[var(--text-primary)] leading-relaxed">
{report.host.vhdx_note}
</p>
<p className="text-xs text-[var(--text-secondary)]">
To actually give the space back to C:, run these in PowerShell as
administrator after reclaiming:
</p>
<pre className="text-[11px] font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2.5 py-2 overflow-x-auto select-text">
{report.host.vhdx_fix.join("\n")}
</pre>
<p className="text-xs text-[var(--text-secondary)]">
Or, without Hyper-V: {report.host.vhdx_fix_gui}.
</p>
</section>
)}
{/* --- Per-project table ---------------------------------------- */}
<section className="space-y-2">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
By project
</h3>
<DiskProjectTable
rows={report.projects}
destructive={plan?.destructive ?? []}
onDestroy={setDestroying}
/>
</section>
{/* --- Globals --------------------------------------------------- */}
<section className="space-y-2" data-testid="disk-globals">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Shared and left over
</h3>
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs">
<dt className="text-[var(--text-secondary)]">
Base images ({report.base_images.length}) shared by every project
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.base_images_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Superseded images from past recreations ({report.orphan_image_count})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_image_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Volumes with no matching project in Triple-C (
{report.orphan_volumes.length})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_volume_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Build cache <strong className="text-[var(--warning)]">whole daemon</strong>,
not just Triple-C{" "}
<span className="text-[var(--text-disabled)]">
(via {report.build_cache.source})
</span>
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.build_cache.reclaimable_bytes)} of{" "}
{formatBytes(report.build_cache.total_bytes)}
</dd>
<dt className="text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
Attributable to Triple-C
</dt>
<dd className="text-right tabular-nums text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
{formatBytes(report.triple_c_total_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Everything on this daemon, yours included
</dt>
<dd className="text-right tabular-nums">
{formatBytes(
report.images_total_bytes +
report.containers_total_bytes +
report.volumes_total_bytes,
)}
</dd>
</dl>
{report.orphan_volumes.length > 0 && (
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
That last figure means only that the volume&rsquo;s project id is not in
your project list &mdash; it is <em>not</em> inferred from a project
being stopped or having no image. A project you have not opened in a
while has no container and no snapshot either, and that is normal, so
each of these is ticked individually and shows the date Docker created
it.
</p>
)}
<p className="text-[11px] text-[var(--text-secondary)]">
Docker stores this at{" "}
<span className="font-mono">{report.host.docker_root_dir || "an unknown path"}</span>
{report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}.
</p>
</section>
{/* --- Store failure, if any ------------------------------------ */}
{report.orphan_volumes_unavailable && (
<section
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
data-testid="disk-store-error"
>
<StatusIndicator
tone="error"
label="Could not read the project list"
className="text-xs"
/>
<p className="mt-1.5 text-xs text-[var(--text-primary)] leading-relaxed">
{report.orphan_volumes_unavailable}
</p>
</section>
)}
{/* --- Safe reclaim ---------------------------------------------- */}
<section className="space-y-2" data-testid="disk-safe-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Safe to reclaim
</h3>
{safeItems.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
Nothing here no leftovers were found.
</p>
) : (
<>
<p className="text-xs text-[var(--text-secondary)]">
None of this is reachable any more, or all of it regenerates on demand.
Nothing you have made is in this list.
</p>
<ul className="space-y-1.5">
{safeItems.map((item) => {
const key = targetKey(item.target);
return (
<li key={key}>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={ticked.has(key)}
disabled={item.blocked !== null}
onChange={() => toggle(item)}
className="mt-0.5 accent-[var(--accent-emphasis)]"
/>
<span className="flex-1 min-w-0">
<span className="flex items-baseline justify-between gap-3">
<span
className={
item.blocked
? "text-[var(--text-disabled)]"
: "text-[var(--text-primary)]"
}
>
{item.label}
{item.daemon_wide && (
<span className="ml-1.5 text-[11px] text-[var(--warning)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] px-1 py-px">
whole daemon
</span>
)}
</span>
<span className="tabular-nums whitespace-nowrap text-[var(--text-secondary)]">
{formatBytes(item.bytes)}
</span>
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
</label>
</li>
);
})}
</ul>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
disabled={selected.length === 0 || working}
onClick={() => runReclaim(selected.map((i) => i.target))}
>
{working ? "Reclaiming…" : "Reclaim"}
</Button>
<span className="text-xs text-[var(--text-secondary)]">
{selected.length === 0
? "Nothing ticked."
: `${selected.length} selected, ${formatBytes(selectedBytes)}.`}
</span>
</div>
</>
)}
</section>
{/* --- Semi-safe -------------------------------------------------- */}
{semiItems.length > 0 && (
<section className="space-y-2" data-testid="disk-semi-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Worth doing, one at a time
</h3>
<p className="text-xs text-[var(--text-secondary)]">
Nothing here loses anything you have installed. Compacting rewrites a
project&rsquo;s stacked layers into one; clearing caches deletes files
that refill themselves. Both take a moment and both are confirmed
separately.
</p>
<ul className="space-y-1.5">
{semiItems.map((item) => (
<li
key={targetKey(item.target)}
className="flex items-start justify-between gap-3"
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)]">{item.label}</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
{/* A bound, not a measurement — rendered through a
different helper so it cannot read as a promise. */}
{item.bytes_are_exact
? formatBytes(item.bytes)
: formatBytesCeiling(item.bytes)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => setConfirming(item)}
>
Run
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Sweep ------------------------------------------------------ */}
<section className="flex items-center gap-3 flex-wrap">
<Button
size="sm"
disabled={working}
onClick={() => runReclaim([{ kind: "dangling_snapshots" }])}
>
Sweep superseded images now
</Button>
<span className="text-xs text-[var(--text-secondary)]">
The same sweep that runs at startup and after every recreation here you
can see what it found.
</span>
</section>
</>
)}
{/* --- Outcome ------------------------------------------------------- */}
{outcome && (
<section
className="border border-[var(--border-color)] bg-[var(--bg-primary)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-1.5"
role="status"
aria-live="polite"
data-testid="disk-outcome"
>
<StatusIndicator
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
className="text-xs"
/>
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
{outcome.results.map((result, index) => (
<li key={index}>
{result.message}
{result.projected_bytes !== null && (
<>
{" "}
<span className="text-[var(--text-disabled)]">
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
{formatBytes(result.freed_bytes)})
</span>
</>
)}
</li>
))}
</ul>
</section>
)}
{/* --- Semi-safe confirmation ---------------------------------------- */}
{confirming && (
<Modal
title={confirming.label}
onClose={() => setConfirming(null)}
widthClassName="w-[30rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={() => setConfirming(null)}>
Cancel
</Button>
<Button
size="md"
variant="primary"
disabled={working}
onClick={() => {
const target = confirming.target;
setConfirming(null);
void runReclaim([target]);
}}
>
{working ? "Working…" : "Run it"}
</Button>
</>
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
<p>{confirming.detail}</p>
{confirming.target.kind === "compact_snapshot" && (
<>
<p>
The snapshot is rebuilt into a single layer while the old one is left
in place, so a failure at any point leaves this project exactly as it
is now.
</p>
<p>
How much comes back depends on how much of those layers a later one
already replaced &mdash; it could be{" "}
{formatBytesCeiling(confirming.bytes)}, and it could be nothing at all.
You will be told the real figure when it finishes.
</p>
<p>
One thing worth knowing: the rewritten image no longer shares the base
image with your other projects, so it carries its own copy of it. That
cost is already subtracted from the figure above, and if the rewrite
turns out not to come out ahead it is thrown away and the snapshot is
left exactly as it is.
</p>
</>
)}
{confirming.target.kind === "clear_caches" &&
confirming.target.include_rustup && (
<p>
Rust toolchains are included in this one. They are regenerable, but
getting them back is a download rather than a rebuild.
</p>
)}
</div>
</Modal>
)}
{/* --- Destructive confirmation --------------------------------------- */}
{destroying && (
<TypedConfirmModal
title={`Delete ${destroying.label.toLowerCase()}`}
expected={destroying.project_name}
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
busy={working}
onCancel={() => setDestroying(null)}
onConfirm={(typed) => {
const target = destroying.target;
setDestroying(null);
void destroy(target, typed);
}}
>
<p>
This removes{" "}
<strong className="text-[var(--text-primary)]">
{destroying.project_name}
</strong>
&rsquo;s {destroying.label.toLowerCase()}, freeing{" "}
{formatBytes(destroying.bytes)}.
</p>
<p className="text-[var(--error)]">{destroying.loses}</p>
<p>
Your mounted project folders live on the host and are not affected by this.
</p>
</TypedConfirmModal>
)}
</div>
);
}
@@ -19,6 +19,7 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
import DiskSettings from "./DiskSettings";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
@@ -173,6 +174,10 @@ export default function SettingsPanel() {
<DockerSettings />
</AccordionSection>
<AccordionSection id="disk" title="Disk" defaultOpen={false}>
<DiskSettings />
</AccordionSection>
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
<CertificateSettings />
</AccordionSection>