diff --git a/app/(dashboard)/pool-decommission/page.tsx b/app/(dashboard)/pool-decommission/page.tsx index c4fbb51c..a6f89394 100644 --- a/app/(dashboard)/pool-decommission/page.tsx +++ b/app/(dashboard)/pool-decommission/page.tsx @@ -17,6 +17,7 @@ import { usePoolOperations } from "@/hooks/use-pool-operations" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions" +import { loadPoolDecommissionData } from "@/lib/pool-decommission-load" import { deriveDecommissionDisplayState, deriveRebalanceDisplayState, @@ -219,18 +220,17 @@ export default function PoolDecommissionPage() { setError(null) setRebalanceError(null) try { - const [overviewResult, rebalanceResult, decommissionResult] = await Promise.allSettled([ - getPoolsOverview(), - getRebalanceStatus(), - getDecommissionStatuses(), - ]) + const { + overview: nextOverview, + rebalanceResult, + decommissionStatuses, + } = await loadPoolDecommissionData({ + getPoolsOverview, + getRebalanceStatus, + getDecommissionStatuses, + }) if (!mountedRef.current || requestId !== requestVersionRef.current) return false - if (overviewResult.status === "rejected") throw overviewResult.reason - if (decommissionResult.status === "rejected") throw decommissionResult.reason - - const nextOverview = overviewResult.value - const decommissionStatuses = decommissionResult.value const statusEntries = decommissionStatuses.map((status) => [status.poolId, status]) setOverview(nextOverview) @@ -661,7 +661,13 @@ export default function PoolDecommissionPage() {

- {!dataReady || error || rebalanceError ? t("Unknown") : selectionLocked ? t("Running") : t("Ready")} + {!dataReady || error || rebalanceError + ? t("Unknown") + : overview.supportState === "unsupported" + ? t("Unsupported") + : selectionLocked + ? t("Running") + : t("Ready")} diff --git a/lib/pool-decommission-load.ts b/lib/pool-decommission-load.ts new file mode 100644 index 00000000..c590abc2 --- /dev/null +++ b/lib/pool-decommission-load.ts @@ -0,0 +1,27 @@ +import type { DecommissionInfo, PoolsOverview, RebalanceStatus } from "@/lib/pool-operations" + +interface PoolDecommissionLoaders { + getPoolsOverview: () => Promise + getRebalanceStatus: () => Promise + getDecommissionStatuses: () => Promise +} + +export async function loadPoolDecommissionData({ + getPoolsOverview, + getRebalanceStatus, + getDecommissionStatuses, +}: PoolDecommissionLoaders) { + const overview = await getPoolsOverview() + const [rebalanceResult, decommissionResult] = await Promise.allSettled([ + getRebalanceStatus(), + overview.supportState === "supported" ? getDecommissionStatuses() : Promise.resolve([]), + ]) + + if (decommissionResult.status === "rejected") throw decommissionResult.reason + + return { + overview, + rebalanceResult, + decommissionStatuses: decommissionResult.value, + } +} diff --git a/tests/lib/pool-decommission-load.test.ts b/tests/lib/pool-decommission-load.test.ts new file mode 100644 index 00000000..f4b78eea --- /dev/null +++ b/tests/lib/pool-decommission-load.test.ts @@ -0,0 +1,98 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" +import { loadPoolDecommissionData } from "../../lib/pool-decommission-load" +import { normalizePoolsOverview } from "../../lib/pool-operations" + +const singlePoolOverview = normalizePoolsOverview({ pools: [{ id: 1, status: "active" }] }) +const multiPoolOverview = normalizePoolsOverview({ + pools: [ + { id: 1, status: "active" }, + { id: 2, status: "active" }, + ], +}) + +test("unsupported single-pool topology skips decommission status and loads as unsupported", async () => { + let statusRequests = 0 + + const result = await loadPoolDecommissionData({ + getPoolsOverview: async () => singlePoolOverview, + getRebalanceStatus: async () => null, + getDecommissionStatuses: async () => { + statusRequests += 1 + throw new Error("501 Not Implemented") + }, + }) + + assert.equal(statusRequests, 0) + assert.equal(result.overview.supportState, "unsupported") + assert.deepEqual(result.decommissionStatuses, []) +}) + +test("supported multi-pool topology requests decommission status", async () => { + let statusRequests = 0 + + await loadPoolDecommissionData({ + getPoolsOverview: async () => multiPoolOverview, + getRebalanceStatus: async () => null, + getDecommissionStatuses: async () => { + statusRequests += 1 + return [] + }, + }) + + assert.equal(statusRequests, 1) +}) + +test("overview failure rejects before dependent status reads", async () => { + let rebalanceRequests = 0 + let statusRequests = 0 + + await assert.rejects( + loadPoolDecommissionData({ + getPoolsOverview: async () => { + throw new Error("overview unavailable") + }, + getRebalanceStatus: async () => { + rebalanceRequests += 1 + return null + }, + getDecommissionStatuses: async () => { + statusRequests += 1 + return [] + }, + }), + /overview unavailable/, + ) + assert.equal(rebalanceRequests, 0) + assert.equal(statusRequests, 0) +}) + +test("supported topology rejects a decommission status failure", async () => { + await assert.rejects( + loadPoolDecommissionData({ + getPoolsOverview: async () => multiPoolOverview, + getRebalanceStatus: async () => null, + getDecommissionStatuses: async () => { + throw new Error("status unavailable") + }, + }), + /status unavailable/, + ) +}) + +test("page source keeps unsupported data separate from locked load failures", () => { + const source = fs.readFileSync("app/(dashboard)/pool-decommission/page.tsx", "utf8") + + assert.match(source, /\{error \? \([\s\S]*?\{t\("Load Failed"\)\}<\/AlertTitle>/) + assert.match( + source, + /\{dataReady && overview\.supportState === "unsupported" \? \([\s\S]*?\{t\("Single pool decommission is not supported"\)\}<\/AlertTitle>/, + ) + assert.match(source, /catch \(loadError\)[\s\S]*?setError\([\s\S]*?setDataReady\(false\)/) + assert.match( + source, + /const interactionLocked =[\s\S]*?Boolean\(error\)[\s\S]*?Boolean\(rebalanceError\)[\s\S]*?!dataReady/, + ) + assert.match(source, /overview\.supportState === "unsupported"[\s\S]*?t\("Unsupported"\)/) +}) diff --git a/tests/lib/pool-operations-safety.test.js b/tests/lib/pool-operations-safety.test.js index a4d690e1..9443f3ad 100644 --- a/tests/lib/pool-operations-safety.test.js +++ b/tests/lib/pool-operations-safety.test.js @@ -22,8 +22,10 @@ test("pool status reads fail closed instead of becoming idle or ready", () => { test("decommission keeps pool data visible when only rebalance status fails", () => { const source = read("app/(dashboard)/pool-decommission/page.tsx") + const loader = read("lib/pool-decommission-load.ts") - assert.match(source, /Promise\.allSettled\(/) + assert.match(source, /loadPoolDecommissionData\(/) + assert.match(loader, /Promise\.allSettled\(/) assert.match(source, /const \[rebalanceError, setRebalanceError\] = useState\(null\)/) assert.match(source, /setRebalanceState\("unknown"\)/) assert.match(source, /setDataReady\(true\)/)