diff --git a/app/(dashboard)/pool-decommission/page.tsx b/app/(dashboard)/pool-decommission/page.tsx index 8cb7486..c4fbb51 100644 --- a/app/(dashboard)/pool-decommission/page.tsx +++ b/app/(dashboard)/pool-decommission/page.tsx @@ -184,6 +184,7 @@ export default function PoolDecommissionPage() { const [submitting, setSubmitting] = useState(false) const [dataReady, setDataReady] = useState(false) const [error, setError] = useState(null) + const [rebalanceError, setRebalanceError] = useState(null) const [selectedPoolId, setSelectedPoolId] = useState("") const [overview, setOverview] = useState({ pools: [] as PoolSummary[], @@ -216,19 +217,30 @@ export default function PoolDecommissionPage() { if (showSpinner) setLoading(true) else setRefreshing(true) setError(null) + setRebalanceError(null) try { - const [nextOverview, rebalanceStatus, decommissionStatuses] = await Promise.all([ + const [overviewResult, rebalanceResult, decommissionResult] = await Promise.allSettled([ getPoolsOverview(), getRebalanceStatus(), getDecommissionStatuses(), ]) if (!mountedRef.current || requestId !== requestVersionRef.current) return false - const nextRebalanceState = deriveRebalanceDisplayState(rebalanceStatus, nextOverview.supportState) + + 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) setStatuses(Object.fromEntries(statusEntries) as Record) - setRebalanceState(nextRebalanceState) + if (rebalanceResult.status === "fulfilled") { + setRebalanceState(deriveRebalanceDisplayState(rebalanceResult.value, nextOverview.supportState)) + } else { + setRebalanceState("unknown") + setRebalanceError(rebalanceResult.reason instanceof Error ? rebalanceResult.reason.message : t("Load Failed")) + } setSelectedPoolId((current) => current && nextOverview.pools.some((pool) => pool.id === current) ? current @@ -283,7 +295,8 @@ export default function PoolDecommissionPage() { const selectionLocked = isTaskLocked(activeTask) const activePoolCount = poolRows.filter((row) => isActivePool(row.pool)).length const trackedProgress = trackedTask ? getProgressPercent(trackedTask.status, trackedTask.pool) : 0 - const interactionLocked = loading || refreshing || submitting || Boolean(error) || !dataReady + const interactionLocked = + loading || refreshing || submitting || Boolean(error) || Boolean(rebalanceError) || !dataReady useEffect(() => { shouldPollRef.current = dataReady && poolRows.some((row) => shouldPoll(row.displayState)) @@ -515,6 +528,21 @@ export default function PoolDecommissionPage() { ) : null} + {rebalanceError ? ( + + + {t("Current Rebalance Status")}: {t("Unavailable")} + + + {rebalanceError} + + + + ) : null} + {initialLoading ? (
@@ -633,7 +661,7 @@ export default function PoolDecommissionPage() {

- {!dataReady || error ? t("Unknown") : selectionLocked ? t("Running") : t("Ready")} + {!dataReady || error || rebalanceError ? t("Unknown") : selectionLocked ? t("Running") : t("Ready")} diff --git a/eslint.config.js b/eslint.config.js index 28778f5..b55b4f6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ const eslintConfig = defineConfig([ ".nuxt/**", "out/**", "build/**", + ".worktrees/**", "next-env.d.ts", ]), { diff --git a/hooks/use-pool-operations.ts b/hooks/use-pool-operations.ts index 11d8ba9..d7295fd 100644 --- a/hooks/use-pool-operations.ts +++ b/hooks/use-pool-operations.ts @@ -5,6 +5,7 @@ import { useApi } from "@/contexts/api-context" import { deriveDecommissionDisplayState, deriveRebalanceDisplayState, + isRebalanceNotStartedError, normalizeDecommissionInfo, normalizeDecommissionStatus, normalizePoolsOverview, @@ -38,8 +39,15 @@ export function usePoolOperations() { }, [api]) const getRebalanceStatus = useCallback(async () => { - const response = await api.get("/rebalance/status") - return normalizeRebalanceStatus(response) + try { + const response = await api.get("/rebalance/status") + return normalizeRebalanceStatus(response) + } catch (error) { + if (isRebalanceNotStartedError(error)) { + return normalizeRebalanceStatus({ status: "idle" }) + } + throw error + } }, [api]) const startRebalance = useCallback(async () => { diff --git a/lib/pool-operations.ts b/lib/pool-operations.ts index c3aced9..3b722cb 100644 --- a/lib/pool-operations.ts +++ b/lib/pool-operations.ts @@ -473,6 +473,10 @@ function normalizeState(value: string): string { return value.trim().toLowerCase() } +export function isRebalanceNotStartedError(error: unknown): boolean { + return error instanceof Error && normalizeState(error.message) === "pool rebalance is not started" +} + function deriveDecommissionStatus(info: JsonRecord): string { if (asBoolean(info.complete || info.Complete)) return "complete" if (asBoolean(info.failed || info.Failed)) return "failed" diff --git a/tests/lib/pool-operations-safety.test.js b/tests/lib/pool-operations-safety.test.js index f489c76..a4d690e 100644 --- a/tests/lib/pool-operations-safety.test.js +++ b/tests/lib/pool-operations-safety.test.js @@ -16,6 +16,20 @@ test("pool status reads fail closed instead of becoming idle or ready", () => { assert.match(decommission, /const \[dataReady, setDataReady\] = useState\(false\)/) assert.match(rebalance, /const interactionLocked =/) assert.match(decommission, /const interactionLocked =/) + assert.match(hook, /isRebalanceNotStartedError\(error\)/) + assert.match(hook, /normalizeRebalanceStatus\(\{ status: "idle" \}\)/) +}) + +test("decommission keeps pool data visible when only rebalance status fails", () => { + const source = read("app/(dashboard)/pool-decommission/page.tsx") + + assert.match(source, /Promise\.allSettled\(/) + assert.match(source, /const \[rebalanceError, setRebalanceError\] = useState\(null\)/) + assert.match(source, /setRebalanceState\("unknown"\)/) + assert.match(source, /setDataReady\(true\)/) + assert.match(source, /Boolean\(rebalanceError\)/) + assert.match(source, /\{rebalanceError \? \(/) + assert.match(source, /t\("Unavailable"\)/) }) test("pool operation requests reject stale responses and mutations use synchronous locks", () => { diff --git a/tests/lib/pool-operations.test.js b/tests/lib/pool-operations.test.js index 98a8b36..193136d 100644 --- a/tests/lib/pool-operations.test.js +++ b/tests/lib/pool-operations.test.js @@ -3,11 +3,19 @@ import assert from "node:assert/strict" import { deriveDecommissionDisplayState, deriveRebalanceDisplayState, + isRebalanceNotStartedError, normalizeDecommissionInfo, normalizePoolsOverview, normalizeRebalanceStatus, } from "../../lib/pool-operations.ts" +test("isRebalanceNotStartedError recognizes only the expected idle response", () => { + assert.equal(isRebalanceNotStartedError(new Error("pool rebalance is not started")), true) + assert.equal(isRebalanceNotStartedError(new Error(" Pool Rebalance Is Not Started ")), true) + assert.equal(isRebalanceNotStartedError(new Error("failed to read rebalance status")), false) + assert.equal(isRebalanceNotStartedError("pool rebalance is not started"), false) +}) + test("normalizePoolsOverview computes support and capacities", () => { const overview = normalizePoolsOverview({ pools: [