Merge branch 'feat/disk-ui' into integration/round-1

This commit is contained in:
2026-08-23 09:51:44 -07:00
20 changed files with 6797 additions and 20 deletions
+10 -4
View File
@@ -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. */
+2 -8
View File
@@ -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,196 @@
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;
}
const LAYERS_HELP =
"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.";
const NEXT_COMMIT_HELP =
"The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that.";
/** `—` 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` renders a portalled div with no `role` and no
`aria-describedby`, so its text reaches no assistive tech and
the trigger announces as "Help". These two headers are
meaningless without their explanation, so it is also emitted
as screen-reader-only text. */}
<Tooltip text={LAYERS_HELP} />
<span className="sr-only"> {LAYERS_HELP}</span>
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Next commit adds
<Tooltip text={NEXT_COMMIT_HELP} />
<span className="sr-only"> {NEXT_COMMIT_HELP}</span>
</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">
{/* `null` means the split could not be measured. Rendering it
as 0 B would be the one guessed number in this table. */}
{cell(
row.snapshot_above_base_bytes ?? -1,
row.snapshot_exists && row.snapshot_above_base_bytes !== null,
)}
{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 ? (
"—"
) : !row.base_lineage_known ? (
// The base this descends from is unknown, so the count
// includes the base's own layers and does not mean
// "recreations". Saying so beats printing a wrong number.
<Tooltip
text={`${row.snapshot_commit_layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`}
>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
) : (
<span className="text-[var(--text-primary)]">
{row.snapshot_commit_layers}
{/* Never colour alone: a count worth acting on says so in
a word, which is also what a screen reader gets. */}
{row.snapshot_commit_layers > 5 && (
<span className="ml-1 text-[11px] text-[var(--warning)]">
stacked
</span>
)}
</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,631 @@
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,
base_lineage_known: true,
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("refuses to present a layer count that does not mean recreations", async () => {
// Without `triple-c.base-image-id` — the normal case for a project created
// before that label existed — the count includes the base's own ~15 layers.
// Printing it beside a header that says "one per recreation" would be a
// wrong number in the column the table exists for.
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("unknown")).toBeInTheDocument();
expect(within(projectRow).queryByText("17")).not.toBeInTheDocument();
});
it("renders an unmeasurable snapshot split as a dash, never as zero", async () => {
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ snapshot_above_base_bytes: null })] }),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument();
expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0);
});
it("marks a heavily stacked snapshot with a word, not just a colour", async () => {
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("stacked")).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("clears the tick list once the reclaim has run", async () => {
// The plan's rows describe objects the reclaim just removed; leaving them
// ticked lets the user fire the same call again against nothing.
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
expect(screen.getByText(/1 selected/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
expect(screen.queryByTestId("disk-safe-bucket")).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
// And it says why the list is gone rather than claiming nothing was found.
expect(screen.getByTestId("disk-plan-stale").textContent).toMatch(
/measured before that last action/,
);
});
it("says why the build-cache figure is the under-reporting one", async () => {
// Without this, a `buildx du` failure silently shows `docker system df`'s
// number, which under-reports what a prune would free.
getDockerDiskUsage.mockResolvedValue(
report({
build_cache: {
total_bytes: 28_000_000_000,
reclaimable_bytes: 1_000_000,
stale_bytes: 0,
source: "system df",
cli_error: "`docker buildx du` failed: executable not found",
},
}),
);
await renderAndScan();
const globals = await screen.findByTestId("disk-globals");
expect(globals.textContent).toMatch(/under-reports what a prune would free/);
expect(globals.textContent).toMatch(/executable not found/);
});
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(note.textContent).toMatch(/Warning: reclaiming here will not shrink your C: drive/);
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: null,
destroyed: { kind: "config_volume", project_id: "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("keeps the confirmation open and busy while the deletion runs", async () => {
// The modal used to be unmounted before the call was awaited, which made
// its whole busy path dead code and left a multi-second volume removal with
// no indication it was happening.
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 and toolchains.",
bytes: 4_860_000_000,
blocked: null,
},
],
}),
);
let finish: (value: unknown) => void = () => {};
destroyProjectDiskObject.mockReturnValue(new Promise((r) => (finish = r)));
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 home volume/ }));
});
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" }));
// Still open, and saying so.
await waitFor(() =>
expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(),
);
await act(async () => {
finish({
target: null,
destroyed: { kind: "home_volume", project_id: "p-whp" },
ok: true,
freed_bytes: 4_860_000_000,
projected_bytes: null,
message: "Removed volume.",
});
});
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
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" },
destroyed: null,
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 as an alert", 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,559 @@
import { useEffect, 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,
runSweep,
clearOutcome,
} = useDiskUsage();
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
// The plan is dropped after any reclaim, so a tick can never outlive the row
// it was made against and be re-fired at an object that is already gone.
useEffect(() => {
if (!plan) setTicked(new Set());
}, [plan]);
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` has no warning tone — `error` would put a
red glyph in a warning-toned panel. This is advisory, so it
carries its own glyph beside the words rather than relying on
the panel's colour. */}
<p className="text-xs font-medium text-[var(--text-primary)]">
<span aria-hidden="true">&#9650;</span> Warning: reclaiming here will not
shrink your C: drive
</p>
<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.build_cache.cli_error && (
<p className="text-[11px] text-[var(--warning)]">
{/* Without this the panel silently shows `docker system df`'s
under-reported build-cache figure and the user has no way
to know why it disagrees with their terminal. */}
Build-cache figures fell back to <code>docker system df</code>, which
under-reports what a prune would free: {report.build_cache.cli_error}
</p>
)}
{report.orphan_volumes.length > 0 && (
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
&ldquo;Volumes with no matching project&rdquo; above 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(--error)]/40 bg-[var(--error-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>
)}
{/* --- The plan was dropped by a reclaim -------------------------- */}
{!plan && (
<p className="text-xs text-[var(--text-secondary)]" data-testid="disk-plan-stale">
The totals above were measured before that last action. Scan again to see
what is left to reclaim.
</p>
)}
{/* --- Safe reclaim ---------------------------------------------- */}
{plan && (
<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"
// A tick that survived onto a now-blocked row is
// excluded from `selected`, so showing it checked
// would make the count disagree with the screen.
checked={item.blocked === null && 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={runSweep}>
Sweep superseded images now
</Button>
<span className="text-xs text-[var(--text-secondary)]">
The same sweep that runs at startup and after every recreation. Unlike the
tick above it also reports what it <em>refused</em> to remove, which is how
a superseded image pinned by a stopped project shows itself.
</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"
>
<div className="flex items-center justify-between gap-3">
<StatusIndicator
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
className="text-xs"
/>
<Button size="sm" variant="ghost" onClick={clearOutcome}>
Dismiss
</Button>
</div>
<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={async () => {
// Same reasoning as the destructive modal: a compaction takes
// minutes, and the dialog reporting it beats it vanishing.
await runReclaim([confirming.target]);
setConfirming(null);
}}
>
{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={async (typed) => {
// The modal stays mounted until the call settles, so its `busy`
// state is what the user sees while a multi-second volume removal
// runs. Clearing it first made the whole busy path dead code.
await destroy(destroying.target, typed);
setDestroying(null);
}}
>
<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>
@@ -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();
});
});
+116
View File
@@ -0,0 +1,116 @@
import { useId, 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);
// Every other `ui/` component uses `useId`; a hardcoded id breaks the
// label association as soon as two of these are mounted at once.
const inputId = useId();
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={inputId}
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
>
Type <strong className="font-mono">{expected}</strong> to confirm
</label>
<input
id={inputId}
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>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useDiskUsage } from "./useDiskUsage";
import type { DiskUsageReport } from "../lib/types";
const getDockerDiskUsage = vi.fn();
const listReclaimable = vi.fn();
const reclaim = vi.fn();
const destroyProjectDiskObject = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
getDockerDiskUsage: () => getDockerDiskUsage(),
listReclaimable: (report: DiskUsageReport) => listReclaimable(report),
reclaim: (targets: unknown) => reclaim(targets),
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
destroyProjectDiskObject(target, confirmation),
sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(),
}));
const sweepOrphanedSnapshots = vi.fn();
const report = (scanned_at: string): DiskUsageReport =>
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
const plan = { items: [], destructive: [], store_error: null };
beforeEach(() => {
vi.clearAllMocks();
listReclaimable.mockResolvedValue(plan);
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
});
describe("useDiskUsage", () => {
it("holds no report until a scan is asked for", () => {
const { result } = renderHook(() => useDiskUsage());
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(getDockerDiskUsage).not.toHaveBeenCalled();
});
it("scans, then plans off the same report rather than scanning again", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
expect(listReclaimable).toHaveBeenCalledWith(report("first"));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.plan).toEqual(plan);
});
it("lets the newest scan win when two are in flight", async () => {
// A user pressing Scan twice can have two `df()` calls outstanding, and
// the second is not necessarily the slower one. A stale response must not
// overwrite a fresher one.
let resolveFirst: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage
.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveFirst = r;
}),
)
.mockResolvedValueOnce(report("second"));
const { result } = renderHook(() => useDiskUsage());
let firstScan: Promise<void> = Promise.resolve();
act(() => {
firstScan = result.current.scan();
});
await act(async () => {
await result.current.scan();
});
expect(result.current.report?.scanned_at).toBe("second");
// The slow first scan lands afterwards and is discarded.
await act(async () => {
resolveFirst(report("first"));
await firstScan;
});
expect(result.current.report?.scanned_at).toBe("second");
expect(result.current.scanning).toBe(false);
});
it("passes the ticked targets straight through", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
expect(reclaim).toHaveBeenCalledWith([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
it("does not call the backend for an empty selection", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([]);
});
expect(reclaim).not.toHaveBeenCalled();
});
it("does not re-scan after a reclaim", async () => {
// Another `df()` costs seconds, and the outcome already carries measured
// bytes for every target. A user who wants fresh totals asks for them.
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
});
it("clears the previous outcome when a new scan starts", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 });
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.outcome?.total_freed_bytes).toBe(42);
await act(async () => {
await result.current.scan();
});
expect(result.current.outcome).toBeNull();
});
it("forwards the typed confirmation verbatim", async () => {
destroyProjectDiskObject.mockResolvedValue({
target: { kind: "dangling_snapshots" },
ok: true,
freed_bytes: 100,
projected_bytes: null,
message: "gone",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp");
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "config_volume", project_id: "p1" },
"whp",
);
expect(result.current.outcome?.total_freed_bytes).toBe(100);
});
it("reports a scan failure and keeps the last good measurement", async () => {
// The old report is still an accurate measurement of an earlier moment,
// and the error says the refresh failed. Blanking it would leave the panel
// with nothing while telling the user nothing more.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable");
await act(async () => {
await result.current.scan();
});
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.scanning).toBe(false);
});
it("never shows fresh totals beside a stale tick list", async () => {
// `setReport` used to land before the plan call was awaited, so a plan
// failure rendered this scan's numbers above the previous scan's rows.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockResolvedValueOnce(report("second"));
listReclaimable.mockRejectedValueOnce("planner exploded");
await act(async () => {
await result.current.scan();
});
expect(result.current.error).toMatch(/planner exploded/);
expect(result.current.report?.scanned_at).toBe("first");
});
it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(result.current.plan).toEqual(plan);
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.plan).toBeNull();
// The totals stay — they were measured before the reclaim and the outcome
// says what changed.
expect(result.current.report?.scanned_at).toBe("first");
});
it("runs the sweep through its own command and reports what it refused", async () => {
// The sweep's `in_use` count — orphans Docker refused to delete because a
// stopped project still needs them — is invisible everywhere else in the
// app, because every other caller throws the report away.
sweepOrphanedSnapshots.mockResolvedValue({
removed: ["sha256:a", "sha256:b"],
reclaimed_bytes: 11_900_000_000,
in_use: 3,
failed: [],
unavailable: null,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(sweepOrphanedSnapshots).toHaveBeenCalled();
expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000);
expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/);
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
});
it("treats an unreachable daemon in the sweep report as an error", async () => {
sweepOrphanedSnapshots.mockResolvedValue({
removed: [],
reclaimed_bytes: 0,
in_use: 0,
failed: [],
unavailable: "Could not reach the Docker engine",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(result.current.error).toMatch(/Could not reach the Docker engine/);
expect(result.current.outcome).toBeNull();
});
});
+198
View File
@@ -0,0 +1,198 @@
import { useCallback, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
import type {
DestructiveTarget,
DiskUsageReport,
ReclaimOutcome,
ReclaimPlan,
ReclaimTarget,
} from "../lib/types";
/**
* State for the Disk section.
*
* ## Why nothing here runs on mount
*
* A scan is `GET /system/df`, which walks every image, container and volume on
* the daemon and computes shared-layer sizes. On a 100 GB store that is
* seconds. `AccordionSection` unmounts its body when collapsed, so a
* `useEffect` scan would re-run every single time the user opened the section.
* The scan is therefore only ever what the Scan button calls.
*
* Note what that does *not* buy: this hook lives inside `DiskSettings`, which
* the accordion unmounts on collapse, so its state goes with it and reopening
* the section shows an unscanned panel again. That is the honest behaviour —
* a stale total is worse than an absent one — but it means collapsing and
* reopening discards a scan the user paid for. Lifting the report into
* `appState` would fix that and is deliberately not done here: it would put a
* multi-megabyte, rapidly-stale blob into the app-wide store for one panel.
*
* ## The generation guard
*
* A user who hits Scan twice can have two `df()` calls in flight, and they can
* land out of order — the second one is not necessarily slower. Every async
* write in `scan` checks it is still the newest before it lands, the same
* pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need
* it: the UI disables their buttons while `working` is set, so there is never
* a second one to race.
*/
export interface DiskUsageState {
report: DiskUsageReport | null;
plan: ReclaimPlan | null;
/** A scan is in flight. */
scanning: boolean;
/** A reclaim or a destroy is in flight. */
working: boolean;
error: string | null;
/** The outcome of the last reclaim, kept on screen until the next scan. */
outcome: ReclaimOutcome | null;
scan: () => Promise<void>;
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
destroy: (target: DestructiveTarget, confirmation: string) => Promise<void>;
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
runSweep: () => Promise<void>;
clearOutcome: () => void;
}
export function useDiskUsage(): DiskUsageState {
const [report, setReport] = useState<DiskUsageReport | null>(null);
const [plan, setPlan] = useState<ReclaimPlan | null>(null);
const [scanning, setScanning] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
const generation = useRef(0);
const scan = useCallback(async () => {
const mine = ++generation.current;
setScanning(true);
setError(null);
// The previous outcome describes a state that no longer holds once a new
// scan starts, so it goes rather than sitting beside fresh numbers.
setOutcome(null);
try {
const next = await commands.getDockerDiskUsage();
if (generation.current !== mine) return;
// Planning is cheap and always wanted: the classification is what makes
// the numbers actionable, and it reuses the report rather than scanning
// again.
const nextPlan = await commands.listReclaimable(next);
if (generation.current !== mine) return;
// Both land together, or neither does. Setting the report before
// awaiting the plan would render this scan's totals above the *previous*
// scan's still-clickable tick list if the plan call failed.
setReport(next);
setPlan(nextPlan);
} catch (e) {
if (generation.current !== mine) return;
setError(String(e));
// The old report is left on screen deliberately — it is still an
// accurate measurement of an earlier moment, and the error says the
// refresh failed. What must not survive is a plan describing a scan the
// user can no longer see the totals for, but that cannot happen: the two
// only ever move together.
} finally {
if (generation.current === mine) setScanning(false);
}
}, []);
const runReclaim = useCallback(async (targets: ReclaimTarget[]) => {
if (targets.length === 0) return;
setWorking(true);
setError(null);
try {
const result = await commands.reclaim(targets);
setOutcome(result);
// **The plan is now stale and must not stay clickable.** Its rows
// describe objects this call just removed, so leaving them ticked lets
// the user fire the same reclaim again against nothing. Dropping the plan
// (not the report) leaves the totals on screen, marked as measured before
// the reclaim, with the tick list gone.
//
// Deliberately no automatic re-scan: it costs another `df()`, and the
// outcome already reports measured bytes for every target — a user who
// wants the new totals asks for them.
setPlan(null);
} catch (e) {
setError(String(e));
} finally {
setWorking(false);
}
}, []);
const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => {
setWorking(true);
setError(null);
try {
const result = await commands.destroyProjectDiskObject(target, confirmation);
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
// Same reasoning as `runReclaim`: the destructive list named an object
// that is now gone.
setPlan(null);
} catch (e) {
setError(String(e));
} finally {
setWorking(false);
}
}, []);
/**
* The startup sweep, on demand.
*
* Not the same as ticking "superseded snapshot layers", even though both end
* up removing the same images: this reports `in_use` — the orphans Docker
* *refused* to delete because a stopped project's container still needs
* them. That refusal is the sweep's third safety net and it is invisible
* everywhere else in the app, because every existing caller throws the
* report away.
*/
const runSweep = useCallback(async () => {
setWorking(true);
setError(null);
try {
const sweep = await commands.sweepOrphanedSnapshots();
if (sweep.unavailable) {
setError(sweep.unavailable);
return;
}
const refused =
sweep.in_use > 0
? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.`
: "";
setOutcome({
results: [
{
target: { kind: "dangling_snapshots" },
destroyed: null,
ok: sweep.failed.length === 0,
freed_bytes: sweep.reclaimed_bytes,
projected_bytes: null,
message: `Swept ${sweep.removed.length} superseded image(s).${refused}`,
},
],
total_freed_bytes: sweep.reclaimed_bytes,
});
setPlan(null);
} catch (e) {
setError(String(e));
} finally {
setWorking(false);
}
}, []);
const clearOutcome = useCallback(() => setOutcome(null), []);
return {
report,
plan,
scanning,
working,
error,
outcome,
scan,
runReclaim,
destroy,
runSweep,
clearOutcome,
};
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes";
describe("formatBytes", () => {
it("defaults to base 1000, because that is what Docker prints", () => {
// The Disk panel exists to explain `docker system df`, which formats with
// `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying
// 28.0 GB for the same build cache reads as a bug in the panel.
expect(formatBytes(28_000_000_000)).toBe("28.0 GB");
expect(formatBytes(1_000)).toBe("1.0 KB");
expect(formatBytes(1_500_000)).toBe("1.5 MB");
expect(formatBytes(12_273_392_374)).toBe("12.3 GB");
});
it("leaves whole bytes without a decimal point", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(999)).toBe("999 B");
});
it("reproduces the Project Home convention exactly under `binary`", () => {
// Three modules import `projects/home/format.ts#formatBytes`, which is now
// this function. Its output had to be byte-identical or re-pointing it
// would have quietly changed every file listing in the app.
expect(formatBytes(1023, { binary: true })).toBe("1023 B");
expect(formatBytes(1024, { binary: true })).toBe("1.0 KB");
expect(formatBytes(1024 * 1024, { binary: true })).toBe("1.0 MB");
expect(formatBytes(1024 * 1024 * 1024, { binary: true })).toBe("1.0 GB");
expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB");
});
it("reproduces the migration convention exactly by default", () => {
// `migrationCopy.formatDataSize` is now a call to this, and its output is
// asserted in MigrateContainerModal.test.tsx.
expect(formatBytes(41_000_000)).toBe("41.0 MB");
expect(formatBytes(3_800_000_000)).toBe("3.8 GB");
});
it("labels binary units honestly when asked to", () => {
expect(formatBytes(1024, { binary: true, iec: true })).toBe("1.0 KiB");
expect(formatBytes(1024 ** 3, { binary: true, iec: true })).toBe("1.0 GiB");
});
it("climbs to TB rather than showing five-digit gigabytes", () => {
expect(formatBytes(2_500_000_000_000)).toBe("2.5 TB");
});
it("promotes the unit when rounding lands on a whole step", () => {
// `toFixed` runs after the divide loop, so a value just under a boundary
// rounds up into a unit the loop had already ruled out. This is the app's
// only byte formatter and the panel is full of near-boundary sizes.
expect(formatBytes(999_999)).toBe("1.0 MB");
expect(formatBytes(999_999_999)).toBe("1.0 GB");
expect(formatBytes(999_999_999_999)).toBe("1.0 TB");
expect(formatBytes(1_048_575, { binary: true })).toBe("1.0 MB");
// Just below the rounding threshold it must NOT promote.
expect(formatBytes(999_949)).toBe("999.9 KB");
expect(formatBytes(999_400, { precision: 0 })).toBe("999 KB");
// The top unit has nowhere to go: it renders a whole step rather than
// running off the end of the unit array.
expect(formatBytes(999_999_999_999_999_999)).toBe("1000.0 PB");
});
it("renders an em dash for a size the daemon did not compute", () => {
// Docker reports -1 for "not calculated" on shared sizes and volume ref
// counts. `NaN GB` in the middle of a table is worse than nothing.
expect(formatBytes(-1)).toBe("—");
expect(formatBytes(NaN)).toBe("—");
expect(formatBytes(Infinity)).toBe("—");
});
it("honours a requested precision", () => {
expect(formatBytes(1_234_567_890, { precision: 2 })).toBe("1.23 GB");
expect(formatBytes(1_234_567_890, { precision: 0 })).toBe("1 GB");
});
});
describe("formatBytesDelta", () => {
it("signs a figure that is being added rather than measured", () => {
// "Next commit adds +868.0 MB" — the sign is what makes it read as a cost
// about to be incurred rather than a size already on disk.
expect(formatBytesDelta(868_000_000)).toBe("+868.0 MB");
expect(formatBytesDelta(0)).toBe("+0 B");
});
it("does not sign an unknown", () => {
expect(formatBytesDelta(-1)).toBe("—");
});
});
describe("formatBytesCeiling", () => {
it("says 'up to', because a compaction's yield is a bound not a promise", () => {
// Every other figure in the Disk panel is measured. This one cannot be
// known until the rewrite runs, and rendering it through a separate
// function is what stops it being read as a guarantee.
expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB");
});
it("refuses to imply a saving when there is no bound to give", () => {
expect(formatBytesCeiling(0)).toBe("an unknown amount");
expect(formatBytesCeiling(-1)).toBe("an unknown amount");
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* The one byte formatter.
*
* The app had four of them — `projects/home/format.ts`,
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
* unit labels and the precision. The first two now delegate here.
*
* The other two deliberately do not, yet: `UpdateDialog` renders KB at
* `toFixed(0)`, so re-pointing it would change what a download size reads as,
* and neither is on the Disk panel's path. They are the remaining copies.
*
* ## Why the default is base 1000
*
* The Disk panel exists to explain what `docker system df` reports, and Docker
* formats every size it prints with `units.HumanSize`, which is **base 1000**.
* A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the
* same build cache would read as a bug in the panel. So decimal is the default
* and binary is opt-in, rather than the other way round.
*
* Both existing conventions are preserved for every size either call site can
* realistically produce — a file size or a payload size, i.e. a non-negative
* finite number below a terabyte. Outside that range this deliberately differs
* from what it replaced: a negative or `NaN` input now renders `—` rather than
* `-1 B` or `NaN GB`, and the unit ladder continues past GB instead of
* stopping there.
*
* - `{ }` → `41.0 MB` (decimal, what migration used)
* - `{ binary: true }` → `1.5 GB` (÷1024 with decimal-style
* labels, what Project Home used
* — technically a misnomer, but
* it is the app's convention and
* changing it is not this
* feature's business)
* - `{ binary: true, iec: true }` → `1.5 GiB` (÷1024 labelled honestly)
*/
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
export interface FormatBytesOptions {
/** Divide by 1024 instead of 1000. */
binary?: boolean;
/** Label binary units as `KiB`/`MiB`/`GiB` rather than `KB`/`MB`/`GB`. */
iec?: boolean;
/** Decimal places above `B`. Bytes are always whole. */
precision?: number;
}
export function formatBytes(bytes: number, options: FormatBytesOptions = {}): string {
const { binary = false, iec = false, precision = 1 } = options;
// A negative or non-finite size is a bug upstream, not something to render as
// `NaN GB` in the middle of a table. Docker reports -1 for "not computed",
// and that is the case this actually catches.
if (!Number.isFinite(bytes) || bytes < 0) return "—";
const step = binary ? 1024 : 1000;
const units = binary && iec ? IEC_UNITS : DECIMAL_UNITS;
let value = bytes;
let unit = 0;
while (value >= step && unit < units.length - 1) {
value /= step;
unit += 1;
}
// **Promote again if rounding pushed the value back up to a whole step.**
// `toFixed` runs after the loop, so 999,999 B divides to 999.999 KB and then
// renders as "1000.0 KB" — a unit the loop had already decided against. The
// same happens at every boundary (999,999,999 → "1000.0 MB", and 1,048,575
// → "1024.0 KB" in binary).
if (unit < units.length - 1 && Number(value.toFixed(precision)) >= step) {
value /= step;
unit += 1;
}
// Whole bytes never get a decimal point: `512 B`, not `512.0 B`.
return unit === 0
? `${Math.round(bytes)} ${units[0]}`
: `${value.toFixed(precision)} ${units[unit]}`;
}
/**
* `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than
* measured. Used for "next commit adds …", which is the number that explains
* why a snapshot grows.
*/
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
const formatted = formatBytes(bytes, options);
return formatted === "—" ? formatted : `+${formatted}`;
}
/**
* `up to 12.3 GB` — for a bound rather than a measurement.
*
* The Disk panel is careful about this distinction: every figure it shows is
* measured except a compaction's yield, which cannot be known until it runs.
* Rendering that one through a different function is what stops it being read
* as a promise.
*/
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
return `up to ${formatBytes(bytes, options)}`;
}
+32 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -356,3 +356,34 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });
// Disk
/** Measure where the daemon's bytes have gone.
*
* **Expensive — keep it behind an explicit Scan button.** This is
* `GET /system/df`, which walks every image, container and volume on the
* daemon to compute shared-layer sizes, plus an `image_history` per image.
* Seconds on a 100 GB store. Never call it on mount and never poll it. */
export const getDockerDiskUsage = () => invoke<DiskUsageReport>("get_docker_disk_usage");
/** Classify what could be reclaimed, with measured bytes. Takes the report
* from `getDockerDiskUsage` so re-planning costs no second scan. */
export const listReclaimable = (report: DiskUsageReport) =>
invoke<ReclaimPlan>("list_reclaimable", { report });
/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action,
* so no selection built here can delete a live project's data. */
export const reclaim = (targets: ReclaimTarget[]) =>
invoke<ReclaimOutcome>("reclaim", { targets });
/** Delete one object that has no other copy. `confirmation` must be the
* project's name, typed by the user. One target per call, never bulk. */
export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) =>
invoke<ReclaimResult>("destroy_project_disk_object", { target, confirmation });
/** Run the orphaned-snapshot sweep on demand and see its report — the same
* sweep that runs at startup and after every recreation, whose result every
* existing caller throws away. */
export const sweepOrphanedSnapshots = () =>
invoke<SnapshotSweepReport>("sweep_orphaned_snapshots");
+205
View File
@@ -823,3 +823,208 @@ export interface MigrationState {
options: MigrationOptions;
plan: MigrationPlan | null;
}
// ---------------------------------------------------------------------------
// Disk
// ---------------------------------------------------------------------------
//
// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every
// other IPC struct in this app.
/** One row of the per-project disk table. */
export interface ProjectDiskRow {
project_id: string;
project_name: string;
snapshot_image: string;
snapshot_exists: boolean;
/** Total size of the snapshot image, base image included. */
snapshot_bytes: number;
/** Bytes shared with another image — almost always the base. */
snapshot_shared_bytes: number;
/** Layers stacked above the base image: **one per container recreation**.
* This is the number that explains why a snapshot grows — but only when
* `base_lineage_known` is true. Otherwise it counts the base's layers too. */
snapshot_commit_layers: number;
/** Whether the base image this snapshot descends from could be identified.
* False is the normal case for a project created before the
* `triple-c.base-image-id` label existed; the layer count must not be
* presented as a recreation count then. */
base_lineage_known: boolean;
/** Bytes those layers account for. `null` when the base image is gone and
* the split cannot be measured — never a guess. */
snapshot_above_base_bytes: number | null;
container_exists: boolean;
container_running: boolean;
/** The writable layer, i.e. exactly what the next commit will add. */
container_writable_bytes: number;
home_volume_bytes: number;
home_volume_present: boolean;
config_volume_bytes: number;
config_volume_present: boolean;
total_bytes: number;
migrating: boolean;
}
export interface BaseImageRow {
reference: string;
bytes: number;
shared_bytes: number;
containers: number;
is_labelled_base: boolean;
}
/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies.
* The vhdx copy comes from Rust so the wording cannot drift from the
* constants its tests pin. */
export interface HostStorage {
docker_root_dir: string;
operating_system: string;
is_docker_desktop: boolean;
is_windows_host: boolean;
vhdx_applies: boolean;
/** Empty unless `vhdx_applies`. */
vhdx_note: string;
vhdx_fix: string[];
vhdx_fix_gui: string;
}
export interface BuildCacheUsage {
total_bytes: number;
reclaimable_bytes: number;
/** What a `--filter until=168h` prune would reach. */
stale_bytes: number;
/** `"buildx du"` or `"system df"` — `docker system df` under-reports build
* cache, so which one produced the number is worth showing. */
source: string;
cli_error: string | null;
}
/** A per-project volume whose project id is not in Triple-C's project store.
*
* **Not "a volume with no container".** From the daemon's side an idle live
* project and a deleted one look identical — volumes present, no container,
* nothing running — so only the project store can tell them apart. */
export interface OrphanVolume {
name: string;
project_id: string;
bytes: number;
/** `"home"` or `"config"`. */
role: string;
/** When Docker created it. Evidence a user can recognise a project by; a
* size and a UUID identify nothing. From `df()` metadata — volumes are
* never mounted to inspect them, because `docker run -v` *creates* a
* volume that does not exist. */
created_at: string | null;
}
/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */
export interface DiskUsageReport {
scanned_at: string;
projects: ProjectDiskRow[];
base_images: BaseImageRow[];
base_images_bytes: number;
orphan_image_bytes: number;
orphan_image_count: number;
orphan_volumes: OrphanVolume[];
orphan_volume_bytes: number;
/** Why orphan detection was suppressed, when it was. */
orphan_volumes_unavailable: string | null;
build_cache: BuildCacheUsage;
images_total_bytes: number;
containers_total_bytes: number;
volumes_total_bytes: number;
triple_c_total_bytes: number;
host: HostStorage;
}
/** Mirrors Rust `Safety` (serde snake_case). */
export type ReclaimSafety = "safe" | "semi_safe";
/** Mirrors Rust `ReclaimTarget`, an internally tagged enum.
*
* This type **cannot express a destructive action** — that is
* `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one.
* The separation is structural on both sides on purpose. */
export type ReclaimTarget =
| { kind: "dangling_snapshots" }
| { kind: "superseded_base_images" }
| { kind: "build_cache"; all: boolean }
| { kind: "migration_pins" }
| { kind: "migration_staging" }
| { kind: "probe_containers" }
| { kind: "scrub_containers" }
| { kind: "orphan_volume"; name: string }
| { kind: "compact_snapshot"; project_id: string }
| { kind: "clear_caches"; project_id: string; include_rustup: boolean };
/** Mirrors Rust `DestructiveTarget`. Every one of these deletes something with
* no other copy, and needs the project's name typed to confirm. */
export type DestructiveTarget =
| { kind: "home_volume"; project_id: string }
| { kind: "config_volume"; project_id: string }
| { kind: "snapshot_image"; project_id: string }
| { kind: "rollback_pin"; project_id: string; tag: string };
export interface ReclaimItem {
target: ReclaimTarget;
safety: ReclaimSafety;
/** Reaches beyond Triple-C's own objects — true only for the build cache,
* and the UI must say so. */
daemon_wide: boolean;
label: string;
detail: string;
bytes: number;
/** `false` means `bytes` is a bound, not a measurement. Render it as
* "up to …" — only snapshot compaction sets this. */
bytes_are_exact: boolean;
bytes_floor: number | null;
/** Why this cannot run right now. */
blocked: string | null;
}
export interface DestructiveItem {
target: DestructiveTarget;
project_id: string;
project_name: string;
label: string;
/** Spelled out in full — this is the confirmation copy. */
loses: string;
bytes: number;
blocked: string | null;
}
export interface ReclaimPlan {
items: ReclaimItem[];
/** Display only. `reclaim` cannot act on these. */
destructive: DestructiveItem[];
store_error: string | null;
}
export interface ReclaimResult {
/** The reclaim target this reports on, or `null` when it reports a destroy.
* Exactly one of `target` / `destroyed` is ever set — a destroy used to come
* back wearing a `ReclaimTarget` that named work it had not done. */
target: ReclaimTarget | null;
destroyed: DestructiveTarget | null;
ok: boolean;
freed_bytes: number;
/** What was projected beforehand, for the one action that projects. */
projected_bytes: number | null;
message: string;
}
export interface ReclaimOutcome {
results: ReclaimResult[];
total_freed_bytes: number;
}
/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of
* `[reference, error]` pairs — a Rust tuple serialises as an array. */
export interface SnapshotSweepReport {
removed: string[];
reclaimed_bytes: number;
/** Refused because a container is still built from them. Normal. */
in_use: number;
failed: [string, string][];
unavailable: string | null;
}