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:
@@ -1,10 +1,16 @@
|
||||
/** Shared formatting helpers for the Project Home views. */
|
||||
|
||||
import { formatBytes as shared } from "../../../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* File sizes in Project Home, ÷1024 with `KB`/`MB`/`GB` labels.
|
||||
*
|
||||
* Kept as a named re-export rather than deleted: three modules import it from
|
||||
* here, and the binary/decimal-label pairing is a Project Home convention
|
||||
* rather than the app-wide default. The implementation is `lib/formatBytes`.
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
return shared(bytes, { binary: true });
|
||||
}
|
||||
|
||||
/** "2h ago" / "3d ago". Returns null for unparseable timestamps. */
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { PackageFailure } from "../../lib/types";
|
||||
import { formatBytes } from "../../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||||
@@ -62,14 +63,7 @@ export const REPLAY_COST =
|
||||
|
||||
/** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */
|
||||
export function formatDataSize(bytes: number): string {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||||
|
||||
@@ -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’s project id is not in
|
||||
your project list — 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’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 — 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>
|
||||
’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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user