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\)/)