Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions app/(dashboard)/pool-decommission/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -661,7 +661,13 @@ export default function PoolDecommissionPage() {
</p>
</div>
<Badge variant={selectionLocked ? "outline" : "secondary"}>
{!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")}
</Badge>
</CardHeader>
<CardContent className="space-y-5">
Expand Down
27 changes: 27 additions & 0 deletions lib/pool-decommission-load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { DecommissionInfo, PoolsOverview, RebalanceStatus } from "@/lib/pool-operations"

interface PoolDecommissionLoaders {
getPoolsOverview: () => Promise<PoolsOverview>
getRebalanceStatus: () => Promise<RebalanceStatus | null>
getDecommissionStatuses: () => Promise<DecommissionInfo[]>
}

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,
}
}
98 changes: 98 additions & 0 deletions tests/lib/pool-decommission-load.test.ts
Original file line number Diff line number Diff line change
@@ -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]*?<AlertTitle>\{t\("Load Failed"\)\}<\/AlertTitle>/)
assert.match(
source,
/\{dataReady && overview\.supportState === "unsupported" \? \([\s\S]*?<AlertTitle>\{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"\)/)
})
4 changes: 3 additions & 1 deletion tests/lib/pool-operations-safety.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string \| null>\(null\)/)
assert.match(source, /setRebalanceState\("unknown"\)/)
assert.match(source, /setDataReady\(true\)/)
Expand Down