Hold back the Disk panel and OS drag-out from the ship branch
This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.
Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.
Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.
Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.
Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
pinned the compaction Dockerfile against `snapshot_scrub_script()`.
Dropped — it existed only for compaction. `snapshot_scrub_script` and
its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
`any_held_excluding`, `migration_commands::is_migrating`, and
`formatBytes{Delta,Ceiling}` lose their last production caller but are
kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
were read only by the disk survey and are removed. The corrupt-load
marker and `.bak` are still written.
Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -1,250 +0,0 @@
|
||||
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.";
|
||||
|
||||
/** Why a layer count reads "unknown" rather than as a number. */
|
||||
const layersUnknownHelp = (layers: number) =>
|
||||
`${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.`;
|
||||
|
||||
/** `—` 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) : "—";
|
||||
}
|
||||
|
||||
const SNAPSHOT_HELP =
|
||||
"This project's share of its snapshot image — the bytes no other image carries. The base image is shared by every project, so charging it to each row would show the same 4.7 GB eight times over. It is the figure the Total is built from.";
|
||||
|
||||
/** Why a snapshot figure is the whole image rather than a share of one. */
|
||||
const SPLIT_UNKNOWN_HELP =
|
||||
"Nothing measurably shares layers with this snapshot, and the base image it descends from is no longer on the daemon, so there is no split to show and none is guessed. This is the whole image, which is what it actually costs — a compacted snapshot is exactly this shape.";
|
||||
|
||||
/**
|
||||
* How `snapshot_attributed_bytes` was arrived at, in the row's own terms.
|
||||
*
|
||||
* Rust computes the number in one function so the column and the Total cannot
|
||||
* be derived from two different rules again — but the branches do not mean the
|
||||
* same thing to a reader, so the sub-line has to say which one this row is.
|
||||
* `snapshot_above_base_bytes` is `null` in exactly the branch where the figure
|
||||
* *is* the whole image, which is what makes it the test.
|
||||
*/
|
||||
function attributionNote(row: ProjectDiskRow): { note: string; help: string | null } {
|
||||
if (row.snapshot_above_base_bytes !== null) {
|
||||
return { note: `${formatBytes(row.snapshot_bytes)} with base`, help: null };
|
||||
}
|
||||
return { note: "whole image — base unknown", help: SPLIT_UNKNOWN_HELP };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 whitespace-nowrap">
|
||||
Snapshot
|
||||
<Tooltip text={SNAPSHOT_HELP} />
|
||||
<span className="sr-only"> — {SNAPSHOT_HELP}</span>
|
||||
</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">
|
||||
{/* `snapshot_attributed_bytes`, and nothing else. This column
|
||||
used to render `snapshot_above_base_bytes` and fall back
|
||||
to `—` while the Total was computed from
|
||||
`snapshot_bytes - snapshot_shared_bytes` regardless — so a
|
||||
row could show `—` here and still carry a whole 4.7 GB
|
||||
base image in its Total, once per project. One field, one
|
||||
rule, computed once in Rust: the parts add up. */}
|
||||
{row.snapshot_exists ? formatBytes(row.snapshot_attributed_bytes) : "—"}
|
||||
{row.snapshot_exists && (() => {
|
||||
const { note, help } = attributionNote(row);
|
||||
return help === null ? (
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
{note}
|
||||
</span>
|
||||
) : (
|
||||
// Same treatment as the Layers column: `Tooltip` portals
|
||||
// a plain div with no `role` and no `aria-describedby`,
|
||||
// so the explanation is also emitted as screen-reader
|
||||
// text rather than living in the tooltip alone.
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
<Tooltip text={help}>
|
||||
<span>{note}</span>
|
||||
</Tooltip>
|
||||
<span className="sr-only"> — {help}</span>
|
||||
</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.
|
||||
//
|
||||
// The explanation is the only thing standing between
|
||||
// "unknown" and reading as a bug, so it cannot live in the
|
||||
// tooltip alone: `Tooltip` portals a plain div with no
|
||||
// `role` and no `aria-describedby`, and wrapped around
|
||||
// children it has no focus handlers either — so on hover-
|
||||
// less input it is unreachable and to a screen reader it
|
||||
// does not exist. Same treatment as the column headers
|
||||
// above: tooltip for the mouse, `sr-only` text for
|
||||
// everything else.
|
||||
<>
|
||||
<Tooltip text={layersUnknownHelp(row.snapshot_commit_layers)}>
|
||||
<span className="text-[var(--text-secondary)]">unknown</span>
|
||||
</Tooltip>
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
— {layersUnknownHelp(row.snapshot_commit_layers)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,855 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/** The same, for a destructive object — never ticked, but still listed. */
|
||||
function destructiveKey(item: DestructiveItem): string {
|
||||
return JSON.stringify(item.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* An orphaned volume is confirmed against **its own name**, not a project's.
|
||||
*
|
||||
* There is no project to name: the whole definition of the variant is that its
|
||||
* id matches nothing in the store, and `disk.rs`'s `destroy` takes the orphan
|
||||
* arm before it ever looks a project up. `DestructiveItem.project_name` carries
|
||||
* the volume name for exactly these items, which is what the gate compares.
|
||||
*/
|
||||
function isOrphanVolume(item: DestructiveItem): boolean {
|
||||
return item.target.kind === "orphan_volume";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) gets one list of
|
||||
* ticks and one button, because none of it can lose anything a user has.
|
||||
* 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, **and an orphaned volume** — is not in either
|
||||
* list: it is reached one object at a time, behind a typed confirmation, and
|
||||
* the backend refuses it in bulk by taking a different type entirely.
|
||||
*
|
||||
* ## Why orphaned volumes are down there and not in the tick list
|
||||
*
|
||||
* They used to be a `ReclaimTarget` at `Safety::Safe`: a tick and the group
|
||||
* Reclaim button, no confirmation. The object behind that tick is a
|
||||
* `triple-c-claude-config-*` volume holding a Claude OAuth credential, every
|
||||
* plugin and skill installed into that project, and every conversation
|
||||
* transcript it ever had — and the *same volume* for a project still in the
|
||||
* store required typing the project's name. The only difference between the two
|
||||
* is a lookup against `projects.json`, which this app has been wrong about
|
||||
* before: a second instance's project is absent from an in-memory list, a
|
||||
* corrupt store empties it, a restored data directory empties it too. It once
|
||||
* flagged two live projects as orphaned.
|
||||
*
|
||||
* So "no matching project" means one thing only — the id is not in the project
|
||||
* list. It is never inferred from a project being stopped, having no container
|
||||
* or having no image; an idle live project looks identical from the daemon's
|
||||
* side. Each volume is deleted on its own, against its own name typed out.
|
||||
*/
|
||||
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);
|
||||
// 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]);
|
||||
|
||||
// Split before anything renders. The per-project table keys off
|
||||
// `project_id`, and an orphan's id matches no row by definition — so without
|
||||
// this split those items are simply invisible, which is how a variant that
|
||||
// moved from the tick list to the destructive list can vanish from the UI
|
||||
// entirely rather than reappear behind a confirmation.
|
||||
const orphanVolumes = plan?.destructive.filter(isOrphanVolume) ?? [];
|
||||
// A destructive item is rendered inside its project's row, so one whose
|
||||
// project id matches no row would be measured and shown nowhere. That is not
|
||||
// hypothetical: `survey_rollback_pins` walks *images*, not projects, and
|
||||
// deliberately tolerates an absent project by falling back to the raw id as
|
||||
// the display name — so a pin left behind by a deleted project is exactly
|
||||
// this case, and it is the multi-GB kind. Anything unmatched gets its own
|
||||
// section rather than being silently dropped.
|
||||
const rowIds = new Set((report?.projects ?? []).map((r) => r.project_id));
|
||||
const projectDestructive =
|
||||
plan?.destructive.filter((d) => !isOrphanVolume(d) && rowIds.has(d.project_id)) ?? [];
|
||||
const unmatchedDestructive =
|
||||
plan?.destructive.filter((d) => !isOrphanVolume(d) && !rowIds.has(d.project_id)) ?? [];
|
||||
|
||||
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;
|
||||
|
||||
// What the backend said about the parts it refused, rendered **verbatim**.
|
||||
// A refusal arrives inside `Ok` — the command succeeded at declining — so it
|
||||
// never reaches `error`, and the dialog that asked for the work has nothing
|
||||
// else to show. Not a sentence of our own: the backend is the only side that
|
||||
// knows which blocker is actually holding the project, and one written here
|
||||
// would go stale the day that answer improves.
|
||||
const refusalText =
|
||||
outcome?.results
|
||||
.filter((r) => !r.ok)
|
||||
.map((r) => r.message)
|
||||
.join(" ") ?? "";
|
||||
|
||||
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">
|
||||
{/* 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. */}
|
||||
<Button variant="primary" size="md" onClick={scan} disabled={scanning || working}>
|
||||
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
|
||||
</Button>
|
||||
{/* 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. */}
|
||||
<span role="status" aria-live="polite">
|
||||
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||
</span>
|
||||
<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">▲</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={projectDestructive}
|
||||
onDestroy={openDestroying}
|
||||
/>
|
||||
</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{" "}
|
||||
{/* Live information about where the figure came from, not a
|
||||
disabled control — `--text-disabled` is ~4.1:1 and fails AA
|
||||
at this size. */}
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
(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">
|
||||
“Volumes with no matching project” above means only that the
|
||||
volume’s project id is not in your project list — it is{" "}
|
||||
<em>not</em> inferred from a project being stopped or having no image. A project you have not opened in a
|
||||
while has no container and no snapshot either, and that is normal, so
|
||||
nothing here is deleted in a group: each one is listed below on its own,
|
||||
with the date Docker created it, and removing it takes typing that
|
||||
volume’s name.
|
||||
</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’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={() => openConfirming(item)}
|
||||
>
|
||||
Run…
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Destructive leftovers with no project row ------------------- */}
|
||||
{unmatchedDestructive.length > 0 && (
|
||||
<section className="space-y-2" data-testid="disk-unmatched-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Leftovers from projects no longer in Triple-C
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
These belong to a project id that is not in your project list, so there
|
||||
is no row above to show them under. The same caveat as the volumes below
|
||||
applies: “not in your project list” is the only thing this
|
||||
means, and an idle live project is indistinguishable from a deleted one
|
||||
from Docker’s side. Because there is no project name to type, each
|
||||
one is confirmed against its project <em>id</em>.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{unmatchedDestructive.map((item) => (
|
||||
<li
|
||||
key={destructiveKey(item)}
|
||||
className="flex items-start justify-between gap-3"
|
||||
data-testid={`disk-unmatched-${destructiveKey(item)}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[var(--text-primary)] font-mono break-all">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{item.loses}
|
||||
</span>
|
||||
{item.blocked && (
|
||||
<span className="block text-xs text-[var(--text-secondary)]">
|
||||
{item.blocked}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
{formatBytes(item.bytes)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={item.blocked !== null || working}
|
||||
onClick={() => openDestroying(item)}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Orphaned volumes: destructive, one at a time ---------------- */}
|
||||
{orphanVolumes.length > 0 && (
|
||||
<section className="space-y-2" data-testid="disk-orphan-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Volumes with no matching project
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
A volume here is one whose project id is not in your project list. That
|
||||
is <em>all</em> it means — it is <em>not</em> inferred from a
|
||||
project being stopped, having no container or having no image. An idle
|
||||
live project looks exactly the same from Docker’s side, and that
|
||||
inference has already flagged two live projects here once.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
Deleting a{" "}
|
||||
<span className="font-mono">triple-c-claude-config-*</span> volume
|
||||
deletes{" "}
|
||||
<strong className="text-[var(--text-primary)]">
|
||||
the Claude login credential that project signed in with, every plugin
|
||||
and skill installed into it, and every conversation transcript it ever
|
||||
had
|
||||
</strong>
|
||||
. A <span className="font-mono">triple-c-home-*</span> volume holds its
|
||||
dotfiles, shell history and installed toolchains. There is no other copy
|
||||
of either and nothing regenerates, so each one is deleted on its own,
|
||||
against that volume’s name typed out — never as part of a
|
||||
group.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{orphanVolumes.map((item) => (
|
||||
<li
|
||||
key={destructiveKey(item)}
|
||||
className="flex items-start justify-between gap-3"
|
||||
data-testid={`disk-orphan-${item.project_name}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[var(--text-primary)] font-mono break-all">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{item.loses}
|
||||
</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">
|
||||
{formatBytes(item.bytes)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={item.blocked !== null || working}
|
||||
onClick={() => openDestroying(item)}
|
||||
>
|
||||
Delete…
|
||||
</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">
|
||||
{/* 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. */}
|
||||
<StatusIndicator
|
||||
tone={failedCount === 0 ? "ok" : "error"}
|
||||
label={
|
||||
failedCount === 0
|
||||
? `Reclaimed ${formatBytes(outcome.total_freed_bytes)}`
|
||||
: `Reclaimed ${formatBytes(outcome.total_freed_bytes)} — ${failedCount} of ${outcome.results.length} failed`
|
||||
}
|
||||
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 && (
|
||||
<>
|
||||
{" "}
|
||||
{/* The comparison that makes a compaction's yield
|
||||
readable — live information, so not the disabled ink. */}
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
|
||||
{formatBytes(result.freed_bytes)})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Semi-safe confirmation ---------------------------------------- */}
|
||||
{confirming && (
|
||||
<Modal
|
||||
title={confirming.label}
|
||||
onClose={closeConfirming}
|
||||
widthClassName="w-[30rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={closeConfirming}>
|
||||
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 —
|
||||
// and if it fails, the dialog is the only place the user is
|
||||
// still looking, so it stays open and reports it here.
|
||||
// `false` covers both a throw and a refusal that came back
|
||||
// inside `Ok`; either way the work did not happen, so the
|
||||
// dialog stays put and reports it where the user is looking.
|
||||
const ok = await runReclaim([confirming.target]);
|
||||
setActionFailed(!ok);
|
||||
if (ok) setConfirming(null);
|
||||
}}
|
||||
>
|
||||
{working ? "Working…" : "Run it"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
|
||||
{/* The failure lands here rather than only in the panel's error
|
||||
line, which this dialog is covering. */}
|
||||
{actionFailed && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{error ?? (refusalText || "That did not run. Nothing was changed.")}
|
||||
</p>
|
||||
)}
|
||||
<p>{confirming.detail}</p>
|
||||
{confirming.target.kind === "compact_snapshot" && (
|
||||
<>
|
||||
<p>
|
||||
The snapshot is rebuilt into a single layer while the old one is left
|
||||
in place, so a failure at any point leaves this project exactly as it
|
||||
is now.
|
||||
</p>
|
||||
<p>
|
||||
How much comes back depends on how much of those layers a later one
|
||||
already replaced — it could be{" "}
|
||||
{formatBytesCeiling(confirming.bytes)}, and it could be nothing at all.
|
||||
You will be told the real figure when it finishes.
|
||||
</p>
|
||||
<p>
|
||||
One thing worth knowing: the rewritten image no longer shares the base
|
||||
image with your other projects, so it carries its own copy of it. That
|
||||
cost is already subtracted from the figure above, and if the rewrite
|
||||
turns out not to come out ahead it is thrown away and the snapshot is
|
||||
left exactly as it is.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{confirming.target.kind === "clear_caches" &&
|
||||
confirming.target.include_rustup && (
|
||||
<p>
|
||||
Rust toolchains are included in this one. They are regenerable, but
|
||||
getting them back is a download rather than a rebuild.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* --- Destructive confirmation --------------------------------------- */}
|
||||
{destroying && (() => {
|
||||
// An orphaned volume has no project, so nothing about this dialog can
|
||||
// be phrased in terms of one: the gate takes the volume's own name (as
|
||||
// `disk.rs`'s `destroy` does), and the name is never lower-cased on its
|
||||
// way to the title, because the comparison the backend makes is
|
||||
// case-sensitive and a mangled name in the heading is a name the user
|
||||
// cannot type.
|
||||
const orphan = isOrphanVolume(destroying);
|
||||
// A leftover whose project is gone has no name either. `project_name`
|
||||
// is the raw id in that case — which is deliberate on the Rust side and
|
||||
// is exactly what `destroy` compares against — so the gate works, but
|
||||
// the label has to say "id" or it asks for something that does not
|
||||
// exist.
|
||||
const ownerless = !orphan && !rowIds.has(destroying.project_id);
|
||||
return (
|
||||
<TypedConfirmModal
|
||||
title={
|
||||
orphan
|
||||
? `Delete volume ${destroying.project_name}`
|
||||
: `Delete ${destroying.label.toLowerCase()}`
|
||||
}
|
||||
expected={destroying.project_name}
|
||||
subject={orphan ? "volume name" : ownerless ? "project id" : "project name"}
|
||||
confirmLabel={orphan ? "Delete volume" : `Delete ${destroying.label.toLowerCase()}`}
|
||||
busy={working}
|
||||
// A failure here has to land inside the dialog. The panel's own
|
||||
// error line is at the top of several screens of scroll, and this
|
||||
// dialog was reached from a project row far below it.
|
||||
error={
|
||||
actionFailed
|
||||
? (error ?? (refusalText || "That did not run. Nothing was deleted."))
|
||||
: null
|
||||
}
|
||||
onCancel={closeDestroying}
|
||||
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.
|
||||
const ok = await destroy(destroying.target, typed);
|
||||
setActionFailed(!ok);
|
||||
if (ok) setDestroying(null);
|
||||
}}
|
||||
>
|
||||
{orphan ? (
|
||||
<p>
|
||||
This removes the volume{" "}
|
||||
<strong className="text-[var(--text-primary)] font-mono break-all">
|
||||
{destroying.project_name}
|
||||
</strong>
|
||||
, freeing {formatBytes(destroying.bytes)}. It is offered here for one
|
||||
reason only: no project in your list has its id. That is a lookup against
|
||||
a file, not a judgement about whether anything is using the volume.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
This removes{" "}
|
||||
<strong className="text-[var(--text-primary)]">
|
||||
{destroying.project_name}
|
||||
</strong>
|
||||
’s {destroying.label.toLowerCase()}, freeing{" "}
|
||||
{formatBytes(destroying.bytes)}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[var(--error)]">{destroying.loses}</p>
|
||||
{orphan && (
|
||||
<p>
|
||||
Nothing here can undo this. If you recognise that project id, close this
|
||||
and leave the volume alone until you are certain.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Your mounted project folders live on the host and are not affected by this.
|
||||
</p>
|
||||
</TypedConfirmModal>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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();
|
||||
@@ -174,10 +173,6 @@ export default function SettingsPanel() {
|
||||
<DockerSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="disk" title="Disk" defaultOpen={false}>
|
||||
<DiskSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
|
||||
<CertificateSettings />
|
||||
</AccordionSection>
|
||||
|
||||
Reference in New Issue
Block a user