fix: show pool operation status details

This commit is contained in:
马登山
2026-05-09 11:32:21 +08:00
parent dd1d68ad9e
commit 70e6cd3a6f
21 changed files with 568 additions and 181 deletions
+270 -131
View File
@@ -12,15 +12,18 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
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 type {
DecommissionDisplayState,
DecommissionInfo,
PoolsOverview,
PoolSummary,
RebalanceDisplayState,
import {
deriveDecommissionDisplayState,
deriveRebalanceDisplayState,
type DecommissionDisplayState,
type DecommissionInfo,
type PoolSupportState,
type PoolSummary,
type PoolsOverview,
type RebalanceDisplayState,
} from "@/lib/pool-operations"
import { useMessage } from "@/lib/feedback/message"
import { niceBytes } from "@/lib/functions"
@@ -31,15 +34,60 @@ function shouldPoll(state: DecommissionDisplayState) {
return ["running", "canceling"].includes(state)
}
function formatBytesValue(value?: number) {
return value === undefined ? "--" : niceBytes(String(value))
}
function getPoolDisplayState(
status: DecommissionInfo | null,
supportState: PoolSupportState,
rebalanceState: RebalanceDisplayState,
isConfirming = false,
) {
return deriveDecommissionDisplayState(status, supportState, rebalanceState, isConfirming)
}
function formatPoolStatusLabel(state: DecommissionDisplayState, t: (key: string) => string) {
switch (state) {
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")
}
}
function getPoolStatusBadgeVariant(state: DecommissionDisplayState) {
if (state === "failed") return "destructive"
if (state === "completed") return "default"
return "secondary"
}
export default function PoolDecommissionPage() {
const { t } = useTranslation()
const message = useMessage()
const { getDecommissionViewModel, startDecommission, cancelDecommission } = usePoolOperations()
const { getPoolsOverview, getRebalanceStatus, getDecommissionStatus, startDecommission, cancelDecommission } =
usePoolOperations()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedPoolId, setSelectedPoolId] = useState("")
const [confirming, setConfirming] = useState(false)
const [activePoolId, setActivePoolId] = useState("")
const [confirmingPoolId, setConfirmingPoolId] = useState("")
const [overview, setOverview] = useState<PoolsOverview>({
pools: [] as PoolSummary[],
totalCapacity: 0,
@@ -48,8 +96,7 @@ export default function PoolDecommissionPage() {
poolCount: 0,
supportState: "unsupported" as const,
})
const [status, setStatus] = useState<DecommissionInfo | null>(null)
const [displayState, setDisplayState] = useState<DecommissionDisplayState>("ready")
const [statuses, setStatuses] = useState<Record<string, DecommissionInfo | null>>({})
const [rebalanceState, setRebalanceState] = useState<RebalanceDisplayState>("idle")
const pollRef = useRef<number | null>(null)
@@ -61,17 +108,24 @@ export default function PoolDecommissionPage() {
}
const loadData = useCallback(
async (showSpinner = true, nextPoolId = selectedPoolId, nextConfirming = confirming) => {
async (showSpinner = true) => {
if (showSpinner) setLoading(true)
setError(null)
try {
const view = await getDecommissionViewModel(nextPoolId || undefined, nextConfirming)
setOverview(view.overview)
setStatus(view.status)
setRebalanceState(view.rebalanceState)
setDisplayState(view.displayState)
if (!nextPoolId && view.overview.pools[0]?.id) {
setSelectedPoolId(view.overview.pools[0].id)
const [nextOverview, rebalanceStatus] = await Promise.all([
getPoolsOverview(),
getRebalanceStatus().catch(() => null),
])
const nextRebalanceState = deriveRebalanceDisplayState(rebalanceStatus, nextOverview.supportState)
const statusEntries = await Promise.all(
nextOverview.pools.map(async (pool) => [pool.id, await getDecommissionStatus(pool.id).catch(() => null)]),
)
setOverview(nextOverview)
setStatuses(Object.fromEntries(statusEntries) as Record<string, DecommissionInfo | null>)
setRebalanceState(nextRebalanceState)
if (!activePoolId && nextOverview.pools[0]?.id) {
setActivePoolId(nextOverview.pools[0].id)
}
} catch (loadError) {
setError((loadError as Error).message || t("Load Failed"))
@@ -79,7 +133,7 @@ export default function PoolDecommissionPage() {
if (showSpinner) setLoading(false)
}
},
[confirming, getDecommissionViewModel, selectedPoolId, t],
[activePoolId, getDecommissionStatus, getPoolsOverview, getRebalanceStatus, t],
)
useEffect(() => {
@@ -89,32 +143,38 @@ export default function PoolDecommissionPage() {
useEffect(() => {
clearPoll()
if (!shouldPoll(displayState)) return
if (
!Object.values(statuses).some((status) =>
shouldPoll(getPoolDisplayState(status, overview.supportState, rebalanceState)),
)
) {
return
}
pollRef.current = window.setTimeout(() => {
void loadData(false)
}, POLL_MS)
return clearPoll
}, [displayState, loadData])
}, [loadData, overview.supportState, rebalanceState, statuses])
useEffect(() => {
void loadData(false, selectedPoolId, confirming)
}, [confirming, loadData, selectedPoolId])
const activeStatus = statuses[activePoolId] ?? null
const activeDisplayState = getPoolDisplayState(
activeStatus,
overview.supportState,
rebalanceState,
confirmingPoolId === activePoolId,
)
const canCancelActive = activePoolId
? getPoolDisplayState(activeStatus, overview.supportState, rebalanceState) === "running"
: false
const selectedPool = overview.pools.find((pool) => pool.id === selectedPoolId) ?? null
const isBlocked = ["unsupported", "blocked-by-rebalance"].includes(displayState)
const canRequestStart =
!isBlocked && !!selectedPoolId && ["ready", "failed", "canceled", "completed"].includes(displayState)
const canConfirm = confirming && !!selectedPoolId && !isBlocked && !submitting
const canCancel = ["running"].includes(displayState)
const handleStart = async () => {
if (!selectedPoolId) return
const handleStart = async (poolId: string) => {
setSubmitting(true)
try {
await startDecommission(selectedPoolId)
setConfirming(false)
await startDecommission(poolId)
setConfirmingPoolId("")
setActivePoolId(poolId)
message.success(t("Pool decommission started"))
await loadData(false, selectedPoolId, false)
await loadData(false)
} catch (startError) {
message.error((startError as Error).message || t("Failed to start decommission"))
} finally {
@@ -122,13 +182,14 @@ export default function PoolDecommissionPage() {
}
}
const handleCancel = async () => {
if (!selectedPoolId) return
const handleCancel = async (poolId = activePoolId) => {
if (!poolId) return
setSubmitting(true)
try {
await cancelDecommission(selectedPoolId)
await cancelDecommission(poolId)
setActivePoolId(poolId)
message.success(t("Pool decommission cancel requested"))
await loadData(false, selectedPoolId, false)
await loadData(false)
} catch (cancelError) {
message.error((cancelError as Error).message || t("Failed to cancel decommission"))
} finally {
@@ -137,7 +198,7 @@ export default function PoolDecommissionPage() {
}
const statusLabel = useMemo(() => {
switch (displayState) {
switch (activeDisplayState) {
case "unsupported":
return t("Unsupported")
case "blocked-by-rebalance":
@@ -151,15 +212,33 @@ export default function PoolDecommissionPage() {
case "canceling":
return t("Canceling")
case "completed":
return t("Completed")
return t("Completed Status")
case "failed":
return t("Failed")
return t("Failed Status")
case "canceled":
return t("Canceled")
default:
return t("Unknown")
}
}, [displayState, t])
}, [activeDisplayState, t])
const poolRows = useMemo(
() =>
overview.pools.map((pool) => {
const rowStatus = statuses[pool.id] ?? null
return {
pool,
status: rowStatus,
displayState: getPoolDisplayState(
rowStatus,
overview.supportState,
rebalanceState,
confirmingPoolId === pool.id,
),
}
}),
[confirmingPoolId, overview.pools, overview.supportState, rebalanceState, statuses],
)
return (
<Page>
@@ -170,7 +249,7 @@ export default function PoolDecommissionPage() {
<RiRefreshLine className="me-2 size-4" />
{t("Sync")}
</Button>
<Button variant="outline" onClick={handleCancel} disabled={!canCancel || submitting}>
<Button variant="outline" onClick={() => void handleCancel()} disabled={!canCancelActive || submitting}>
{t("Cancel")}
</Button>
</>
@@ -182,14 +261,14 @@ export default function PoolDecommissionPage() {
<div className="space-y-6">
<PoolsOverviewCard overview={overview} operationLabel={t("Pool Decommission")} />
{displayState === "unsupported" ? (
{overview.supportState === "unsupported" ? (
<Alert>
<AlertTitle>{t("Single pool decommission is not supported")}</AlertTitle>
<AlertDescription>{t("Decommission requires more than one pool in the cluster.")}</AlertDescription>
</Alert>
) : null}
{displayState === "blocked-by-rebalance" ? (
{["starting", "running", "stopping", "failed", "stopped", "unknown"].includes(rebalanceState) ? (
<Alert>
<RiAlertLine className="size-4" />
<AlertTitle>{t("Rebalance must complete before decommission")}</AlertTitle>
@@ -214,7 +293,11 @@ export default function PoolDecommissionPage() {
</div>
<Badge
variant={
displayState === "failed" ? "destructive" : displayState === "completed" ? "default" : "secondary"
activeDisplayState === "failed"
? "destructive"
: activeDisplayState === "completed"
? "default"
: "secondary"
}
>
{statusLabel}
@@ -227,98 +310,154 @@ export default function PoolDecommissionPage() {
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-[280px_1fr]">
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t("Target Pool")}</p>
<Select
value={selectedPoolId}
onValueChange={(value) => {
setSelectedPoolId(value)
setConfirming(false)
}}
disabled={submitting || ["running", "canceling"].includes(displayState)}
>
<SelectTrigger>
<SelectValue placeholder={t("Select a pool")} />
</SelectTrigger>
<SelectContent>
{overview.pools.map((pool) => (
<SelectItem key={pool.id} value={pool.id}>
{pool.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid gap-4 md:grid-cols-4">
<div>
<p className="text-xs text-muted-foreground">{t("Rebalance Status")}</p>
<p className="text-sm font-medium">{rebalanceState}</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div>
<p className="text-xs text-muted-foreground">{t("Rebalance Status")}</p>
<p className="text-sm font-medium">{rebalanceState}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Progress")}</p>
<p className="text-sm font-medium">{Math.round(status?.progressPercent ?? 0)}%</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Objects")}</p>
<p className="text-sm font-medium">{status?.objects ?? "--"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
<p className="text-sm font-medium">{status?.bytes ? niceBytes(String(status.bytes)) : "--"}</p>
</div>
<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>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Progress")}</p>
<p className="text-sm font-medium">{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>
</div>
</div>
{selectedPool ? (
<div className="rounded-none border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">{selectedPool.name}</p>
<p className="text-sm text-muted-foreground">
{t("Used Capacity")}: {selectedPool.used ? niceBytes(String(selectedPool.used)) : "--"}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={!canRequestStart || submitting}
onClick={() => setConfirming(true)}
>
{t("Start Decommission")}
</Button>
</div>
</div>
</div>
) : null}
<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-right">{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 canRequestStart =
!submitting && ["ready", "failed", "canceled", "completed"].includes(rowState)
const canConfirm = !submitting && rowState === "confirming"
const canCancel = !submitting && rowState === "running"
{confirming ? (
return (
<TableRow
key={pool.id}
data-state={pool.id === activePoolId ? "selected" : undefined}
onClick={() => setActivePoolId(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">
<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>{rowStatus?.objects ?? "--"}</TableCell>
<TableCell>{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}
onClick={(event) => {
event.stopPropagation()
setActivePoolId(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 className="space-y-3">
<p>
{t(
"This action retires the selected pool and should be used only after verifying rebalance has completed.",
)}
</p>
<p>
{t("Selected Pool")}: {selectedPool?.name ?? "--"}
</p>
<div className="flex gap-2">
<Button onClick={handleStart} disabled={!canConfirm}>
{submitting ? <Spinner className="me-2 size-4" /> : null}
{t("Confirm")}
</Button>
<Button variant="outline" onClick={() => setConfirming(false)} disabled={submitting}>
{t("Cancel")}
</Button>
</div>
<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={status?.progressPercent ?? 0} className="h-2" />
<Progress value={activeStatus?.progressPercent ?? 0} className="h-2" />
</>
)}
</CardContent>
+31 -12
View File
@@ -23,7 +23,7 @@ import { niceBytes } from "@/lib/functions"
const POLL_MS = 5000
function formatDuration(seconds?: number) {
if (!seconds) return "--"
if (seconds === undefined) return "--"
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const remainSeconds = seconds % 60
@@ -36,6 +36,14 @@ function shouldPoll(state: RebalanceDisplayState) {
return ["starting", "running", "stopping"].includes(state)
}
function formatBytesValue(value?: number) {
return value === undefined ? "--" : niceBytes(String(value))
}
function formatNumberValue(value?: number) {
return value === undefined ? "--" : String(value)
}
export default function RebalancePage() {
const { t } = useTranslation()
const dialog = useDialog()
@@ -146,9 +154,9 @@ export default function RebalancePage() {
case "stopping":
return t("Stopping")
case "completed":
return t("Completed")
return t("Completed Status")
case "failed":
return t("Failed")
return t("Failed Status")
case "stopped":
return t("Stopped")
default:
@@ -156,6 +164,19 @@ export default function RebalancePage() {
}
}, [displayState, t])
const pools = useMemo(() => {
const statusPools = new Map((status?.pools ?? []).map((pool) => [pool.id, pool]))
return overview.pools.map((pool) => {
const statusPool = statusPools.get(pool.id)
if (!statusPool) return pool
return {
...pool,
status: statusPool.status || pool.status,
progress: statusPool.progress,
}
})
}, [overview.pools, status?.pools])
return (
<Page>
<PageHeader
@@ -230,9 +251,7 @@ export default function RebalancePage() {
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Bytes Moved")}</p>
<p className="text-sm font-medium">
{status?.totals?.bytes ? niceBytes(String(status.totals.bytes)) : "--"}
</p>
<p className="text-sm font-medium">{formatBytesValue(status?.totals?.bytes)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Elapsed")}</p>
@@ -258,21 +277,21 @@ export default function RebalancePage() {
</TableRow>
</TableHeader>
<TableBody>
{overview.pools.length === 0 ? (
{pools.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
{t("No Data")}
</TableCell>
</TableRow>
) : (
overview.pools.map((pool) => (
pools.map((pool) => (
<TableRow key={pool.id}>
<TableCell>{pool.name}</TableCell>
<TableCell>{pool.status || "--"}</TableCell>
<TableCell>{pool.used ? niceBytes(String(pool.used)) : "--"}</TableCell>
<TableCell>{pool.progress.bytes ? niceBytes(String(pool.progress.bytes)) : "--"}</TableCell>
<TableCell>{pool.progress.objects || "--"}</TableCell>
<TableCell>{pool.progress.versions || "--"}</TableCell>
<TableCell>{formatBytesValue(pool.used)}</TableCell>
<TableCell>{formatBytesValue(pool.progress.bytes)}</TableCell>
<TableCell>{formatNumberValue(pool.progress.objects)}</TableCell>
<TableCell>{formatNumberValue(pool.progress.versions)}</TableCell>
</TableRow>
))
)}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "يجب أن يكون طول اسم المستخدم بين 3 و128 حرفًا",
"username length cannot be less than 8 characters and greater than 16 characters": "لا يمكن أن يكون طول اسم المستخدم أقل من 8 أحرف وأكثر من 16 حرفًا",
"waiting": "في الانتظار"
"waiting": "في الانتظار",
"Completed Status": "مكتمل",
"Active Pool": "التجمع النشط"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "Der Benutzername muss zwischen 3 und 128 Zeichen lang sein",
"username length cannot be less than 8 characters and greater than 16 characters": "Benutzernamenlänge darf nicht weniger als 8 Zeichen und nicht mehr als 16 Zeichen sein",
"waiting": "Warten"
"waiting": "Warten",
"Completed Status": "Abgeschlossen",
"Active Pool": "Aktiver Pool"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "Username length must be between 3 and 128 characters",
"username length cannot be less than 8 characters and greater than 16 characters": "username length cannot be less than 8 characters and greater than 16 characters",
"waiting": "Waiting"
"waiting": "Waiting",
"Completed Status": "Completed",
"Active Pool": "Active Pool"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "La longitud del nombre de usuario debe estar entre 3 y 128 caracteres",
"username length cannot be less than 8 characters and greater than 16 characters": "La longitud del nombre de usuario no puede ser menor de 8 caracteres y mayor de 16 caracteres",
"waiting": "Esperando"
"waiting": "Esperando",
"Completed Status": "Completada",
"Active Pool": "Pool activo"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "Le nom d'utilisateur doit contenir entre 3 et 128 caractères",
"username length cannot be less than 8 characters and greater than 16 characters": "la longueur du nom d'utilisateur ne peut pas être inférieure à 8 caractères et supérieure à 16 caractères",
"waiting": "En attente"
"waiting": "En attente",
"Completed Status": "Terminé",
"Active Pool": "Pool actif"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "Panjang nama pengguna harus antara 3 dan 128 karakter",
"username length cannot be less than 8 characters and greater than 16 characters": "panjang nama pengguna tidak boleh kurang dari 8 karakter dan lebih dari 16 karakter",
"waiting": "Menunggu"
"waiting": "Menunggu",
"Completed Status": "Selesai",
"Active Pool": "Pool aktif"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "La lunghezza del nome utente deve essere compresa tra 3 e 128 caratteri",
"username length cannot be less than 8 characters and greater than 16 characters": "La lunghezza del nome utente non può essere inferiore a 8 caratteri e superiore a 16 caratteri",
"waiting": "In attesa"
"waiting": "In attesa",
"Completed Status": "Completato",
"Active Pool": "Pool attivo"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "ユーザー名は3文字以上128文字以下で入力してください",
"username length cannot be less than 8 characters and greater than 16 characters": "ユーザー名の長さは8文字未満、16文字を超えることはできません",
"waiting": "待機中"
"waiting": "待機中",
"Completed Status": "完了しました",
"Active Pool": "アクティブなプール"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "사용자 이름은 3자 이상 128자 이하여야 합니다",
"username length cannot be less than 8 characters and greater than 16 characters": "사용자 이름 길이는 8자 미만, 16자 초과일 수 없습니다",
"waiting": "대기 중"
"waiting": "대기 중",
"Completed Status": "완료",
"Active Pool": "활성 풀"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "O nome de usuário deve ter entre 3 e 128 caracteres",
"username length cannot be less than 8 characters and greater than 16 characters": "O comprimento do nome de usuário não pode ser menor que 8 caracteres e maior que 16 caracteres",
"waiting": "Aguardando"
"waiting": "Aguardando",
"Completed Status": "Concluído",
"Active Pool": "Pool ativo"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "Длина имени пользователя должна быть от 3 до 128 символов",
"username length cannot be less than 8 characters and greater than 16 characters": "Длина имени пользователя не может быть менее 8 символов и более 16 символов",
"waiting": "Ожидание"
"waiting": "Ожидание",
"Completed Status": "Завершено",
"Active Pool": "Активный пул"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "güncelleme:görünür",
"username length cannot be less than 3 characters and greater than 128 characters": "Kullanıcı adı uzunluğu 3 ile 128 karakter arasında olmalıdır",
"username length cannot be less than 8 characters and greater than 16 characters": "Kullanıcı adı uzunluğu 8 karakterden az 16 karakterden büyük olamaz",
"waiting": "Bekliyor"
"waiting": "Bekliyor",
"Completed Status": "Tamamlandı",
"Active Pool": "Etkin pool"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "Độ dài tên người dùng phải từ 3 đến 128 ký tự",
"username length cannot be less than 8 characters and greater than 16 characters": "độ dài tên người dùng phải từ 8 đến 16 ký tự",
"waiting": "Đang chờ"
"waiting": "Đang chờ",
"Completed Status": "Đã hoàn thành",
"Active Pool": "Pool đang hoạt động"
}
+3 -1
View File
@@ -1305,5 +1305,7 @@
"update:visible": "update:visible",
"username length cannot be less than 3 characters and greater than 128 characters": "用户名长度不能少于 3 个字符且不能超过 128 个字符",
"username length cannot be less than 8 characters and greater than 16 characters": "用户名长度不能少于8个字符且不能大于16个字符",
"waiting": "等待中"
"waiting": "等待中",
"Completed Status": "已完成",
"Active Pool": "当前存储池"
}
+66 -6
View File
@@ -120,6 +120,23 @@ function normalizeProgress(value: unknown): PoolUsageProgress {
}
}
function hasProgress(progress: PoolUsageProgress): boolean {
return Boolean(progress.bytes || progress.objects || progress.versions || progress.eta || progress.elapsed)
}
function aggregatePoolProgress(pools: PoolSummary[]): PoolUsageProgress {
return pools.reduce(
(totals, pool) => ({
bytes: totals.bytes + pool.progress.bytes,
objects: totals.objects + pool.progress.objects,
versions: totals.versions + pool.progress.versions,
eta: Math.max(totals.eta, pool.progress.eta),
elapsed: Math.max(totals.elapsed, pool.progress.elapsed),
}),
{ bytes: 0, objects: 0, versions: 0, eta: 0, elapsed: 0 },
)
}
function pickStatus(record: JsonRecord): string {
return (
asString(record.status) ||
@@ -132,8 +149,24 @@ function pickStatus(record: JsonRecord): string {
function normalizePool(value: unknown, index: number): PoolSummary {
const record = asRecord(value)
const total = asNumber(record.total || record.capacity || record.totalSize || record.Total || record.TotalSize)
const used = asNumber(record.used || record.usedCapacity || record.Used || record.UsedCapacity)
const decommissionInfo = asRecord(record.decommissionInfo || record.DecommissionInfo)
const total = asNumber(
record.total ||
record.capacity ||
record.totalSize ||
record.Total ||
record.TotalSize ||
decommissionInfo.totalSize ||
decommissionInfo.TotalSize,
)
const used = asNumber(
record.used ||
record.usedCapacity ||
record.Used ||
record.UsedCapacity ||
decommissionInfo.currentSize ||
decommissionInfo.CurrentSize,
)
const available =
asNumber(record.available || record.availableCapacity || record.Available || record.AvailableCapacity) ||
Math.max(total - used, 0)
@@ -143,7 +176,7 @@ function normalizePool(value: unknown, index: number): PoolSummary {
return {
id: String(rawId ?? index),
name: asString(record.name) || `Pool ${String(rawId ?? index)}`,
name: asString(record.name) || asString(record.cmdline) || `Pool ${String(rawId ?? index)}`,
total,
used,
available,
@@ -185,16 +218,20 @@ export function normalizeRebalanceStatus(value: unknown): RebalanceStatus {
? asArray(record.Pools)
: []
const pools = poolsSource.map((pool, index) => normalizePool(pool, index))
const totals = normalizeProgress(record.progress || record.Progress || record.totals || record.Totals)
const explicitTotals = normalizeProgress(record.progress || record.Progress || record.totals || record.Totals)
const totals = hasProgress(explicitTotals) ? 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
? (pools.reduce((sum, pool) => sum + pool.progress.bytes, 0) / totals.bytes) * 100
: 0)
: ["completed", "complete", "success", "finished"].includes(normalizeState(status))
? 100
: 0)
return {
id: asString(record.id) || asString(record.ID),
status: pickStatus(record),
status,
startedAt: asString(record.startedAt || record.StartedAt) || undefined,
updatedAt: asString(record.updatedAt || record.UpdatedAt) || undefined,
stoppedAt: asString(record.stoppedAt || record.StoppedAt) || undefined,
@@ -234,6 +271,29 @@ function normalizeState(value: string): string {
return value.trim().toLowerCase()
}
function deriveStatusFromPools(pools: PoolSummary[]): string {
if (pools.length === 0) return ""
const states = pools.map((pool) => normalizeState(pool.status))
if (states.some((state) => ["failed", "error"].includes(state))) return "failed"
if (states.some((state) => ["stopping", "stop_requested", "stop-requested"].includes(state))) return "stopping"
if (
states.some((state) =>
["running", "in_progress", "in-progress", "progressing", "starting", "started"].includes(state),
)
) {
return "running"
}
if (
states.every((state) =>
["completed", "complete", "success", "finished", "none", "not_started", "not-started"].includes(state),
)
) {
return "completed"
}
return ""
}
export function deriveRebalanceDisplayState(
status: RebalanceStatus | null,
supportState: PoolSupportState,
+12 -18
View File
@@ -25,28 +25,22 @@ function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
test("config: backward-compat — empty prefix yields the original URLs", () => {
configManager.clearCache()
withEnv(
{ [PREFIX_ENV]: "", [HOST_ENV]: "https://app.example.com" },
() => {
const cfg = configManager.loadRuntimeConfig()
assert.ok(cfg)
assert.equal(cfg.s3.endpoint, "https://app.example.com")
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/admin/v3")
},
)
withEnv({ [PREFIX_ENV]: "", [HOST_ENV]: "https://app.example.com" }, () => {
const cfg = configManager.loadRuntimeConfig()
assert.ok(cfg)
assert.equal(cfg.s3.endpoint, "https://app.example.com")
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/admin/v3")
})
})
test("config: loadRuntimeConfig adds the prefix to api.baseURL only (s3.endpoint stays clean)", () => {
configManager.clearCache()
withEnv(
{ [PREFIX_ENV]: "/rustfs/api", [HOST_ENV]: "https://app.example.com" },
() => {
const cfg = configManager.loadRuntimeConfig()
assert.ok(cfg)
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/api/rustfs/admin/v3")
assert.equal(cfg.s3.endpoint, "https://app.example.com")
},
)
withEnv({ [PREFIX_ENV]: "/rustfs/api", [HOST_ENV]: "https://app.example.com" }, () => {
const cfg = configManager.loadRuntimeConfig()
assert.ok(cfg)
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/api/rustfs/admin/v3")
assert.equal(cfg.s3.endpoint, "https://app.example.com")
})
})
test("config: createDefaultConfig adds the prefix to api.baseURL only (browser-fallback path)", () => {
+13
View File
@@ -0,0 +1,13 @@
import test from "node:test"
import assert from "node:assert/strict"
import fs from "node:fs"
test("pool decommission page renders pool selection as a table instead of a select", () => {
const source = fs.readFileSync("app/(dashboard)/pool-decommission/page.tsx", "utf8")
assert.equal(source.includes('from "@/components/ui/table"'), true)
assert.equal(source.includes('from "@/components/ui/select"'), false)
assert.doesNotMatch(source, /<Select\b/)
assert.match(source, /<Table\b/)
assert.match(source, /poolRows\.map/)
})
+121
View File
@@ -22,6 +22,53 @@ test("normalizePoolsOverview computes support and capacities", () => {
assert.equal(overview.supportState, "supported")
})
test("normalizePoolsOverview reads pool list decommissionInfo capacities", () => {
const overview = normalizePoolsOverview([
{
id: 0,
cmdline: "http://172.30.0.{11...14}:9000/data/disk{1...2}",
lastUpdate: "2026-05-09T01:06:58.577822385Z",
decommissionInfo: {
startTime: null,
startSize: 0,
totalSize: 980428783616,
currentSize: 55435714560,
complete: false,
failed: false,
canceled: false,
objectsDecommissioned: 0,
objectsDecommissionedFailed: 0,
bytesDecommissioned: 0,
bytesDecommissionedFailed: 0,
},
},
{
id: 1,
cmdline: "http://172.30.0.{15...18}:9000/data/disk{1...2}",
lastUpdate: "2026-05-09T01:06:58.577822801Z",
decommissionInfo: {
startTime: null,
startSize: 0,
totalSize: 980428783616,
currentSize: 55434608640,
complete: false,
failed: false,
canceled: false,
objectsDecommissioned: 0,
objectsDecommissionedFailed: 0,
bytesDecommissioned: 0,
bytesDecommissionedFailed: 0,
},
},
])
assert.equal(overview.poolCount, 2)
assert.equal(overview.totalCapacity, 1960857567232)
assert.equal(overview.totalUsedCapacity, 110870323200)
assert.equal(overview.pools[0]?.name, "http://172.30.0.{11...14}:9000/data/disk{1...2}")
assert.equal(overview.pools[0]?.used, 55435714560)
})
test("normalizeRebalanceStatus reads progress and pool details", () => {
const status = normalizeRebalanceStatus({
id: "reb-1",
@@ -35,6 +82,80 @@ test("normalizeRebalanceStatus reads progress and pool details", () => {
assert.equal(status.pools[0]?.progress.bytes, 400)
})
test("normalizeRebalanceStatus derives status from pool-only response", () => {
const status = normalizeRebalanceStatus({
id: "3be0831f-4315-4adb-9904-3bb0609b3bfc",
pools: [
{
id: 0,
status: "Completed",
used: 0.9357573544019193,
lastError: null,
progress: {
objects: 0,
versions: 0,
bytes: 0,
remainingBuckets: 0,
bucket: "",
object: "",
elapsed: 0,
eta: 0,
},
},
{
id: 1,
status: "None",
used: 0.9357573544019193,
lastError: null,
progress: null,
},
],
stoppedAt: null,
})
assert.equal(status.status, "completed")
assert.equal(status.progressPercent, 100)
assert.equal(status.pools[0]?.status, "Completed")
assert.equal(status.pools[1]?.status, "None")
assert.equal(deriveRebalanceDisplayState(status, "supported"), "completed")
})
test("normalizeRebalanceStatus aggregates pool progress when totals are missing", () => {
const status = normalizeRebalanceStatus({
id: "reb-2",
pools: [
{
id: 0,
status: "Running",
progress: {
objects: 4,
versions: 2,
bytes: 128,
elapsed: 10,
eta: 40,
},
},
{
id: 1,
status: "Running",
progress: {
objects: 6,
versions: 3,
bytes: 256,
elapsed: 15,
eta: 20,
},
},
],
})
assert.equal(status.totals.bytes, 384)
assert.equal(status.totals.objects, 10)
assert.equal(status.totals.versions, 5)
assert.equal(status.totals.elapsed, 15)
assert.equal(status.totals.eta, 40)
})
test("normalizeDecommissionInfo reads nested response", () => {
const info = normalizeDecommissionInfo({
decommissionInfo: {
+13
View File
@@ -0,0 +1,13 @@
import test from "node:test"
import assert from "node:assert/strict"
import fs from "node:fs"
test("operation status badges do not use count-based translation keys", () => {
const files = ["app/(dashboard)/rebalance/page.tsx", "app/(dashboard)/pool-decommission/page.tsx"]
const source = files.map((file) => fs.readFileSync(file, "utf8")).join("\n")
assert.equal(source.includes('t("Completed")'), false)
assert.equal(source.includes('t("Failed")'), false)
assert.equal(source.includes('t("Completed Status")'), true)
assert.equal(source.includes('t("Failed Status")'), true)
})