diff --git a/app/(dashboard)/rebalance/page.tsx b/app/(dashboard)/rebalance/page.tsx
index 0beee2ef..8249f461 100644
--- a/app/(dashboard)/rebalance/page.tsx
+++ b/app/(dashboard)/rebalance/page.tsx
@@ -49,6 +49,14 @@ function isFailedRebalancePool(pool: PoolSummary) {
return ["failed", "error"].includes(pool.status.trim().toLowerCase())
}
+function formatCleanupWarnings(pool: PoolSummary) {
+ const warnings = pool.cleanupWarnings
+ if (!warnings.count) return "--"
+
+ const context = [warnings.lastBucket, warnings.lastObject].filter(Boolean).join("/")
+ return context ? `${warnings.count} (${context})` : String(warnings.count)
+}
+
export default function RebalancePage() {
const { t } = useTranslation()
const dialog = useDialog()
@@ -178,6 +186,7 @@ export default function RebalancePage() {
...pool,
status: statusPool.status || pool.status,
progress: statusPool.progress,
+ cleanupWarnings: statusPool.cleanupWarnings,
}
})
}, [overview.pools, status?.pools])
@@ -279,24 +288,37 @@ export default function RebalancePage() {
{t("Bytes Moved")}
{t("Objects")}
{t("Versions")}
+ {t("Cleanup Warnings")}
{pools.length === 0 ? (
-
+
{t("No Data")}
) : (
pools.map((pool) => (
-
+
{pool.name}
{pool.status || "--"}
{formatBytesValue(pool.used)}
{formatBytesValue(pool.progress.bytes)}
{formatNumberValue(pool.progress.objects)}
{formatNumberValue(pool.progress.versions)}
+
+ {pool.cleanupWarnings.count > 0 ? (
+
+ {formatCleanupWarnings(pool)}
+
+ ) : (
+ "--"
+ )}
+
))
)}
diff --git a/lib/pool-operations.ts b/lib/pool-operations.ts
index 87075b1d..3935765e 100644
--- a/lib/pool-operations.ts
+++ b/lib/pool-operations.ts
@@ -29,6 +29,14 @@ export interface PoolUsageProgress {
elapsed: number
}
+export interface RebalanceCleanupWarnings {
+ count: number
+ lastMessage?: string
+ lastBucket?: string
+ lastObject?: string
+ lastAt?: string
+}
+
export interface PoolDecommissionSummary {
startTime?: string
startSize: number
@@ -54,6 +62,7 @@ export interface PoolSummary {
lastUpdate?: string
status: string
progress: PoolUsageProgress
+ cleanupWarnings: RebalanceCleanupWarnings
decommission: PoolDecommissionSummary
}
@@ -138,6 +147,17 @@ function normalizeProgress(value: unknown): PoolUsageProgress {
}
}
+function normalizeCleanupWarnings(value: unknown): RebalanceCleanupWarnings {
+ const record = asRecord(value)
+ return {
+ count: asNumber(record.count || record.Count),
+ lastMessage: asString(record.lastMsg || record.LastMsg || record.lastMessage || record.LastMessage) || undefined,
+ lastBucket: asString(record.lastBucket || record.LastBucket) || undefined,
+ lastObject: asString(record.lastObject || record.LastObject) || undefined,
+ lastAt: asString(record.lastAt || record.LastAt) || undefined,
+ }
+}
+
function normalizeDecommissionSummary(value: unknown): PoolDecommissionSummary {
const record = asRecord(value)
return {
@@ -185,12 +205,12 @@ function hasExplicitProgressPercent(record: JsonRecord): boolean {
function deriveRebalanceProgressPercent(status: string, pools: PoolSummary[]): number {
const state = normalizeState(status)
- if (["completed", "complete", "success", "finished"].includes(state)) return 100
+ if (["completed", "complete", "success", "finished"].includes(state) && pools.every(isCompletedRebalancePool)) {
+ return 100
+ }
if (pools.length === 0) return 0
- const finishedPools = pools.filter((pool) =>
- ["completed", "complete", "success", "finished"].includes(normalizeState(pool.status)),
- ).length
+ const finishedPools = pools.filter(isCompletedRebalancePool).length
return Math.round((finishedPools / pools.length) * 100)
}
@@ -224,6 +244,7 @@ function normalizePool(value: unknown, index: number): PoolSummary {
rawUsagePercent > 0 && rawUsagePercent <= 1 ? rawUsagePercent * 100 : rawUsagePercent,
)
const progress = normalizeProgress(record.progress || record.Progress)
+ const cleanupWarnings = normalizeCleanupWarnings(record.cleanupWarnings || record.CleanupWarnings)
const rawId =
record.id ?? record.poolId ?? record.pool ?? record.index ?? record.ID ?? record.PoolID ?? record.poolIndex
@@ -238,6 +259,7 @@ function normalizePool(value: unknown, index: number): PoolSummary {
lastUpdate: asString(record.lastUpdate || record.LastUpdate || record.updatedAt || record.UpdatedAt) || undefined,
status: pickStatus(record),
progress,
+ cleanupWarnings,
decommission,
}
}
@@ -327,6 +349,14 @@ function normalizeState(value: string): string {
return value.trim().toLowerCase()
}
+function isIdleRebalancePool(pool: PoolSummary): boolean {
+ return ["", "none", "not_started", "not-started", "idle"].includes(normalizeState(pool.status))
+}
+
+function isCompletedRebalancePool(pool: PoolSummary): boolean {
+ return ["completed", "complete", "success", "finished"].includes(normalizeState(pool.status))
+}
+
function deriveStatusFromPools(pools: PoolSummary[]): string {
if (pools.length === 0) return ""
@@ -340,13 +370,11 @@ function deriveStatusFromPools(pools: PoolSummary[]): string {
) {
return "running"
}
- if (
- states.every((state) =>
- ["completed", "complete", "success", "finished", "none", "not_started", "not-started"].includes(state),
- )
- ) {
+ if (pools.every(isCompletedRebalancePool)) {
return "completed"
}
+ if (pools.some(isCompletedRebalancePool) && pools.some(isIdleRebalancePool)) return "running"
+ if (pools.every(isIdleRebalancePool)) return ""
return ""
}
diff --git a/tests/lib/pool-operations.test.js b/tests/lib/pool-operations.test.js
index 7aa59f86..dc54eb37 100644
--- a/tests/lib/pool-operations.test.js
+++ b/tests/lib/pool-operations.test.js
@@ -115,15 +115,31 @@ test("normalizeRebalanceStatus reads progress and pool details", () => {
id: "reb-1",
status: "running",
progress: { bytes: "1000", objects: "10", versions: "3", eta: "12", elapsed: "6" },
- pools: [{ id: 1, used: "20", progress: { bytes: "400" } }],
+ pools: [
+ {
+ id: 1,
+ used: "20",
+ progress: { bytes: "400" },
+ cleanupWarnings: {
+ count: 1,
+ lastMsg: "cleanup warning",
+ lastBucket: "test-bucket",
+ lastObject: "object-a",
+ lastAt: "2026-06-12T00:00:00Z",
+ },
+ },
+ ],
})
assert.equal(status.id, "reb-1")
assert.equal(status.progressPercent, 40)
assert.equal(status.pools[0]?.progress.bytes, 400)
+ assert.equal(status.pools[0]?.cleanupWarnings.count, 1)
+ assert.equal(status.pools[0]?.cleanupWarnings.lastMessage, "cleanup warning")
+ assert.equal(status.pools[0]?.cleanupWarnings.lastBucket, "test-bucket")
})
-test("normalizeRebalanceStatus derives status from pool-only response", () => {
+test("normalizeRebalanceStatus does not complete mixed completed and idle pools", () => {
const status = normalizeRebalanceStatus({
id: "3be0831f-4315-4adb-9904-3bb0609b3bfc",
pools: [
@@ -154,11 +170,11 @@ test("normalizeRebalanceStatus derives status from pool-only response", () => {
stoppedAt: null,
})
- assert.equal(status.status, "completed")
- assert.equal(status.progressPercent, 100)
+ assert.equal(status.status, "running")
+ assert.equal(status.progressPercent, 50)
assert.equal(status.pools[0]?.status, "Completed")
assert.equal(status.pools[1]?.status, "None")
- assert.equal(deriveRebalanceDisplayState(status, "supported"), "completed")
+ assert.equal(deriveRebalanceDisplayState(status, "supported"), "running")
})
test("normalizeRebalanceStatus aggregates pool progress when totals are missing", () => {
diff --git a/tests/lib/pool-overview-source.test.js b/tests/lib/pool-overview-source.test.js
index a9781350..0fe836ea 100644
--- a/tests/lib/pool-overview-source.test.js
+++ b/tests/lib/pool-overview-source.test.js
@@ -16,7 +16,7 @@ test("rebalance page highlights failed pool rows", () => {
assert.match(source, /function isFailedRebalancePool\(pool: PoolSummary\)/)
assert.match(source, /\["failed", "error"\]\.includes\(pool\.status\.trim\(\)\.toLowerCase\(\)\)/)
- assert.match(source, /className=\{cn\(isFailedRebalancePool\(pool\) && "bg-destructive\/10"\)\}/)
+ assert.match(source, /bg-destructive\/15 hover:bg-destructive\/20/)
})
test("decommission page keeps decommission detail columns", () => {