mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
feat: redesign pool decommission page
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"\)/)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user