Fix what review found in the Disk section
Safety: - `destroy`'s rollback-pin arm took a tag over IPC and interpolated it straight into an image reference it then removed. `tag: "latest"` named the project's live snapshot, deleted under a dialog saying "rollback pin". It is the one destructive variant carrying a free-form string, so it now goes through `parse_rollback_tag`. - The compaction's scratch container was named `triple-c-scrub-*`, which is what the scrub reclaim bucket hunts and force-removes. A reclaim from a second window would have destroyed the container a running compaction was about to commit. It gets `triple-c-compact-*`, swept at the start of the next compaction rather than from a bucket anything else can fire. - Deleting a home or config volume only refused a *running* container, but a stopped one still pins its volumes — the resting state of every project ever started — so the user typed the project name and met a raw 409. The container is now removed first and `loses` says so. Correctness: - The compaction Dockerfile emitted no `LABEL`, so the flattened intermediate could never match the sweep's `dangling` + `triple-c.managed` filter that three cleanup paths rely on. Verified on Docker 29.7.2 that the label lands on the final stage, the build still yields one layer, and untagging the staging tag after the commit leaves the committed snapshot intact and startable. - `snapshot_commit_layers` silently meant something else when `triple-c.base-image-id` was absent — the normal case for a pre-label project — counting the base's own layers and letting a never-recreated project qualify for compaction. `base_lineage_known` now carries that, the column says "unknown", and the plan does not offer the rewrite. - `destroy` returned a `ReclaimResult` wearing a `ReclaimTarget` that named work it had not done (a home-volume deletion came back as `OrphanVolume`). Split into `target` / `destroyed`, exactly one set. - `formatBytes` ran `toFixed` after the divide loop, so 999,999 rendered as "1000.0 KB" — in the app's only byte formatter, in a panel full of near-boundary sizes. - `is_base_image_reference` split on the first colon, so a registry port ate the repo name. UI: - `snapshot_above_base_bytes: null` — deliberately unmeasurable — rendered as "0 B", the one guessed number in the table. - Layer count was flagged by colour alone; it now says "stacked". - The tick list survived a reclaim, so the same call could be re-fired at objects that no longer existed. The plan is dropped after any action and the panel says the totals predate it. - `setReport` landed before the plan call was awaited, so a plan failure rendered fresh totals above the previous scan's rows. - Both confirmation modals unmounted before awaiting, making the entire busy path dead code during multi-second work. - `buildx du` failures silently showed `docker system df`'s under-reported build-cache figure with no explanation. - Tooltip text reached no assistive tech, so two headers announced as "Help"; hardcoded input id; error-toned glyph in warning-toned panels; `sweepOrphanedSnapshots` and `clearOutcome` had no callers. - Four docstrings claimed things the code did not do, and two tests were named for behaviour they did not assert. Tests: 513 frontend (was 502), 370 Rust (was 365). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -11,6 +11,12 @@ interface Props {
|
||||
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) : "—";
|
||||
@@ -59,11 +65,18 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||
Layers
|
||||
<Tooltip text="Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted." />
|
||||
{/* `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="The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that." />
|
||||
<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
|
||||
@@ -104,7 +117,12 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
</span>
|
||||
</th>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
||||
{cell(row.snapshot_above_base_bytes ?? 0, row.snapshot_exists)}
|
||||
{/* `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
|
||||
@@ -117,18 +135,28 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums">
|
||||
{row.snapshot_exists ? (
|
||||
<span
|
||||
className={
|
||||
row.snapshot_commit_layers > 5
|
||||
? "text-[var(--warning)]"
|
||||
: "text-[var(--text-primary)]"
|
||||
}
|
||||
>
|
||||
{row.snapshot_commit_layers}
|
||||
</span>
|
||||
) : (
|
||||
{!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">
|
||||
|
||||
@@ -35,6 +35,7 @@ const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
|
||||
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,
|
||||
@@ -170,6 +171,36 @@ describe("DiskSettings", () => {
|
||||
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.
|
||||
@@ -218,6 +249,45 @@ describe("DiskSettings", () => {
|
||||
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");
|
||||
@@ -380,9 +450,7 @@ describe("DiskSettings", () => {
|
||||
);
|
||||
await renderAndScan();
|
||||
const note = await screen.findByTestId("disk-vhdx-note");
|
||||
expect(
|
||||
within(note).getByText("Reclaiming here will not shrink your C: drive"),
|
||||
).toBeInTheDocument();
|
||||
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();
|
||||
@@ -415,7 +483,8 @@ describe("DiskSettings", () => {
|
||||
}),
|
||||
);
|
||||
destroyProjectDiskObject.mockResolvedValue({
|
||||
target: { kind: "orphan_volume", name: "triple-c-claude-config-p-whp" },
|
||||
target: null,
|
||||
destroyed: { kind: "config_volume", project_id: "p-whp" },
|
||||
ok: true,
|
||||
freed_bytes: 427_000_000,
|
||||
projected_bytes: null,
|
||||
@@ -450,6 +519,57 @@ describe("DiskSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -479,6 +599,7 @@ describe("DiskSettings", () => {
|
||||
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,
|
||||
@@ -499,7 +620,7 @@ describe("DiskSettings", () => {
|
||||
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a scan failure rather than showing stale numbers", async () => {
|
||||
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 () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
import Modal from "../ui/Modal";
|
||||
@@ -38,12 +38,29 @@ function targetKey(target: ReclaimTarget): string {
|
||||
* and the backend refuses it in bulk by taking a different type entirely.
|
||||
*/
|
||||
export default function DiskSettings() {
|
||||
const { report, plan, scanning, working, error, outcome, scan, runReclaim, destroy } =
|
||||
useDiskUsage();
|
||||
const {
|
||||
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(
|
||||
@@ -112,11 +129,14 @@ export default function DiskSettings() {
|
||||
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
|
||||
data-testid="disk-vhdx-note"
|
||||
>
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label="Reclaiming here will not shrink your C: drive"
|
||||
className="text-xs"
|
||||
/>
|
||||
{/* `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>
|
||||
@@ -203,11 +223,20 @@ export default function DiskSettings() {
|
||||
)}
|
||||
</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">
|
||||
That last figure means only that the volume’s project id is not in
|
||||
your project list — it is <em>not</em> inferred from a project
|
||||
being stopped or having no image. A project you have not opened in a
|
||||
“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
|
||||
each of these is ticked individually and shows the date Docker created
|
||||
it.
|
||||
@@ -223,7 +252,7 @@ export default function DiskSettings() {
|
||||
{/* --- Store failure, if any ------------------------------------ */}
|
||||
{report.orphan_volumes_unavailable && (
|
||||
<section
|
||||
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
|
||||
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
|
||||
@@ -237,7 +266,16 @@ export default function DiskSettings() {
|
||||
</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
|
||||
@@ -260,7 +298,10 @@ export default function DiskSettings() {
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ticked.has(key)}
|
||||
// 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)]"
|
||||
@@ -317,6 +358,7 @@ export default function DiskSettings() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Semi-safe -------------------------------------------------- */}
|
||||
{semiItems.length > 0 && (
|
||||
@@ -371,16 +413,13 @@ export default function DiskSettings() {
|
||||
|
||||
{/* --- Sweep ------------------------------------------------------ */}
|
||||
<section className="flex items-center gap-3 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={working}
|
||||
onClick={() => runReclaim([{ kind: "dangling_snapshots" }])}
|
||||
>
|
||||
<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 — here you
|
||||
can see what it found.
|
||||
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>
|
||||
</>
|
||||
@@ -394,11 +433,16 @@ export default function DiskSettings() {
|
||||
aria-live="polite"
|
||||
data-testid="disk-outcome"
|
||||
>
|
||||
<StatusIndicator
|
||||
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
|
||||
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
|
||||
className="text-xs"
|
||||
/>
|
||||
<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}>
|
||||
@@ -433,10 +477,11 @@ export default function DiskSettings() {
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={working}
|
||||
onClick={() => {
|
||||
const target = confirming.target;
|
||||
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);
|
||||
void runReclaim([target]);
|
||||
}}
|
||||
>
|
||||
{working ? "Working…" : "Run it"}
|
||||
@@ -487,10 +532,12 @@ export default function DiskSettings() {
|
||||
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
|
||||
busy={working}
|
||||
onCancel={() => setDestroying(null)}
|
||||
onConfirm={(typed) => {
|
||||
const target = destroying.target;
|
||||
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);
|
||||
void destroy(target, typed);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
|
||||
Reference in New Issue
Block a user