From c375569947c0b214db8ed3ab107971f1f05a5e0f Mon Sep 17 00:00:00 2001 From: cxymds Date: Tue, 23 Jun 2026 17:44:11 +0800 Subject: [PATCH] feat: redesign pool decommission page --- app/(dashboard)/pool-decommission/page.tsx | 768 +++++++++++++-------- hooks/use-pool-operations.ts | 9 + i18n/locales/ar-MA.json | 10 +- i18n/locales/de-DE.json | 10 +- i18n/locales/en-US.json | 10 +- i18n/locales/es-ES.json | 10 +- i18n/locales/fr-FR.json | 10 +- i18n/locales/id-ID.json | 10 +- i18n/locales/it-IT.json | 10 +- i18n/locales/ja-JP.json | 10 +- i18n/locales/ko-KR.json | 10 +- i18n/locales/pt-BR.json | 10 +- i18n/locales/ru-RU.json | 10 +- i18n/locales/tr-TR.json | 10 +- i18n/locales/vi-VN.json | 10 +- i18n/locales/zh-CN.json | 10 +- lib/pool-operations.ts | 72 +- tests/lib/pool-decommission-page.test.js | 21 +- tests/lib/pool-operations.test.js | 104 +++ tests/lib/pool-overview-source.test.js | 10 +- 20 files changed, 799 insertions(+), 325 deletions(-) diff --git a/app/(dashboard)/pool-decommission/page.tsx b/app/(dashboard)/pool-decommission/page.tsx index 435df83..e551d78 100644 --- a/app/(dashboard)/pool-decommission/page.tsx +++ b/app/(dashboard)/pool-decommission/page.tsx @@ -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 ( +
+ + {Math.round(value)}% +
+ ) +} + 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(null) - const [activePoolId, setActivePoolId] = useState("") const [selectedPoolId, setSelectedPoolId] = useState("") const [confirmingPoolId, setConfirmingPoolId] = useState("") const [overview, setOverview] = useState({ @@ -129,9 +201,6 @@ export default function PoolDecommissionPage() { setOverview(nextOverview) setStatuses(Object.fromEntries(statusEntries) as Record) 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 ( - - - + } >

{t("Pool Decommission")}

- + {trackedTask ? ( + + +
+ {t("Current Pool Decommission Status")} +

{trackedTask.pool.name}

+
+
+ + {formatPoolStatusLabel(trackedTask, t)} + + +
+
+ +
+ + + {Math.round(trackedProgress)}% + +
+
+
+

{t("Bytes Moved")}

+

{formatBytesValue(trackedTask.status?.bytes)}

+
+
+

{t("Remaining Bytes")}

+

+ {formatBytesValue(getRemainingBytes(trackedTask.status, trackedTask.pool))} +

+
+
+

{t("Objects")}

+

{formatInteger(trackedTask.status?.objects)}

+
+
+

{t("Objects Failed")}

+

{formatInteger(trackedTask.status?.objectsFailed)}

+
+
+

{t("Bytes Failed")}

+

{formatBytesValue(trackedTask.status?.bytesFailed)}

+
+
+

{t("Start Time")}

+

{formatDateTime(trackedTask.status?.startedAt)}

+
+
+

{t("Updated At")}

+

{formatDateTime(trackedTask.status?.updatedAt)}

+
+
+
+
+

{t("Current Object")}

+

{getCurrentObject(trackedTask.status)}

+
+
+

{t("State")}

+

+ {trackedTask.status?.stage || trackedTask.status?.status || "--"} +

+
+
+

{t("Waiting Reason")}

+

{trackedTask.status?.waitingReason || "--"}

+
+
+
+
+ ) : null} {overview.supportState === "unsupported" ? ( @@ -293,202 +413,264 @@ export default function PoolDecommissionPage() { ) : null} - - -
- {t("Current Pool Decommission Status")} -

- {t("Select a pool, review its impact, then start or monitor retirement.")} -

-
- - {statusLabel} - -
- - {loading ? ( -
- +
+ + +
+ {t("Pool Decommission")} +

+ {selectionLocked + ? t("Selection Locked") + : t("Select a pool, review its impact, then start or monitor retirement.")} +

- ) : ( - <> -
+ + {selectionLocked ? t("Running") : t("Ready")} + + + + {loading ? ( +
+ +
+ ) : ( + <> + + + + {t("ID")} + {t("Pool")} + {t("Status")} + {t("Total Capacity")} + {t("Used Capacity")} + {t("Usage")} + {t("Progress")} + {t("Bytes Moved")} + {t("Actions")} + + + + {poolRows.length === 0 ? ( + + + {t("No Data")} + + + ) : ( + 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 ( + { + if (selectionLocked) return + setSelectedPoolId(pool.id) + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + if (selectionLocked) return + setSelectedPoolId(pool.id) + }} + > + {pool.id} + {pool.name} + + + {formatPoolStatusLabel(row, t)} + + + {formatBytesValue(pool.total)} + {formatBytesValue(pool.used)} + + + + + {showProgress ? : "--"} + + {showProgress ? formatBytesValue(rowStatus?.bytes) : "--"} + +
+ {rowState === "confirming" ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+
+ ) + }) + )} +
+
+ + {confirmingPoolId ? ( + + {t("Review before decommission")} + + {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 ?? "--"} + + + ) : null} + + )} +
+ + +
+ + + {t("Selected Pool")} + + +
+

{selectionLocked ? t("Selection Locked") : t("Pool")}

+

{selectedPoolName}

+
+
-

{t("Active Pool")}

-

- {overview.pools.find((pool) => pool.id === activePoolId)?.name ?? "--"} -

+

{t("Total Capacity")}

+

{formatBytesValue(selectedRow?.pool.total)}

-

{t("Selected Pool")}

-

{selectedPoolName}

+

{t("Used Capacity")}

+

{formatBytesValue(selectedRow?.pool.used)}

-

{t("Progress")}

-

- {showActiveProgress ? `${Math.round(activeStatus?.progressPercent ?? 0)}%` : "--"} -

+

{t("Available")}

+

{formatBytesValue(selectedRow?.pool.available)}

-

{t("Bytes Moved")}

-

- {showActiveProgress ? formatBytesValue(activeStatus?.bytes) : "--"} -

+

{t("Updated At")}

+

{formatDateTime(selectedRow?.pool.lastUpdate)}

+
+
+ {t("Usage")} + {Math.round(selectedRow?.pool.usagePercent ?? 0)}% +
+ +
+
+
- - - - {t("Pool")} - {t("Status")} - {t("Used Capacity")} - {t("Progress")} - {t("Objects")} - {t("Bytes Moved")} - {t("Actions")} - - - - {poolRows.length === 0 ? ( - - - {t("No Data")} - - - ) : ( - 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 ( - setSelectedPoolId(pool.id)} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - setSelectedPoolId(pool.id) - }} - > - {pool.name} - - - {formatPoolStatusLabel(rowState, t)} - - - {formatBytesValue(pool.used)} - - {showProgress ? ( -
- - - {Math.round(rowStatus?.progressPercent ?? 0)}% - -
- ) : ( - "--" - )} -
- {showProgress ? (rowStatus?.objects ?? "--") : "--"} - {showProgress ? formatBytesValue(rowStatus?.bytes) : "--"} - -
- {rowState === "confirming" ? ( - <> - - - - ) : ( - <> - - - - )} -
-
-
- ) - }) - )} -
-
- - {confirmingPoolId ? ( - - {t("Review before decommission")} - - {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 ?? "--"} - - - ) : null} - - - - )} - - + + + {t("Actions")} + + +
+
+

