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>(new Set()); const [confirming, setConfirming] = useState(null); const [destroying, setDestroying] = useState(null); // A dialog whose action failed stays open and says so *inside itself*. The // hook's `error` is rendered at the top of a panel that is metres of scroll // long, so a user who reached a project row through the table would have // watched the dialog vanish and seen nothing take its place. This flag is // what distinguishes "this dialog's action just failed" from a stale scan // error that happened to still be sitting in `error` when it opened. const [actionFailed, setActionFailed] = useState(false); // 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); // Opening or closing either dialog clears the in-dialog failure with it, so // one never starts out showing the previous attempt's error. const openConfirming = (item: ReclaimItem) => { setConfirming(item); setActionFailed(false); }; const openDestroying = (item: DestructiveItem) => { setDestroying(item); setActionFailed(false); }; const closeConfirming = () => { setConfirming(null); setActionFailed(false); }; const closeDestroying = () => { setDestroying(null); setActionFailed(false); }; 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; }); }; // Counted from the per-result list rather than from a flag: a reclaim of // five targets can come back with two failures and a real byte total. const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0; const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off"; const statusLabel = scanning ? "Scanning" : report ? `Scanned ${new Date(report.scanned_at).toLocaleTimeString()}` : "Not scanned"; return (
{/* --- Why this section exists ------------------------------------- */}

Every time a container is recreated, Triple-C commits it — and a commit{" "} stacks a new layer 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.

{/* --- Scan --------------------------------------------------------- */}
{/* Disabled while a mutation runs, not only while scanning: a scan started on top of a reclaim measures a daemon that is being changed underneath it, and the hook can only discard such a result — better not to spend the seconds. */} {/* The status flips between "Scanning", "Scanned HH:MM:SS" and "Not scanned" with no other signal. The live region is mounted here unconditionally — wrapping it around the indicator only once there is something to say would make the region *appear* already populated, which is the one shape assistive tech does not announce. */} Reads the whole Docker store; takes a few seconds on a large one.
{error && (

{error}

)} {!report && !scanning && (

Nothing has been measured yet. Scanning is the only thing here that costs anything, so it is never done for you.

)} {report && ( <> {/* --- Windows / WSL2, mandatory when it applies ----------------- */} {report.host.vhdx_applies && (
{/* `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. */}

Warning: reclaiming here will not shrink your C: drive

{report.host.vhdx_note}

To actually give the space back to C:, run these in PowerShell as administrator after reclaiming:

                {report.host.vhdx_fix.join("\n")}
              

Or, without Hyper-V: {report.host.vhdx_fix_gui}.

)} {/* --- Per-project table ---------------------------------------- */}

By project

{/* --- Globals --------------------------------------------------- */}

Shared and left over

Base images ({report.base_images.length}) — shared by every project
{formatBytes(report.base_images_bytes)}
Superseded images from past recreations ({report.orphan_image_count})
{formatBytes(report.orphan_image_bytes)}
Volumes with no matching project in Triple-C ( {report.orphan_volumes.length})
{formatBytes(report.orphan_volume_bytes)}
Build cache — whole daemon, not just Triple-C{" "} {/* Live information about where the figure came from, not a disabled control — `--text-disabled` is ~4.1:1 and fails AA at this size. */} (via {report.build_cache.source})
{formatBytes(report.build_cache.reclaimable_bytes)} of{" "} {formatBytes(report.build_cache.total_bytes)}
Attributable to Triple-C
{formatBytes(report.triple_c_total_bytes)}
Everything on this daemon, yours included
{formatBytes( report.images_total_bytes + report.containers_total_bytes + report.volumes_total_bytes, )}
{report.build_cache.cli_error && (

{/* 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 docker system df, which under-reports what a prune would free: {report.build_cache.cli_error}

)} {report.orphan_volumes.length > 0 && (

“Volumes with no matching project” above means only that the volume’s project id is not in your project list — it is{" "} not 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.

)}

Docker stores this at{" "} {report.host.docker_root_dir || "an unknown path"} {report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}.

{/* --- Store failure, if any ------------------------------------ */} {report.orphan_volumes_unavailable && (

{report.orphan_volumes_unavailable}

)} {/* --- The plan was dropped by a reclaim -------------------------- */} {!plan && (

The totals above were measured before that last action. Scan again to see what is left to reclaim.

)} {/* --- Safe reclaim ---------------------------------------------- */} {plan && (

Safe to reclaim

{safeItems.length === 0 ? (

Nothing here — no leftovers were found.

) : ( <>

None of this is reachable any more, or all of it regenerates on demand. Nothing you have made is in this list.

    {safeItems.map((item) => { const key = targetKey(item.target); return (
  • ); })}
{selected.length === 0 ? "Nothing ticked." : `${selected.length} selected, ${formatBytes(selectedBytes)}.`}
)}
)} {/* --- Semi-safe -------------------------------------------------- */} {semiItems.length > 0 && (

Worth doing, one at a time

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.

    {semiItems.map((item) => (
  • {item.label} {item.detail} {item.blocked && ( {item.blocked} )} {/* 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)}
  • ))}
)} {/* --- Sweep ------------------------------------------------------ */}
The same sweep that runs at startup and after every recreation. Unlike the tick above it also reports what it refused to remove, which is how a superseded image pinned by a stopped project shows itself.
)} {/* --- Outcome ------------------------------------------------------- */} {outcome && (
{/* The headline has to carry the failure in words. A partial reclaim that freed something still has a byte figure worth printing, so the count is appended to it rather than replacing it — and the per-result lines below say *which* ones and why, so this stops at how many. */}
    {outcome.results.map((result, index) => (
  • {result.message} {result.projected_bytes !== null && ( <> {" "} {/* The comparison that makes a compaction's yield readable — live information, so not the disabled ink. */} (projected {formatBytesCeiling(result.projected_bytes)}, actually{" "} {formatBytes(result.freed_bytes)}) )}
  • ))}
)} {/* --- Semi-safe confirmation ---------------------------------------- */} {confirming && ( } >
{/* The failure lands here rather than only in the panel's error line, which this dialog is covering. */} {actionFailed && (

{error ?? "That did not run. Nothing was changed."}

)}

{confirming.detail}

{confirming.target.kind === "compact_snapshot" && ( <>

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.

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.

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.

)} {confirming.target.kind === "clear_caches" && confirming.target.include_rustup && (

Rust toolchains are included in this one. They are regenerable, but getting them back is a download rather than a rebuild.

)}
)} {/* --- Destructive confirmation --------------------------------------- */} {destroying && ( { // 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. const ok = await destroy(destroying.target, typed); setActionFailed(!ok); if (ok) setDestroying(null); }} >

This removes{" "} {destroying.project_name} ’s {destroying.label.toLowerCase()}, freeing{" "} {formatBytes(destroying.bytes)}.

{destroying.loses}

Your mounted project folders live on the host and are not affected by this.

)}
); }