fix: preserve pool status before rebalance starts (#166)

This commit is contained in:
cxymds
2026-07-14 17:51:25 +08:00
committed by GitHub
parent 6dfe2761b2
commit 05c2673f7a
6 changed files with 70 additions and 7 deletions
+33 -5
View File
@@ -184,6 +184,7 @@ export default function PoolDecommissionPage() {
const [submitting, setSubmitting] = useState(false)
const [dataReady, setDataReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rebalanceError, setRebalanceError] = useState<string | null>(null)
const [selectedPoolId, setSelectedPoolId] = useState("")
const [overview, setOverview] = useState<PoolsOverview>({
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<string, DecommissionInfo | null>)
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() {
</Alert>
) : null}
{rebalanceError ? (
<Alert variant="destructive">
<AlertTitle>
{t("Current Rebalance Status")}: {t("Unavailable")}
</AlertTitle>
<AlertDescription className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{rebalanceError}</span>
<Button type="button" variant="outline" size="sm" onClick={() => void loadData()}>
<RiRefreshLine className="size-4" aria-hidden />
{t("Refresh")}
</Button>
</AlertDescription>
</Alert>
) : null}
{initialLoading ? (
<div className="flex min-h-64 items-center justify-center gap-2 text-sm text-muted-foreground" role="status">
<Spinner className="size-5" aria-hidden />
@@ -633,7 +661,7 @@ export default function PoolDecommissionPage() {
</p>
</div>
<Badge variant={selectionLocked ? "outline" : "secondary"}>
{!dataReady || error ? t("Unknown") : selectionLocked ? t("Running") : t("Ready")}
{!dataReady || error || rebalanceError ? t("Unknown") : selectionLocked ? t("Running") : t("Ready")}
</Badge>
</CardHeader>
<CardContent className="space-y-5">
+1
View File
@@ -14,6 +14,7 @@ const eslintConfig = defineConfig([
".nuxt/**",
"out/**",
"build/**",
".worktrees/**",
"next-env.d.ts",
]),
{
+10 -2
View File
@@ -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 () => {
+4
View File
@@ -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"
+14
View File
@@ -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<string \| null>\(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", () => {
+8
View File
@@ -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: [