mirror of
https://github.com/rustfs/console.git
synced 2026-08-30 17:14:47 +08:00
fix: show pool operation status details
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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": "التجمع النشط"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "アクティブなプール"
|
||||
}
|
||||
|
||||
@@ -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": "활성 풀"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Активный пул"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user