{t("Start Decommission")}

+

{t("Ready")}

+
+ {t("Ready")} +
+
+
+

{t("Cancel")}

+

{t("Running")}

+
+ {t("Running")} +
+
+
+

{t("Clear Decommission")}

+

+ {t("Failed Status")} / {t("Canceled")} +

+
+ {t("Clear Records")} +
+
+
+
+
) diff --git a/hooks/use-pool-operations.ts b/hooks/use-pool-operations.ts index 4962395..b281168 100644 --- a/hooks/use-pool-operations.ts +++ b/hooks/use-pool-operations.ts @@ -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 => { const [overview, status] = await Promise.all([getPoolsOverview(), getRebalanceStatus().catch(() => null)]) return { @@ -121,6 +129,7 @@ export function usePoolOperations() { getDecommissionStatus, startDecommission, cancelDecommission, + clearDecommission, getRebalanceViewModel, getDecommissionViewModel, } diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 97020f0..7d2b031 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -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" } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 42fdbbb..e8b4df3 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -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" } diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 711cce3..d590d37 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -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" } diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index 01e8418..e843e69 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -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" } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 51cd82f..a3acaed 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -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" } diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 4a16e83..28e2c79 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -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" } diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 54bcf9e..7d83240 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -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" } diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index ed9f345..18bd7fd 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -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" } diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 2e562d2..b3e1856 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -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" } diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 6eb27ef..59493a4 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -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" } diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index 747e30d..c74c93d 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -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" } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 3ad893f..a76e4ed 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -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" } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index 6245ecb..f37bf48 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -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" } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index c2cf158..a753788 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -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": "清理退役" } diff --git a/lib/pool-operations.ts b/lib/pool-operations.ts index 1df7038..3e1a433 100644 --- a/lib/pool-operations.ts +++ b/lib/pool-operations.ts @@ -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" diff --git a/tests/lib/pool-decommission-page.test.js b/tests/lib/pool-decommission-page.test.js index b3e5516..d7597a9 100644 --- a/tests/lib/pool-decommission-page.test.js +++ b/tests/lib/pool-decommission-page.test.js @@ -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"\)/) +}) diff --git a/tests/lib/pool-operations.test.js b/tests/lib/pool-operations.test.js index 7eb7217..b9b1db3 100644 --- a/tests/lib/pool-operations.test.js +++ b/tests/lib/pool-operations.test.js @@ -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", diff --git a/tests/lib/pool-overview-source.test.js b/tests/lib/pool-overview-source.test.js index 0fe836e..83e4245 100644 --- a/tests/lib/pool-overview-source.test.js +++ b/tests/lib/pool-overview-source.test.js @@ -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, - //, - ) + assert.doesNotMatch(source, /\{t\("Usage"\)\}<\/TableHead>/) + assert.match(source, /\{t\("Progress"\)\}<\/TableHead>/) + assert.match(source, /\{t\("Bytes Moved"\)\}<\/TableHead>/) }) test("pool overview card gates decommission-specific columns", () => {