feat: redesign pool decommission page

This commit is contained in:
cxymds
2026-06-23 17:44:11 +08:00
parent 3b07981e8b
commit c375569947
20 changed files with 799 additions and 325 deletions
+475 -293
View File
@@ -3,10 +3,9 @@
import * as React from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { RiAlertLine, RiRefreshLine } from "@remixicon/react"
import { RiAlertLine, RiDeleteBin5Line, RiPlayCircleLine, RiRefreshLine } from "@remixicon/react"
import { Page } from "@/components/page"
import { PageHeader } from "@/components/page-header"
import { PoolsOverviewCard } from "@/components/pools/overview"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -15,6 +14,8 @@ import { Progress } from "@/components/ui/progress"
import { Spinner } from "@/components/ui/spinner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { usePoolOperations } from "@/hooks/use-pool-operations"
import { useMessage } from "@/lib/feedback/message"
import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions"
import {
deriveDecommissionDisplayState,
deriveRebalanceDisplayState,
@@ -25,17 +26,22 @@ import {
type PoolsOverview,
type RebalanceDisplayState,
} from "@/lib/pool-operations"
import { useMessage } from "@/lib/feedback/message"
import { niceBytes } from "@/lib/functions"
import { cn } from "@/lib/utils"
const POLL_MS = 5000
interface PoolRow {
pool: PoolSummary
status: DecommissionInfo | null
displayState: DecommissionDisplayState
}
function shouldPoll(state: DecommissionDisplayState) {
return ["running", "canceling"].includes(state)
}
function formatBytesValue(value?: number) {
return value === undefined ? "--" : niceBytes(String(value))
function formatBytesValue(value?: number | null) {
return typeof value === "number" ? niceBytes(String(value)) : "--"
}
function getPoolDisplayState(
@@ -47,8 +53,12 @@ function getPoolDisplayState(
return deriveDecommissionDisplayState(status, supportState, rebalanceState, isConfirming)
}
function formatPoolStatusLabel(state: DecommissionDisplayState, t: (key: string) => string) {
switch (state) {
function formatPoolStatusLabel(row: PoolRow, t: (key: string) => string) {
const rawStatus = row.status?.status.trim().toLowerCase()
if (row.displayState === "running" && rawStatus === "queued") return t("Queued")
switch (row.displayState) {
case "unsupported":
return t("Unsupported")
case "blocked-by-rebalance":
@@ -75,6 +85,7 @@ function formatPoolStatusLabel(state: DecommissionDisplayState, t: (key: string)
function getPoolStatusBadgeVariant(state: DecommissionDisplayState) {
if (state === "failed") return "destructive"
if (state === "completed") return "default"
if (state === "running" || state === "canceling") return "outline"
return "secondary"
}
@@ -82,15 +93,76 @@ function hasDecommissionProgress(state: DecommissionDisplayState) {
return ["running", "canceling", "completed", "failed", "canceled"].includes(state)
}
function getProgressBase(status: DecommissionInfo | null, pool: PoolSummary) {
return Math.max((status?.totalSize || pool.decommission.totalSize || pool.total) - (status?.startSize || 0), 0)
}
function getProgressPercent(status: DecommissionInfo | null, pool: PoolSummary) {
if (!status) return 0
if (status.complete) return 100
if (status.progressPercent > 0) return status.progressPercent
const progressBase = getProgressBase(status, pool)
if (progressBase <= 0) return 0
return Math.max(0, Math.min(100, (status.bytes / progressBase) * 100))
}
function getRemainingBytes(status: DecommissionInfo | null, pool: PoolSummary) {
if (!status) return undefined
const progressBase = getProgressBase(status, pool)
if (progressBase <= 0) return undefined
return Math.max(progressBase - status.bytes, 0)
}
function getCurrentObject(status: DecommissionInfo | null) {
if (!status) return "--"
const parts = [status.bucket, status.prefix, status.object].filter(Boolean)
return parts.length > 0 ? parts.join("/") : "--"
}
function isTaskLocked(row: PoolRow | undefined) {
return Boolean(row && ["running", "canceling"].includes(row.displayState))
}
function canClearDecommission(row: PoolRow) {
return row.displayState === "failed" || row.displayState === "canceled"
}
function canStartDecommission(row: PoolRow, selectionLocked: boolean) {
return row.displayState === "ready" && !selectionLocked
}
function UsageMeter({ value, tone = "primary" }: { value: number; tone?: "primary" | "destructive" | "muted" }) {
return (
<div className="flex min-w-32 items-center gap-3">
<Progress
value={value}
className={cn(
"w-28 [&_[data-slot=progress-indicator]]:bg-primary",
tone === "destructive" && "[&_[data-slot=progress-indicator]]:bg-destructive",
tone === "muted" && "[&_[data-slot=progress-indicator]]:bg-muted-foreground",
)}
/>
<span className="w-10 text-right text-xs tabular-nums text-muted-foreground">{Math.round(value)}%</span>
</div>
)
}
export default function PoolDecommissionPage() {
const { t } = useTranslation()
const message = useMessage()
const { getPoolsOverview, getRebalanceStatus, getDecommissionStatus, startDecommission, cancelDecommission } =
usePoolOperations()
const {
getPoolsOverview,
getRebalanceStatus,
getDecommissionStatus,
startDecommission,
cancelDecommission,
clearDecommission,
} = usePoolOperations()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [activePoolId, setActivePoolId] = useState("")
const [selectedPoolId, setSelectedPoolId] = useState("")
const [confirmingPoolId, setConfirmingPoolId] = useState("")
const [overview, setOverview] = useState<PoolsOverview>({
@@ -129,9 +201,6 @@ export default function PoolDecommissionPage() {
setOverview(nextOverview)
setStatuses(Object.fromEntries(statusEntries) as Record<string, DecommissionInfo | null>)
setRebalanceState(nextRebalanceState)
if (!activePoolId && nextOverview.pools[0]?.id) {
setActivePoolId(nextOverview.pools[0].id)
}
if (!selectedPoolId && nextOverview.pools[0]?.id) {
setSelectedPoolId(nextOverview.pools[0].id)
}
@@ -141,7 +210,7 @@ export default function PoolDecommissionPage() {
if (showSpinner) setLoading(false)
}
},
[activePoolId, getDecommissionStatus, getPoolsOverview, getRebalanceStatus, selectedPoolId, t],
[getDecommissionStatus, getPoolsOverview, getRebalanceStatus, selectedPoolId, t],
)
useEffect(() => {
@@ -149,89 +218,6 @@ export default function PoolDecommissionPage() {
return clearPoll
}, [loadData])
useEffect(() => {
clearPoll()
if (
!Object.values(statuses).some((status) =>
shouldPoll(getPoolDisplayState(status, overview.supportState, rebalanceState)),
)
) {
return
}
pollRef.current = window.setTimeout(() => {
void loadData(false)
}, POLL_MS)
return clearPoll
}, [loadData, overview.supportState, rebalanceState, statuses])
const activeStatus = statuses[activePoolId] ?? null
const activeDisplayState = getPoolDisplayState(
activeStatus,
overview.supportState,
rebalanceState,
confirmingPoolId === activePoolId,
)
const showActiveProgress = Boolean(activeStatus) && hasDecommissionProgress(activeDisplayState)
const canCancelActive = activePoolId
? getPoolDisplayState(activeStatus, overview.supportState, rebalanceState) === "running"
: false
const selectedPoolName = overview.pools.find((pool) => pool.id === selectedPoolId)?.name ?? "--"
const handleStart = async (poolId: string) => {
setSubmitting(true)
try {
await startDecommission(poolId)
setConfirmingPoolId("")
setActivePoolId(poolId)
message.success(t("Pool decommission started"))
await loadData(false)
} catch (startError) {
message.error((startError as Error).message || t("Failed to start decommission"))
} finally {
setSubmitting(false)
}
}
const handleCancel = async (poolId = activePoolId) => {
if (!poolId) return
setSubmitting(true)
try {
await cancelDecommission(poolId)
setActivePoolId(poolId)
message.success(t("Pool decommission cancel requested"))
await loadData(false)
} catch (cancelError) {
message.error((cancelError as Error).message || t("Failed to cancel decommission"))
} finally {
setSubmitting(false)
}
}
const statusLabel = useMemo(() => {
switch (activeDisplayState) {
case "unsupported":
return t("Unsupported")
case "blocked-by-rebalance":
return t("Blocked")
case "ready":
return t("Ready")
case "confirming":
return t("Needs Confirmation")
case "running":
return t("Running")
case "canceling":
return t("Canceling")
case "completed":
return t("Completed Status")
case "failed":
return t("Failed Status")
case "canceled":
return t("Canceled")
default:
return t("Unknown")
}
}, [activeDisplayState, t])
const poolRows = useMemo(
() =>
overview.pools.map((pool) => {
@@ -250,26 +236,160 @@ export default function PoolDecommissionPage() {
[confirmingPoolId, overview.pools, overview.supportState, rebalanceState, statuses],
)
const activeTask = poolRows.find((row) => isTaskLocked(row))
const selectedRow =
activeTask ?? poolRows.find((row) => row.pool.id === selectedPoolId) ?? poolRows.find((row) => row.pool.id)
const trackedTask =
activeTask ??
(selectedRow && hasDecommissionProgress(selectedRow.displayState) && selectedRow.status ? selectedRow : undefined)
const selectionLocked = isTaskLocked(activeTask)
const selectedPoolName = selectedRow?.pool.name ?? "--"
const trackedProgress = trackedTask ? getProgressPercent(trackedTask.status, trackedTask.pool) : 0
useEffect(() => {
clearPoll()
if (!poolRows.some((row) => shouldPoll(row.displayState))) return
pollRef.current = window.setTimeout(() => {
void loadData(false)
}, POLL_MS)
return clearPoll
}, [loadData, poolRows])
const handleStart = async (poolId: string) => {
setSubmitting(true)
try {
await startDecommission(poolId)
setConfirmingPoolId("")
setSelectedPoolId(poolId)
message.success(t("Pool decommission started"))
await loadData(false)
} catch (startError) {
message.error((startError as Error).message || t("Failed to start decommission"))
} finally {
setSubmitting(false)
}
}
const handleCancel = async (poolId: string) => {
setSubmitting(true)
try {
await cancelDecommission(poolId)
setSelectedPoolId(poolId)
message.success(t("Pool decommission cancel requested"))
await loadData(false)
} catch (cancelError) {
message.error((cancelError as Error).message || t("Failed to cancel decommission"))
} finally {
setSubmitting(false)
}
}
const handleClear = async (poolId: string) => {
setSubmitting(true)
try {
await clearDecommission(poolId)
if (selectedPoolId === poolId) setConfirmingPoolId("")
message.success(t("Decommission cleared"))
await loadData(false)
} catch (clearError) {
message.error((clearError as Error).message || t("Failed to clear decommission"))
} finally {
setSubmitting(false)
}
}
return (
<Page>
<PageHeader
actions={
<>
<Button variant="outline" onClick={() => void loadData()} disabled={loading || submitting}>
<RiRefreshLine className="me-2 size-4" aria-hidden />
{t("Sync")}
</Button>
<Button variant="outline" onClick={() => void handleCancel()} disabled={!canCancelActive || submitting}>
{t("Cancel")}
</Button>
</>
<Button variant="outline" onClick={() => void loadData()} disabled={loading || submitting}>
<RiRefreshLine className="me-2 size-4" aria-hidden />
{t("Sync")}
</Button>
}
>
<h1 className="text-2xl font-bold">{t("Pool Decommission")}</h1>
</PageHeader>
<div className="space-y-6">
<PoolsOverviewCard overview={overview} operationLabel={t("Pool Decommission")} showDecommissionColumns />
{trackedTask ? (
<Card className="rounded-none">
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t("Current Pool Decommission Status")}</CardTitle>
<p className="text-sm text-muted-foreground">{trackedTask.pool.name}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={getPoolStatusBadgeVariant(trackedTask.displayState)}>
{formatPoolStatusLabel(trackedTask, t)}
</Badge>
<Button
variant="outline"
onClick={() => void handleCancel(trackedTask.pool.id)}
disabled={trackedTask.displayState !== "running" || submitting}
>
{t("Cancel")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center gap-4">
<Progress value={trackedProgress} className="flex-1" />
<span className="w-12 text-right text-sm font-semibold tabular-nums">
{Math.round(trackedProgress)}%
</span>
</div>
<div className="grid gap-x-8 gap-y-4 md:grid-cols-4 xl:grid-cols-7">
<div>
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
<p className="text-sm font-medium">{formatBytesValue(trackedTask.status?.bytes)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Remaining Bytes")}</p>
<p className="text-sm font-medium">
{formatBytesValue(getRemainingBytes(trackedTask.status, trackedTask.pool))}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Objects")}</p>
<p className="text-sm font-medium">{formatInteger(trackedTask.status?.objects)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Objects Failed")}</p>
<p className="text-sm font-medium">{formatInteger(trackedTask.status?.objectsFailed)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Bytes Failed")}</p>
<p className="text-sm font-medium">{formatBytesValue(trackedTask.status?.bytesFailed)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Start Time")}</p>
<p className="text-sm font-medium">{formatDateTime(trackedTask.status?.startedAt)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Updated At")}</p>
<p className="text-sm font-medium">{formatDateTime(trackedTask.status?.updatedAt)}</p>
</div>
</div>
<div className="grid gap-3 border-t pt-4 text-sm md:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">{t("Current Object")}</p>
<p className="truncate font-medium">{getCurrentObject(trackedTask.status)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("State")}</p>
<p className="truncate font-medium">
{trackedTask.status?.stage || trackedTask.status?.status || "--"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Waiting Reason")}</p>
<p className="truncate font-medium">{trackedTask.status?.waitingReason || "--"}</p>
</div>
</div>
</CardContent>
</Card>
) : null}
{overview.supportState === "unsupported" ? (
<Alert>
@@ -293,202 +413,264 @@ export default function PoolDecommissionPage() {
</Alert>
) : null}
<Card className="rounded-none">
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t("Current Pool Decommission Status")}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("Select a pool, review its impact, then start or monitor retirement.")}
</p>
</div>
<Badge
variant={
activeDisplayState === "failed"
? "destructive"
: activeDisplayState === "completed"
? "default"
: "secondary"
}
>
{statusLabel}
</Badge>
</CardHeader>
<CardContent className="space-y-6">
{loading ? (
<div className="flex items-center justify-center py-10">
<Spinner className="size-8 text-muted-foreground" />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className="rounded-none">
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t("Pool Decommission")}</CardTitle>
<p className="text-sm text-muted-foreground">
{selectionLocked
? t("Selection Locked")
: t("Select a pool, review its impact, then start or monitor retirement.")}
</p>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-4">
<Badge variant={selectionLocked ? "outline" : "secondary"}>
{selectionLocked ? t("Running") : t("Ready")}
</Badge>
</CardHeader>
<CardContent className="space-y-5">
{loading ? (
<div className="flex items-center justify-center py-10">
<Spinner className="size-8 text-muted-foreground" />
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-14">{t("ID")}</TableHead>
<TableHead>{t("Pool")}</TableHead>
<TableHead>{t("Status")}</TableHead>
<TableHead>{t("Total Capacity")}</TableHead>
<TableHead>{t("Used Capacity")}</TableHead>
<TableHead>{t("Usage")}</TableHead>
<TableHead>{t("Progress")}</TableHead>
<TableHead>{t("Bytes Moved")}</TableHead>
<TableHead className="text-end">{t("Actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{poolRows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground">
{t("No Data")}
</TableCell>
</TableRow>
) : (
poolRows.map((row) => {
const { pool, status: rowStatus, displayState: rowState } = row
const showProgress = Boolean(rowStatus) && hasDecommissionProgress(rowState)
const progressPercent = showProgress ? getProgressPercent(rowStatus, pool) : 0
const isSelected = selectedRow?.pool.id === pool.id
const canStart = canStartDecommission(row, selectionLocked) && !submitting
const canConfirm = rowState === "confirming" && !submitting
const canCancel = rowState === "running" && !submitting
const canClear = canClearDecommission(row) && !submitting
return (
<TableRow
key={pool.id}
tabIndex={0}
aria-selected={isSelected}
className={cn(
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
rowState === "running" && "bg-primary/5",
rowState === "failed" && "bg-destructive/10",
rowState === "canceled" && "bg-muted/60",
)}
data-state={isSelected ? "selected" : undefined}
onClick={() => {
if (selectionLocked) return
setSelectedPoolId(pool.id)
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
if (selectionLocked) return
setSelectedPoolId(pool.id)
}}
>
<TableCell className="text-muted-foreground">{pool.id}</TableCell>
<TableCell className="max-w-[340px] truncate font-medium">{pool.name}</TableCell>
<TableCell>
<Badge variant={getPoolStatusBadgeVariant(rowState)}>
{formatPoolStatusLabel(row, t)}
</Badge>
</TableCell>
<TableCell>{formatBytesValue(pool.total)}</TableCell>
<TableCell>{formatBytesValue(pool.used)}</TableCell>
<TableCell>
<UsageMeter value={pool.usagePercent} />
</TableCell>
<TableCell>
{showProgress ? <UsageMeter value={progressPercent} tone="muted" /> : "--"}
</TableCell>
<TableCell>{showProgress ? formatBytesValue(rowStatus?.bytes) : "--"}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
{rowState === "confirming" ? (
<>
<Button
size="sm"
onClick={(event) => {
event.stopPropagation()
void handleStart(pool.id)
}}
disabled={!canConfirm}
>
{submitting && selectedPoolId === pool.id ? (
<Spinner className="me-2 size-4" />
) : null}
{t("Confirm")}
</Button>
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setConfirmingPoolId("")
}}
disabled={submitting}
>
{t("Cancel")}
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
disabled={!canStart}
onClick={(event) => {
event.stopPropagation()
setSelectedPoolId(pool.id)
setConfirmingPoolId(pool.id)
}}
>
<RiPlayCircleLine className="me-1 size-3.5" aria-hidden />
{t("Start Decommission")}
</Button>
<Button
size="sm"
variant="outline"
disabled={!canCancel}
onClick={(event) => {
event.stopPropagation()
void handleCancel(pool.id)
}}
>
{t("Cancel")}
</Button>
<Button
size="sm"
variant="destructive"
disabled={!canClear}
onClick={(event) => {
event.stopPropagation()
void handleClear(pool.id)
}}
>
<RiDeleteBin5Line className="me-1 size-3.5" aria-hidden />
{t("Clear Decommission")}
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
{confirmingPoolId ? (
<Alert>
<AlertTitle>{t("Review before decommission")}</AlertTitle>
<AlertDescription>
{t(
"This action retires the selected pool and should be used only after verifying rebalance has completed.",
)}{" "}
{t("Selected Pool")}:{" "}
{overview.pools.find((pool) => pool.id === confirmingPoolId)?.name ?? "--"}
</AlertDescription>
</Alert>
) : null}
</>
)}
</CardContent>
</Card>
<div className="space-y-6">
<Card className="rounded-none">
<CardHeader>
<CardTitle>{t("Selected Pool")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs text-muted-foreground">{selectionLocked ? t("Selection Locked") : t("Pool")}</p>
<p className="truncate text-sm font-semibold">{selectedPoolName}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-muted-foreground">{t("Active Pool")}</p>
<p className="truncate text-sm font-medium">
{overview.pools.find((pool) => pool.id === activePoolId)?.name ?? "--"}
</p>
<p className="text-xs text-muted-foreground">{t("Total Capacity")}</p>
<p className="text-sm font-medium">{formatBytesValue(selectedRow?.pool.total)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Selected Pool")}</p>
<p className="truncate text-sm font-medium">{selectedPoolName}</p>
<p className="text-xs text-muted-foreground">{t("Used Capacity")}</p>
<p className="text-sm font-medium">{formatBytesValue(selectedRow?.pool.used)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Progress")}</p>
<p className="text-sm font-medium">
{showActiveProgress ? `${Math.round(activeStatus?.progressPercent ?? 0)}%` : "--"}
</p>
<p className="text-xs text-muted-foreground">{t("Available")}</p>
<p className="text-sm font-medium">{formatBytesValue(selectedRow?.pool.available)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
<p className="text-sm font-medium">
{showActiveProgress ? formatBytesValue(activeStatus?.bytes) : "--"}
</p>
<p className="text-xs text-muted-foreground">{t("Updated At")}</p>
<p className="text-sm font-medium">{formatDateTime(selectedRow?.pool.lastUpdate)}</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t("Usage")}</span>
<span>{Math.round(selectedRow?.pool.usagePercent ?? 0)}%</span>
</div>
<Progress value={selectedRow?.pool.usagePercent ?? 0} />
</div>
</CardContent>
</Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("Pool")}</TableHead>
<TableHead>{t("Status")}</TableHead>
<TableHead>{t("Used Capacity")}</TableHead>
<TableHead>{t("Progress")}</TableHead>
<TableHead>{t("Objects")}</TableHead>
<TableHead>{t("Bytes Moved")}</TableHead>
<TableHead className="text-end">{t("Actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{poolRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
{t("No Data")}
</TableCell>
</TableRow>
) : (
poolRows.map(({ pool, status: rowStatus, displayState: rowState }) => {
const showProgress = Boolean(rowStatus) && hasDecommissionProgress(rowState)
const canRequestStart = !submitting && rowState === "ready"
const canConfirm = !submitting && rowState === "confirming"
const canCancel = !submitting && rowState === "running"
return (
<TableRow
key={pool.id}
tabIndex={0}
aria-selected={pool.id === selectedPoolId}
className="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-state={pool.id === selectedPoolId ? "selected" : undefined}
onClick={() => setSelectedPoolId(pool.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
setSelectedPoolId(pool.id)
}}
>
<TableCell className="max-w-[320px] truncate">{pool.name}</TableCell>
<TableCell>
<Badge variant={getPoolStatusBadgeVariant(rowState)}>
{formatPoolStatusLabel(rowState, t)}
</Badge>
</TableCell>
<TableCell>{formatBytesValue(pool.used)}</TableCell>
<TableCell className="min-w-32">
{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">
{Math.round(rowStatus?.progressPercent ?? 0)}%
</span>
</div>
) : (
"--"
)}
</TableCell>
<TableCell>{showProgress ? (rowStatus?.objects ?? "--") : "--"}</TableCell>
<TableCell>{showProgress ? formatBytesValue(rowStatus?.bytes) : "--"}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
{rowState === "confirming" ? (
<>
<Button
size="sm"
onClick={(event) => {
event.stopPropagation()
void handleStart(pool.id)
}}
disabled={!canConfirm}
>
{submitting && activePoolId === pool.id ? (
<Spinner className="me-2 size-4" />
) : null}
{t("Confirm")}
</Button>
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation()
setConfirmingPoolId("")
}}
disabled={submitting}
>
{t("Cancel")}
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
disabled={!canRequestStart || showProgress}
onClick={(event) => {
event.stopPropagation()
setActivePoolId(pool.id)
setSelectedPoolId(pool.id)
setConfirmingPoolId(pool.id)
}}
>
{t("Start Decommission")}
</Button>
<Button
size="sm"
variant="outline"
disabled={!canCancel}
onClick={(event) => {
event.stopPropagation()
void handleCancel(pool.id)
}}
>
{t("Cancel")}
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
{confirmingPoolId ? (
<Alert>
<AlertTitle>{t("Review before decommission")}</AlertTitle>
<AlertDescription>
{t(
"This action retires the selected pool and should be used only after verifying rebalance has completed.",
)}{" "}
{t("Selected Pool")}: {overview.pools.find((pool) => pool.id === confirmingPoolId)?.name ?? "--"}
</AlertDescription>
</Alert>
) : null}
<Progress value={activeStatus?.progressPercent ?? 0} className="h-2" />
</>
)}
</CardContent>
</Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>{t("Actions")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-start justify-between gap-4 border-b pb-3">
<div>
<p className="font-medium">{t("Start Decommission")}</p>
<p className="text-xs text-muted-foreground">{t("Ready")}</p>
</div>
<Badge variant="secondary">{t("Ready")}</Badge>
</div>
<div className="flex items-start justify-between gap-4 border-b pb-3">
<div>
<p className="font-medium">{t("Cancel")}</p>
<p className="text-xs text-muted-foreground">{t("Running")}</p>
</div>
<Badge variant="outline">{t("Running")}</Badge>
</div>
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-medium">{t("Clear Decommission")}</p>
<p className="text-xs text-muted-foreground">
{t("Failed Status")} / {t("Canceled")}
</p>
</div>
<Badge variant="destructive">{t("Clear Records")}</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</Page>
)
+9
View File
@@ -80,6 +80,14 @@ export function usePoolOperations() {
[api],
)
const clearDecommission = useCallback(
async (poolId: string) => {
const response = await api.post(`/pools/clear?pool=${encodeURIComponent(poolId)}&by-id=true`, {})
return normalizeDecommissionInfo(response, poolId)
},
[api],
)
const getRebalanceViewModel = useCallback(async (): Promise<RebalanceViewModel> => {
const [overview, status] = await Promise.all([getPoolsOverview(), getRebalanceStatus().catch(() => null)])
return {
@@ -121,6 +129,7 @@ export function usePoolOperations() {
getDecommissionStatus,
startDecommission,
cancelDecommission,
clearDecommission,
getRebalanceViewModel,
getDecommissionViewModel,
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "فشل تحميل الكائنات",
"Access Key cannot contain spaces": "لا يمكن أن يحتوي مفتاح الوصول على مسافات",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "يمكن أن يحتوي الاسم على أحرف وأرقام وشرطات سفلية وشرطات فقط، ويجب أن يبدأ بحرف",
"Name must be at most 32 characters": "يجب ألا يزيد الاسم عن 32 حرفًا"
"Name must be at most 32 characters": "يجب ألا يزيد الاسم عن 32 حرفًا",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Objekte konnten nicht geladen werden",
"Access Key cannot contain spaces": "Der Zugriffsschlüssel darf keine Leerzeichen enthalten",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Der Name darf nur Buchstaben, Ziffern, Unterstriche und Bindestriche enthalten und muss mit einem Buchstaben beginnen",
"Name must be at most 32 characters": "Der Name darf höchstens 32 Zeichen lang sein"
"Name must be at most 32 characters": "Der Name darf höchstens 32 Zeichen lang sein",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Failed to load objects",
"Access Key cannot contain spaces": "Access Key cannot contain spaces",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Name can only contain letters, numbers, underscores and hyphens, and must start with a letter",
"Name must be at most 32 characters": "Name must be at most 32 characters"
"Name must be at most 32 characters": "Name must be at most 32 characters",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "No se pudieron cargar los objetos",
"Access Key cannot contain spaces": "La clave de acceso no puede contener espacios",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "El nombre solo puede contener letras, números, guiones bajos y guiones, y debe comenzar con una letra",
"Name must be at most 32 characters": "El nombre no puede tener más de 32 caracteres"
"Name must be at most 32 characters": "El nombre no puede tener más de 32 caracteres",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Impossible de charger les objets",
"Access Key cannot contain spaces": "La clé d'accès ne peut pas contenir d'espaces",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Le nom ne peut contenir que des lettres, des chiffres, des traits de soulignement et des traits d'union, et doit commencer par une lettre",
"Name must be at most 32 characters": "Le nom ne doit pas dépasser 32 caractères"
"Name must be at most 32 characters": "Le nom ne doit pas dépasser 32 caractères",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Gagal memuat objek",
"Access Key cannot contain spaces": "Kunci akses tidak boleh mengandung spasi",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Nama hanya boleh berisi huruf, angka, garis bawah, dan tanda hubung, serta harus diawali dengan huruf",
"Name must be at most 32 characters": "Nama tidak boleh lebih dari 32 karakter"
"Name must be at most 32 characters": "Nama tidak boleh lebih dari 32 karakter",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Impossibile caricare gli oggetti",
"Access Key cannot contain spaces": "La chiave di accesso non può contenere spazi",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Il nome può contenere solo lettere, numeri, trattini bassi e trattini, e deve iniziare con una lettera",
"Name must be at most 32 characters": "Il nome non può superare i 32 caratteri"
"Name must be at most 32 characters": "Il nome non può superare i 32 caratteri",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "オブジェクトの読み込みに失敗しました",
"Access Key cannot contain spaces": "アクセスキーにスペースを含めることはできません",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "名前には英字、数字、アンダースコア、ハイフンのみ使用でき、英字で始まる必要があります",
"Name must be at most 32 characters": "名前は最大32文字です"
"Name must be at most 32 characters": "名前は最大32文字です",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "객체를 불러오지 못했습니다",
"Access Key cannot contain spaces": "액세스 키에는 공백을 포함할 수 없습니다",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "이름은 영문자, 숫자, 밑줄, 하이픈만 사용할 수 있으며 영문자로 시작해야 합니다",
"Name must be at most 32 characters": "이름은 최대 32자입니다"
"Name must be at most 32 characters": "이름은 최대 32자입니다",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Falha ao carregar objetos",
"Access Key cannot contain spaces": "A chave de acesso não pode conter espaços",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "O nome só pode conter letras, números, sublinhados e hifens, e deve começar com uma letra",
"Name must be at most 32 characters": "O nome não pode ter mais de 32 caracteres"
"Name must be at most 32 characters": "O nome não pode ter mais de 32 caracteres",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Не удалось загрузить объекты",
"Access Key cannot contain spaces": "Ключ доступа не может содержать пробелы",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Имя может содержать только буквы, цифры, символы подчёркивания и дефисы и должно начинаться с буквы",
"Name must be at most 32 characters": "Имя не должно превышать 32 символа"
"Name must be at most 32 characters": "Имя не должно превышать 32 символа",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Nesneler yüklenemedi",
"Access Key cannot contain spaces": "Erişim anahtarı boşluk içeremez",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Ad yalnızca harf, rakam, alt çizgi ve tire içerebilir ve bir harfle başlamalıdır",
"Name must be at most 32 characters": "Ad en fazla 32 karakter olmalıdır"
"Name must be at most 32 characters": "Ad en fazla 32 karakter olmalıdır",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "Không thể tải đối tượng",
"Access Key cannot contain spaces": "Khóa truy cập không được chứa khoảng trắng",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "Tên chỉ được chứa chữ cái, số, dấu gạch dưới và dấu gạch nối, và phải bắt đầu bằng một chữ cái",
"Name must be at most 32 characters": "Tên không được dài quá 32 ký tự"
"Name must be at most 32 characters": "Tên không được dài quá 32 ký tự",
"Queued": "Queued",
"Remaining Bytes": "Remaining Bytes",
"Current Object": "Current Object",
"Waiting Reason": "Waiting Reason",
"Selection Locked": "Selection Locked",
"Decommission cleared": "Decommission cleared",
"Failed to clear decommission": "Failed to clear decommission",
"Clear Decommission": "Clear Decommission"
}
+9 -1
View File
@@ -1338,5 +1338,13 @@
"Failed to load objects": "加载对象失败",
"Access Key cannot contain spaces": "访问密钥不能包含空格",
"Name can only contain letters, numbers, underscores and hyphens, and must start with a letter": "名称只能包含字母、数字、下划线和连字符,并且必须以字母开头",
"Name must be at most 32 characters": "名称最多 32 个字符"
"Name must be at most 32 characters": "名称最多 32 个字符",
"Queued": "排队中",
"Remaining Bytes": "剩余字节数",
"Current Object": "当前对象",
"Waiting Reason": "等待原因",
"Selection Locked": "选择已锁定",
"Decommission cleared": "退役记录已清理",
"Failed to clear decommission": "清理退役记录失败",
"Clear Decommission": "清理退役"
}
+65 -7
View File
@@ -45,10 +45,18 @@ export interface PoolDecommissionSummary {
complete: boolean
failed: boolean
canceled: boolean
queued: boolean
queuedBuckets: string[]
decommissionedBuckets: string[]
bucket: string
prefix: string
object: string
stage: string
objects: number
objectsFailed: number
bytes: number
bytesFailed: number
waitingReason?: string
}
export interface PoolSummary {
@@ -94,11 +102,21 @@ export interface DecommissionInfo {
complete: boolean
failed: boolean
canceled: boolean
queued: boolean
progressPercent: number
objects: number
objectsFailed: number
versions: number
bytes: number
bytesFailed: number
startSize: number
totalSize: number
currentSize: number
bucket: string
prefix: string
object: string
stage: string
waitingReason?: string
message?: string
startedAt?: string
updatedAt?: string
@@ -114,6 +132,10 @@ function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
function asStringArray(value: unknown): string[] {
return asArray(value).map(String)
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
@@ -168,12 +190,20 @@ function normalizeDecommissionSummary(value: unknown): PoolDecommissionSummary {
complete: asBoolean(record.complete || record.Complete),
failed: asBoolean(record.failed || record.Failed),
canceled: asBoolean(record.canceled || record.Canceled),
queued: asBoolean(record.queued || record.Queued),
queuedBuckets: asStringArray(record.queuedBuckets || record.QueuedBuckets),
decommissionedBuckets: asStringArray(record.decommissionedBuckets || record.DecommissionedBuckets),
bucket: asString(record.bucket || record.Bucket),
prefix: asString(record.prefix || record.Prefix),
object: asString(record.object || record.Object),
stage: asString(record.stage || record.Stage),
objects: asNumber(record.objectsDecommissioned || record.ObjectsDecommissioned || record.objects || record.Objects),
objectsFailed: asNumber(
record.objectsDecommissionedFailed || record.ObjectsDecommissionedFailed || record.objectsFailed,
),
bytes: asNumber(record.bytesDecommissioned || record.BytesDecommissioned || record.bytes || record.Bytes),
bytesFailed: asNumber(record.bytesDecommissionedFailed || record.BytesDecommissionedFailed || record.bytesFailed),
waitingReason: asString(record.waitingReason || record.WaitingReason) || undefined,
}
}
@@ -329,23 +359,41 @@ export function normalizeRebalanceStatus(value: unknown): RebalanceStatus {
export function normalizeDecommissionInfo(value: unknown, fallbackPoolId = ""): DecommissionInfo {
const record = asRecord(value)
const info = asRecord(record.decommissionInfo || record.DecommissionInfo || value)
const progressPercent =
const status = pickStatus(record) || pickStatus(info) || deriveDecommissionStatus(info)
const startSize = asNumber(info.startSize || info.StartSize)
const totalSize =
asNumber(info.totalSize || info.TotalSize) || asNumber(record.totalSize || record.TotalSize || record.capacity)
const bytes = asNumber(info.bytesDecommissioned || info.BytesDecommissioned || info.bytes || info.Bytes)
const progressBase = Math.max(totalSize - startSize, 0)
const rawProgress =
asNumber(info.progressPercent || info.ProgressPercent || info.percent || info.Percent) ||
(asBoolean(info.complete || info.Complete) ? 100 : 0)
(asBoolean(info.complete || info.Complete) ? 100 : progressBase > 0 ? (bytes / progressBase) * 100 : 0)
return {
status: pickStatus(info),
status,
poolId: asString(info.poolId || info.pool || info.PoolID || info.Pool) || fallbackPoolId,
complete: asBoolean(info.complete || info.Complete),
failed: asBoolean(info.failed || info.Failed),
canceled: asBoolean(info.canceled || info.Canceled),
progressPercent: clampPercent(progressPercent),
objects: asNumber(info.objects || info.Objects || info.objectCount),
queued: asBoolean(info.queued || info.Queued),
progressPercent: clampPercent(rawProgress),
objects: asNumber(
info.objectsDecommissioned || info.ObjectsDecommissioned || info.objects || info.Objects || info.objectCount,
),
objectsFailed: asNumber(info.objectsDecommissionedFailed || info.ObjectsDecommissionedFailed || info.objectsFailed),
versions: asNumber(info.versions || info.Versions || info.versionCount),
bytes: asNumber(info.bytes || info.Bytes),
bytes,
bytesFailed: asNumber(info.bytesDecommissionedFailed || info.BytesDecommissionedFailed || info.bytesFailed),
startSize,
totalSize,
currentSize: asNumber(info.currentSize || info.CurrentSize || info.size || info.Size),
bucket: asString(info.bucket || info.Bucket),
prefix: asString(info.prefix || info.Prefix),
object: asString(info.object || info.Object),
stage: asString(info.stage || info.Stage),
waitingReason: asString(info.waitingReason || info.WaitingReason) || undefined,
message: asString(info.message || info.Message) || undefined,
startedAt: asString(info.startedAt || info.StartedAt) || undefined,
startedAt: asString(info.startedAt || info.StartedAt || info.startTime || info.StartTime) || undefined,
updatedAt: asString(info.updatedAt || info.UpdatedAt) || undefined,
}
}
@@ -354,6 +402,15 @@ function normalizeState(value: string): string {
return value.trim().toLowerCase()
}
function deriveDecommissionStatus(info: JsonRecord): string {
if (asBoolean(info.complete || info.Complete)) return "complete"
if (asBoolean(info.failed || info.Failed)) return "failed"
if (asBoolean(info.canceled || info.Canceled)) return "canceled"
if (asBoolean(info.queued || info.Queued)) return "queued"
if (asString(info.startTime || info.StartTime)) return "running"
return ""
}
function isIdleRebalancePool(pool: PoolSummary): boolean {
return ["", "none", "not_started", "not-started", "idle"].includes(normalizeState(pool.status))
}
@@ -424,6 +481,7 @@ export function deriveDecommissionDisplayState(
if (info.complete || ["complete", "completed", "success", "finished"].includes(state)) return "completed"
if (info.failed || ["failed", "error"].includes(state)) return "failed"
if (info.canceled || ["canceled", "cancelled", "stopped"].includes(state)) return "canceled"
if (info.queued || ["queued", "waiting"].includes(state)) return "running"
if (["canceling", "cancelling", "stopping"].includes(state)) return "canceling"
if (["running", "in_progress", "in-progress", "started", "starting"].includes(state)) return "running"
return "ready"
+15 -6
View File
@@ -16,15 +16,24 @@ test("pool decommission page keeps status panel independent from list clicks and
const source = fs.readFileSync("app/(dashboard)/pool-decommission/page.tsx", "utf8")
assert.doesNotMatch(source, /{t\("Rebalance Status"\)}/)
assert.match(source, /onClick=\{\(\) => setSelectedPoolId\(pool\.id\)\}/)
assert.match(source, /if \(selectionLocked\) return/)
assert.match(source, /setSelectedPoolId\(pool\.id\)/)
assert.doesNotMatch(source, /onClick=\{\(\) => setActivePoolId\(pool\.id\)\}/)
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.match(source, /return row\.displayState === "ready" && !selectionLocked/)
assert.doesNotMatch(source, /\["ready", "failed", "canceled", "completed"\]\.includes\(rowState\)/)
})
test("pool decommission page gates start, cancel, and clear by decommission state", () => {
const source = fs.readFileSync("app/(dashboard)/pool-decommission/page.tsx", "utf8")
assert.match(source, /function canStartDecommission\(row: PoolRow, selectionLocked: boolean\)/)
assert.match(source, /return row\.displayState === "ready" && !selectionLocked/)
assert.match(source, /function canClearDecommission\(row: PoolRow\)/)
assert.match(source, /return row\.displayState === "failed" \|\| row\.displayState === "canceled"/)
assert.match(source, /const canCancel = rowState === "running" && !submitting/)
assert.match(source, /clearDecommission/)
assert.match(source, /t\("Clear Decommission"\)/)
})
+104
View File
@@ -110,6 +110,110 @@ test("normalizePoolsOverview preserves detailed pool list fields", () => {
assert.equal(pool?.decommission.bytesFailed, 512)
})
test("normalizePoolsOverview preserves decommission runtime details", () => {
const overview = normalizePoolsOverview([
{
id: 0,
cmdline: "pool-0",
status: "running",
totalSize: 1000,
currentSize: 400,
usedSize: 600,
decommissionInfo: {
startTime: "2026-06-23T08:00:00Z",
startSize: 100,
totalSize: 1000,
currentSize: 400,
queued: true,
queuedBuckets: ["a"],
decommissionedBuckets: ["b"],
bucket: "logs",
prefix: "2026/",
object: "part-1",
stage: "copy_object",
objectsDecommissioned: 7,
objectsDecommissionedFailed: 1,
bytesDecommissioned: 2048,
bytesDecommissionedFailed: 512,
waitingReason: "queued",
},
},
])
const decommission = overview.pools[0]?.decommission
assert.equal(decommission?.queued, true)
assert.deepEqual(decommission?.queuedBuckets, ["a"])
assert.deepEqual(decommission?.decommissionedBuckets, ["b"])
assert.equal(decommission?.bucket, "logs")
assert.equal(decommission?.prefix, "2026/")
assert.equal(decommission?.object, "part-1")
assert.equal(decommission?.stage, "copy_object")
assert.equal(decommission?.waitingReason, "queued")
})
test("normalizeDecommissionInfo reads RustFS decommission details", () => {
const status = normalizeDecommissionInfo(
{
id: 0,
status: "running",
totalSize: 1000,
currentSize: 300,
decommissionInfo: {
startTime: "2026-06-23T08:00:00Z",
startSize: 100,
totalSize: 1000,
currentSize: 300,
queued: false,
bucket: "logs",
prefix: "2026/",
object: "part-1",
stage: "copy_object",
objectsDecommissioned: 9,
objectsDecommissionedFailed: 2,
bytesDecommissioned: 450,
bytesDecommissionedFailed: 50,
waitingReason: "waiting_for_worker",
},
},
"0",
)
assert.equal(status.status, "running")
assert.equal(status.progressPercent, 50)
assert.equal(status.objects, 9)
assert.equal(status.objectsFailed, 2)
assert.equal(status.bytes, 450)
assert.equal(status.bytesFailed, 50)
assert.equal(status.startSize, 100)
assert.equal(status.totalSize, 1000)
assert.equal(status.currentSize, 300)
assert.equal(status.bucket, "logs")
assert.equal(status.prefix, "2026/")
assert.equal(status.object, "part-1")
assert.equal(status.stage, "copy_object")
assert.equal(status.waitingReason, "waiting_for_worker")
assert.equal(status.startedAt, "2026-06-23T08:00:00Z")
})
test("normalizeDecommissionInfo derives queued status from decommission info", () => {
const status = normalizeDecommissionInfo(
{
id: 1,
decommissionInfo: {
queued: true,
totalSize: 100,
currentSize: 40,
},
},
"1",
)
assert.equal(status.status, "queued")
assert.equal(status.queued, true)
assert.equal(deriveDecommissionDisplayState(status, "supported", "idle"), "running")
})
test("normalizeRebalanceStatus reads progress and pool details", () => {
const status = normalizeRebalanceStatus({
id: "reb-1",
+5 -5
View File
@@ -19,13 +19,13 @@ test("rebalance page highlights failed pool rows", () => {
assert.match(source, /bg-destructive\/15 hover:bg-destructive\/20/)
})
test("decommission page keeps decommission detail columns", () => {
test("decommission page owns pool operation columns", () => {
const source = fs.readFileSync("app/(dashboard)/pool-decommission/page.tsx", "utf8")
assert.match(
source,
/<PoolsOverviewCard overview=\{overview\} operationLabel=\{t\("Pool Decommission"\)\} showDecommissionColumns \/>/,
)
assert.doesNotMatch(source, /<PoolsOverviewCard/)
assert.match(source, /<TableHead>\{t\("Usage"\)\}<\/TableHead>/)
assert.match(source, /<TableHead>\{t\("Progress"\)\}<\/TableHead>/)
assert.match(source, /<TableHead>\{t\("Bytes Moved"\)\}<\/TableHead>/)
})
test("pool overview card gates decommission-specific columns", () => {