fix: correct pool operation console status

This commit is contained in:
马登山
2026-06-14 13:38:52 +08:00
parent 9df43ee72a
commit 0bd097ad26
7 changed files with 168 additions and 39 deletions
+17 -8
View File
@@ -78,6 +78,10 @@ function getPoolStatusBadgeVariant(state: DecommissionDisplayState) {
return "secondary"
}
function hasDecommissionProgress(state: DecommissionDisplayState) {
return ["running", "canceling", "completed", "failed", "canceled"].includes(state)
}
export default function PoolDecommissionPage() {
const { t } = useTranslation()
const message = useMessage()
@@ -167,6 +171,7 @@ export default function PoolDecommissionPage() {
rebalanceState,
confirmingPoolId === activePoolId,
)
const showActiveProgress = Boolean(activeStatus) && hasDecommissionProgress(activeDisplayState)
const canCancelActive = activePoolId
? getPoolDisplayState(activeStatus, overview.supportState, rebalanceState) === "running"
: false
@@ -264,7 +269,7 @@ export default function PoolDecommissionPage() {
</PageHeader>
<div className="space-y-6">
<PoolsOverviewCard overview={overview} operationLabel={t("Pool Decommission")} />
<PoolsOverviewCard overview={overview} operationLabel={t("Pool Decommission")} showDecommissionColumns />
{overview.supportState === "unsupported" ? (
<Alert>
@@ -328,11 +333,15 @@ export default function PoolDecommissionPage() {
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Progress")}</p>
<p className="text-sm font-medium">{Math.round(activeStatus?.progressPercent ?? 0)}%</p>
<p className="text-sm font-medium">
{showActiveProgress ? `${Math.round(activeStatus?.progressPercent ?? 0)}%` : "--"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
<p className="text-sm font-medium">{formatBytesValue(activeStatus?.bytes)}</p>
<p className="text-sm font-medium">
{showActiveProgress ? formatBytesValue(activeStatus?.bytes) : "--"}
</p>
</div>
</div>
@@ -357,7 +366,7 @@ export default function PoolDecommissionPage() {
</TableRow>
) : (
poolRows.map(({ pool, status: rowStatus, displayState: rowState }) => {
const hasDecommissionStarted = Boolean(rowStatus)
const showProgress = Boolean(rowStatus) && hasDecommissionProgress(rowState)
const canRequestStart = !submitting && rowState === "ready"
const canConfirm = !submitting && rowState === "confirming"
const canCancel = !submitting && rowState === "running"
@@ -376,7 +385,7 @@ export default function PoolDecommissionPage() {
</TableCell>
<TableCell>{formatBytesValue(pool.used)}</TableCell>
<TableCell className="min-w-32">
{hasDecommissionStarted ? (
{showProgress ? (
<div className="flex items-center gap-2">
<Progress value={rowStatus?.progressPercent ?? 0} className="h-2 w-20" />
<span className="text-xs text-muted-foreground">
@@ -387,8 +396,8 @@ export default function PoolDecommissionPage() {
"--"
)}
</TableCell>
<TableCell>{hasDecommissionStarted ? (rowStatus?.objects ?? "--") : "--"}</TableCell>
<TableCell>{hasDecommissionStarted ? formatBytesValue(rowStatus?.bytes) : "--"}</TableCell>
<TableCell>{showProgress ? (rowStatus?.objects ?? "--") : "--"}</TableCell>
<TableCell>{showProgress ? formatBytesValue(rowStatus?.bytes) : "--"}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
{rowState === "confirming" ? (
@@ -423,7 +432,7 @@ export default function PoolDecommissionPage() {
<Button
size="sm"
variant="outline"
disabled={!canRequestStart || hasDecommissionStarted}
disabled={!canRequestStart || showProgress}
onClick={(event) => {
event.stopPropagation()
setActivePoolId(pool.id)
+7 -2
View File
@@ -19,6 +19,7 @@ import type { PoolSummary, PoolsOverview, RebalanceDisplayState, RebalanceStatus
import { useDialog } from "@/lib/feedback/dialog"
import { useMessage } from "@/lib/feedback/message"
import { niceBytes } from "@/lib/functions"
import { cn } from "@/lib/utils"
const POLL_MS = 5000
@@ -44,6 +45,10 @@ function formatNumberValue(value?: number) {
return value === undefined ? "--" : String(value)
}
function isFailedRebalancePool(pool: PoolSummary) {
return ["failed", "error"].includes(pool.status.trim().toLowerCase())
}
export default function RebalancePage() {
const { t } = useTranslation()
const dialog = useDialog()
@@ -205,7 +210,7 @@ export default function RebalancePage() {
</PageHeader>
<div className="space-y-6">
<PoolsOverviewCard overview={overview} operationLabel={t("Rebalance")} />
<PoolsOverviewCard overview={overview} operationLabel={t("Rebalance")} showDecommissionColumns={false} />
{overview.supportState === "unsupported" ? (
<Alert>
@@ -285,7 +290,7 @@ export default function RebalancePage() {
</TableRow>
) : (
pools.map((pool) => (
<TableRow key={pool.id}>
<TableRow key={pool.id} className={cn(isFailedRebalancePool(pool) && "bg-destructive/10")}>
<TableCell>{pool.name}</TableCell>
<TableCell>{pool.status || "--"}</TableCell>
<TableCell>{formatBytesValue(pool.used)}</TableCell>
+36 -20
View File
@@ -35,7 +35,15 @@ function formatDateTime(value?: string) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
export function PoolsOverviewCard({ overview, operationLabel }: { overview: PoolsOverview; operationLabel: string }) {
export function PoolsOverviewCard({
overview,
operationLabel,
showDecommissionColumns = false,
}: {
overview: PoolsOverview
operationLabel: string
showDecommissionColumns?: boolean
}) {
const { t } = useTranslation()
const usedPercent = useMemo(() => {
if (!overview.totalCapacity) return 0
@@ -77,21 +85,25 @@ export function PoolsOverviewCard({ overview, operationLabel }: { overview: Pool
<TableHead>{t("Available")}</TableHead>
<TableHead>{t("Usage")}</TableHead>
<TableHead>{t("Updated At")}</TableHead>
<TableHead>{t("Start Time")}</TableHead>
<TableHead>{t("Start Size")}</TableHead>
<TableHead>{t("Complete")}</TableHead>
<TableHead>{t("Failed Status")}</TableHead>
<TableHead>{t("Canceled")}</TableHead>
<TableHead>{t("Objects")}</TableHead>
<TableHead>{t("Objects Failed")}</TableHead>
<TableHead>{t("Bytes Moved")}</TableHead>
<TableHead>{t("Bytes Failed")}</TableHead>
{showDecommissionColumns ? (
<>
<TableHead>{t("Start Time")}</TableHead>
<TableHead>{t("Start Size")}</TableHead>
<TableHead>{t("Complete")}</TableHead>
<TableHead>{t("Failed Status")}</TableHead>
<TableHead>{t("Canceled")}</TableHead>
<TableHead>{t("Objects")}</TableHead>
<TableHead>{t("Objects Failed")}</TableHead>
<TableHead>{t("Bytes Moved")}</TableHead>
<TableHead>{t("Bytes Failed")}</TableHead>
</>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{overview.pools.length === 0 ? (
<TableRow>
<TableCell colSpan={18} className="text-center text-muted-foreground">
<TableCell colSpan={showDecommissionColumns ? 18 : 9} className="text-center text-muted-foreground">
{t("No Data")}
</TableCell>
</TableRow>
@@ -109,15 +121,19 @@ export function PoolsOverviewCard({ overview, operationLabel }: { overview: Pool
<TableCell>{formatBytesValue(pool.available)}</TableCell>
<TableCell>{formatPercentValue(pool.usagePercent)}</TableCell>
<TableCell>{formatDateTime(pool.lastUpdate)}</TableCell>
<TableCell>{formatDateTime(pool.decommission.startTime)}</TableCell>
<TableCell>{formatBytesValue(pool.decommission.startSize)}</TableCell>
<TableCell>{pool.decommission.complete ? t("Yes") : t("No")}</TableCell>
<TableCell>{pool.decommission.failed ? t("Yes") : t("No")}</TableCell>
<TableCell>{pool.decommission.canceled ? t("Yes") : t("No")}</TableCell>
<TableCell>{formatNumberValue(pool.decommission.objects)}</TableCell>
<TableCell>{formatNumberValue(pool.decommission.objectsFailed)}</TableCell>
<TableCell>{formatBytesValue(pool.decommission.bytes)}</TableCell>
<TableCell>{formatBytesValue(pool.decommission.bytesFailed)}</TableCell>
{showDecommissionColumns ? (
<>
<TableCell>{formatDateTime(pool.decommission.startTime)}</TableCell>
<TableCell>{formatBytesValue(pool.decommission.startSize)}</TableCell>
<TableCell>{pool.decommission.complete ? t("Yes") : t("No")}</TableCell>
<TableCell>{pool.decommission.failed ? t("Yes") : t("No")}</TableCell>
<TableCell>{pool.decommission.canceled ? t("Yes") : t("No")}</TableCell>
<TableCell>{formatNumberValue(pool.decommission.objects)}</TableCell>
<TableCell>{formatNumberValue(pool.decommission.objectsFailed)}</TableCell>
<TableCell>{formatBytesValue(pool.decommission.bytes)}</TableCell>
<TableCell>{formatBytesValue(pool.decommission.bytesFailed)}</TableCell>
</>
) : null}
</TableRow>
))
)}
+26 -7
View File
@@ -174,6 +174,26 @@ function aggregatePoolProgress(pools: PoolSummary[]): PoolUsageProgress {
)
}
function hasExplicitProgressPercent(record: JsonRecord): boolean {
return (
record.progressPercent !== undefined ||
record.ProgressPercent !== undefined ||
record.percent !== undefined ||
record.Percent !== undefined
)
}
function deriveRebalanceProgressPercent(status: string, pools: PoolSummary[]): number {
const state = normalizeState(status)
if (["completed", "complete", "success", "finished"].includes(state)) return 100
if (pools.length === 0) return 0
const finishedPools = pools.filter((pool) =>
["completed", "complete", "success", "finished"].includes(normalizeState(pool.status)),
).length
return Math.round((finishedPools / pools.length) * 100)
}
function pickStatus(record: JsonRecord): string {
return (
asString(record.status) ||
@@ -256,15 +276,14 @@ export function normalizeRebalanceStatus(value: unknown): RebalanceStatus {
: []
const pools = poolsSource.map((pool, index) => normalizePool(pool, index))
const explicitTotals = normalizeProgress(record.progress || record.Progress || record.totals || record.Totals)
const totals = hasProgress(explicitTotals) ? explicitTotals : aggregatePoolProgress(pools)
const totalsAreExplicit = hasProgress(explicitTotals)
const totals = totalsAreExplicit ? explicitTotals : aggregatePoolProgress(pools)
const status = pickStatus(record) || deriveStatusFromPools(pools)
const rawProgress =
asNumber(record.progressPercent || record.ProgressPercent || record.percent || record.Percent) ||
(totals.bytes > 0 && pools.length > 0
const rawProgress = hasExplicitProgressPercent(record)
? asNumber(record.progressPercent || record.ProgressPercent || record.percent || record.Percent)
: totalsAreExplicit && totals.bytes > 0 && pools.length > 0
? (pools.reduce((sum, pool) => sum + pool.progress.bytes, 0) / totals.bytes) * 100
: ["completed", "complete", "success", "finished"].includes(normalizeState(status))
? 100
: 0)
: deriveRebalanceProgressPercent(status, pools)
return {
id: asString(record.id) || asString(record.ID),
+7 -2
View File
@@ -18,8 +18,13 @@ test("pool decommission page keeps status panel independent from list clicks and
assert.doesNotMatch(source, /{t\("Rebalance Status"\)}/)
assert.match(source, /onClick=\{\(\) => setSelectedPoolId\(pool\.id\)\}/)
assert.doesNotMatch(source, /onClick=\{\(\) => setActivePoolId\(pool\.id\)\}/)
assert.match(source, /const hasDecommissionStarted = Boolean\(rowStatus\)/)
assert.match(source, /hasDecommissionStarted \?/)
assert.match(source, /function hasDecommissionProgress\(state: DecommissionDisplayState\)/)
assert.match(
source,
/const showActiveProgress = Boolean\(activeStatus\) && hasDecommissionProgress\(activeDisplayState\)/,
)
assert.match(source, /const showProgress = Boolean\(rowStatus\) && hasDecommissionProgress\(rowState\)/)
assert.match(source, /showProgress \?/)
assert.match(source, /rowState === "ready"/)
assert.doesNotMatch(source, /\["ready", "failed", "canceled", "completed"\]\.includes\(rowState\)/)
})
+38
View File
@@ -197,6 +197,44 @@ test("normalizeRebalanceStatus aggregates pool progress when totals are missing"
assert.equal(status.totals.eta, 40)
})
test("normalizeRebalanceStatus does not infer full progress from aggregated bytes", () => {
const status = normalizeRebalanceStatus({
id: "reb-3",
pools: [
{
id: 0,
status: "Completed",
progress: {
objects: 9,
versions: 9,
bytes: 18 * 1024 ** 3,
elapsed: 53,
eta: 0,
},
},
{
id: 1,
status: "Failed",
progress: {
objects: 173,
versions: 173,
bytes: 346 * 1024 ** 3,
elapsed: 1072,
eta: 0,
},
},
{
id: 2,
status: "None",
progress: null,
},
],
})
assert.equal(status.status, "failed")
assert.equal(status.progressPercent, 33)
})
test("normalizeDecommissionInfo reads nested response", () => {
const info = normalizeDecommissionInfo({
decommissionInfo: {
+37
View File
@@ -0,0 +1,37 @@
import test from "node:test"
import assert from "node:assert/strict"
import fs from "node:fs"
test("rebalance page uses basic pool overview columns", () => {
const source = fs.readFileSync("app/(dashboard)/rebalance/page.tsx", "utf8")
assert.match(
source,
/<PoolsOverviewCard overview=\{overview\} operationLabel=\{t\("Rebalance"\)\} showDecommissionColumns=\{false\} \/>/,
)
})
test("rebalance page highlights failed pool rows", () => {
const source = fs.readFileSync("app/(dashboard)/rebalance/page.tsx", "utf8")
assert.match(source, /function isFailedRebalancePool\(pool: PoolSummary\)/)
assert.match(source, /\["failed", "error"\]\.includes\(pool\.status\.trim\(\)\.toLowerCase\(\)\)/)
assert.match(source, /className=\{cn\(isFailedRebalancePool\(pool\) && "bg-destructive\/10"\)\}/)
})
test("decommission page keeps decommission detail columns", () => {
const source = fs.readFileSync("app/(dashboard)/pool-decommission/page.tsx", "utf8")
assert.match(
source,
/<PoolsOverviewCard overview=\{overview\} operationLabel=\{t\("Pool Decommission"\)\} showDecommissionColumns \/>/,
)
})
test("pool overview card gates decommission-specific columns", () => {
const source = fs.readFileSync("components/pools/overview.tsx", "utf8")
assert.match(source, /showDecommissionColumns = false/)
assert.match(source, /showDecommissionColumns \? 18 : 9/)
assert.match(source, /showDecommissionColumns \? \(/)
})