Merge branch 'feat/disk-ui' into integration/round-1
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user