From 09518bf05f9cd052f0c4309d874f8f04a6b4dbd1 Mon Sep 17 00:00:00 2001 From: overtrue Date: Mon, 13 Jul 2026 07:44:40 +0800 Subject: [PATCH 1/2] fix(admin): show heal runtime state --- crates/cli/src/commands/admin/heal.rs | 60 +++++++++++++++++++++++---- crates/core/src/admin/cluster.rs | 12 ++++++ crates/core/src/admin/mod.rs | 9 ++-- crates/s3/src/admin.rs | 18 ++++++-- 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/commands/admin/heal.rs b/crates/cli/src/commands/admin/heal.rs index 564f94a..fa69cbe 100644 --- a/crates/cli/src/commands/admin/heal.rs +++ b/crates/cli/src/commands/admin/heal.rs @@ -8,7 +8,9 @@ use serde::Serialize; use super::get_admin_client; use crate::exit_code::ExitCode; use crate::output::Formatter; -use rc_core::admin::{AdminApi, HealScanMode, HealStartRequest, HealStatus, HealTaskRequest}; +use rc_core::admin::{ + AdminApi, HealRuntimeState, HealScanMode, HealStartRequest, HealStatus, HealTaskRequest, +}; const HEAL_STOP_SUCCESS_MESSAGE: &str = "Heal operation stopped successfully"; const HEAL_STOP_STILL_RUNNING_MESSAGE: &str = @@ -100,6 +102,8 @@ struct HealStatusOutput { heal_id: String, healing: bool, #[serde(skip_serializing_if = "Option::is_none")] + state: Option, + #[serde(skip_serializing_if = "Option::is_none")] summary: Option, #[serde(skip_serializing_if = "Option::is_none")] detail: Option, @@ -126,6 +130,7 @@ impl From<&HealStatus> for HealStatusOutput { Self { heal_id: status.heal_id.clone(), healing: status.healing, + state: status.state, summary: status.summary.clone(), detail: status.detail.clone(), bucket: status.bucket.clone(), @@ -147,6 +152,7 @@ impl From<&HealStatus> for HealStatusOutput { fn has_heal_status_details(status: &HealStatus) -> bool { status.healing + || status.state.is_some() || !status.heal_id.is_empty() || status.summary.is_some() || status.detail.is_some() @@ -199,6 +205,28 @@ fn heal_stop_output(status: &HealStatus) -> (ExitCode, HealOperationOutput) { ) } +enum HealStatusIndicator { + Progress(&'static str), + Date(&'static str), +} + +fn heal_status_indicator(status: &HealStatus) -> HealStatusIndicator { + match status.state { + Some(HealRuntimeState::Disabled) => HealStatusIndicator::Date("Disabled"), + Some(HealRuntimeState::Uninitialized) => HealStatusIndicator::Date("Uninitialized"), + Some(HealRuntimeState::Active) => HealStatusIndicator::Progress("In Progress"), + Some(HealRuntimeState::Idle) => HealStatusIndicator::Date("Idle"), + None => match status.summary.as_deref() { + Some("running") => HealStatusIndicator::Progress("In Progress"), + Some("finished") => HealStatusIndicator::Progress("Finished"), + Some("stopped") => HealStatusIndicator::Date("Stopped"), + Some("notFound") => HealStatusIndicator::Date("Not Found"), + _ if status.healing => HealStatusIndicator::Progress("In Progress"), + _ => HealStatusIndicator::Date("Idle"), + }, + } +} + /// Execute a heal subcommand pub async fn execute(cmd: HealCommands, formatter: &Formatter) -> ExitCode { match cmd { @@ -247,13 +275,9 @@ async fn execute_status(args: StatusArgs, formatter: &Formatter) -> ExitCode { } fn print_heal_status(status: &HealStatus, formatter: &Formatter) { - let healing_status = match status.summary.as_deref() { - Some("running") => formatter.style_size("In Progress"), - Some("finished") => formatter.style_size("Finished"), - Some("stopped") => formatter.style_date("Stopped"), - Some("notFound") => formatter.style_date("Not Found"), - _ if status.healing => formatter.style_size("In Progress"), - _ => formatter.style_date("Idle"), + let healing_status = match heal_status_indicator(status) { + HealStatusIndicator::Progress(label) => formatter.style_size(label), + HealStatusIndicator::Date(label) => formatter.style_date(label), }; formatter.println(&format!( @@ -600,6 +624,7 @@ mod tests { let status = HealStatus { heal_id: "heal-123".to_string(), healing: true, + state: Some(HealRuntimeState::Active), bucket: "test-bucket".to_string(), object: "test/object.txt".to_string(), scan_mode: Some(HealScanMode::Deep), @@ -619,6 +644,7 @@ mod tests { let output = HealStatusOutput::from(&status); assert_eq!(output.heal_id, "heal-123"); assert!(output.healing); + assert_eq!(output.state, Some(HealRuntimeState::Active)); assert_eq!(output.bucket, "test-bucket"); assert_eq!(output.scan_mode, Some(HealScanMode::Deep)); assert_eq!(output.scan_cycle, 42); @@ -646,5 +672,23 @@ mod tests { heal_active_tasks: 1, ..Default::default() })); + + assert!(has_heal_status_details(&HealStatus { + state: Some(HealRuntimeState::Disabled), + ..Default::default() + })); + } + + #[test] + fn test_heal_status_indicator_reports_disabled_runtime() { + let status = HealStatus { + state: Some(HealRuntimeState::Disabled), + ..Default::default() + }; + + assert!(matches!( + heal_status_indicator(&status), + HealStatusIndicator::Date("Disabled") + )); } } diff --git a/crates/core/src/admin/cluster.rs b/crates/core/src/admin/cluster.rs index 1a536a2..0084052 100644 --- a/crates/core/src/admin/cluster.rs +++ b/crates/core/src/admin/cluster.rs @@ -456,6 +456,15 @@ impl std::str::FromStr for HealScanMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HealRuntimeState { + Disabled, + Uninitialized, + Idle, + Active, +} + /// Request to start a heal operation #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -585,6 +594,9 @@ pub struct HealStatus { #[serde(default)] pub healing: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option, + /// Task summary for token-scoped manual heal status #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index 221e31b..10394b1 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -9,10 +9,11 @@ mod types; pub use cluster::{ BackendInfo, BackendType, BucketsInfo, ClusterInfo, DecommissionPoolStatus, DecommissionStatus, - DiskInfo, HealDriveInfo, HealDriveInfos, HealResultItem, HealScanMode, HealStartRequest, - HealStatus, HealTaskRequest, HealingDiskInfo, MemStats, ObjectsInfo, PoolDecommissionInfo, - PoolErasureSetInfo, PoolStatus, PoolTarget, RebalanceCleanupWarnings, RebalancePoolProgress, - RebalancePoolStatus, RebalanceStartResult, RebalanceStatus, ServerInfo, UsageInfo, + DiskInfo, HealDriveInfo, HealDriveInfos, HealResultItem, HealRuntimeState, HealScanMode, + HealStartRequest, HealStatus, HealTaskRequest, HealingDiskInfo, MemStats, ObjectsInfo, + PoolDecommissionInfo, PoolErasureSetInfo, PoolStatus, PoolTarget, RebalanceCleanupWarnings, + RebalancePoolProgress, RebalancePoolStatus, RebalanceStartResult, RebalanceStatus, ServerInfo, + UsageInfo, }; pub use tier::{ TierAliyun, TierAzure, TierConfig, TierCreds, TierGCS, TierHuaweicloud, TierMinIO, TierR2, diff --git a/crates/s3/src/admin.rs b/crates/s3/src/admin.rs index 043be38..8616b0c 100644 --- a/crates/s3/src/admin.rs +++ b/crates/s3/src/admin.rs @@ -11,10 +11,10 @@ use aws_sigv4::http_request::{ use aws_sigv4::sign::v4; use rc_core::admin::{ AccessKeyInfo, AdminApi, BucketQuota, ClusterInfo, CreateServiceAccountRequest, - DecommissionPoolStatus, DecommissionStatus, Group, GroupStatus, HealScanMode, HealStartRequest, - HealStatus, HealTaskRequest, Policy, PolicyEntity, PolicyInfo, PoolStatus, PoolTarget, - RebalanceStartResult, RebalanceStatus, ServiceAccount, ServiceAccountCreateResponse, - UpdateGroupMembersRequest, User, UserStatus, + DecommissionPoolStatus, DecommissionStatus, Group, GroupStatus, HealRuntimeState, HealScanMode, + HealStartRequest, HealStatus, HealTaskRequest, Policy, PolicyEntity, PolicyInfo, PoolStatus, + PoolTarget, RebalanceStartResult, RebalanceStatus, ServiceAccount, + ServiceAccountCreateResponse, UpdateGroupMembersRequest, User, UserStatus, }; use rc_core::{Alias, Error, Result}; use reqwest::header::{CONTENT_TYPE, HOST, HeaderMap, HeaderName, HeaderValue}; @@ -406,6 +406,8 @@ struct ServiceAccountInfo { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BackgroundHealStatusResponse { + #[serde(default)] + state: Option, #[serde(default)] bitrot_start_time: Option, #[serde(default)] @@ -487,6 +489,7 @@ impl From for HealStatus { heal_active_tasks: active_tasks, ..Default::default() }; + status.state = response.state; if let Some(progress) = response.progress { progress.apply_to_status(&mut status); } @@ -1444,6 +1447,7 @@ mod tests { #[test] fn test_background_heal_status_response_maps_to_heal_status() { let status = HealStatus::from(BackgroundHealStatusResponse { + state: Some(HealRuntimeState::Active), bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()), bitrot_start_cycle: 42, current_scan_mode: Some(2), @@ -1454,6 +1458,7 @@ mod tests { }); assert!(status.healing); + assert_eq!(status.state, Some(HealRuntimeState::Active)); assert_eq!(status.started.as_deref(), Some("2026-04-19T10:00:00Z")); assert_eq!(status.scan_mode, Some(HealScanMode::Deep)); assert_eq!(status.scan_cycle, 42); @@ -1461,6 +1466,7 @@ mod tests { assert_eq!(status.heal_active_tasks, 1); let idle = HealStatus::from(BackgroundHealStatusResponse { + state: Some(HealRuntimeState::Idle), bitrot_start_time: None, bitrot_start_cycle: 0, current_scan_mode: Some(1), @@ -1470,10 +1476,12 @@ mod tests { progress: None, }); assert!(!idle.healing); + assert_eq!(idle.state, Some(HealRuntimeState::Idle)); assert_eq!(idle.scan_mode, Some(HealScanMode::Normal)); assert!(idle.started.is_none()); let completed = HealStatus::from(BackgroundHealStatusResponse { + state: None, bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()), bitrot_start_cycle: 42, current_scan_mode: Some(1), @@ -1487,6 +1495,7 @@ mod tests { assert_eq!(completed.started.as_deref(), Some("2026-04-19T10:00:00Z")); let legacy = HealStatus::from(BackgroundHealStatusResponse { + state: None, bitrot_start_time: Some("2026-04-19T10:00:00Z".to_string()), bitrot_start_cycle: 0, current_scan_mode: None, @@ -1498,6 +1507,7 @@ mod tests { assert!(legacy.healing); let active = HealStatus::from(BackgroundHealStatusResponse { + state: None, bitrot_start_time: None, bitrot_start_cycle: 0, current_scan_mode: None, From 76e55e36bc731fd32ab876c477d18f7c97ee313b Mon Sep 17 00:00:00 2001 From: overtrue Date: Mon, 13 Jul 2026 07:49:12 +0800 Subject: [PATCH 2/2] fix(admin): tolerate unknown heal runtime state --- crates/cli/src/commands/admin/heal.rs | 2 +- crates/core/src/admin/cluster.rs | 10 ++++++++++ schemas/output_v2.json | 11 +++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/admin/heal.rs b/crates/cli/src/commands/admin/heal.rs index fa69cbe..6126e7f 100644 --- a/crates/cli/src/commands/admin/heal.rs +++ b/crates/cli/src/commands/admin/heal.rs @@ -216,7 +216,7 @@ fn heal_status_indicator(status: &HealStatus) -> HealStatusIndicator { Some(HealRuntimeState::Uninitialized) => HealStatusIndicator::Date("Uninitialized"), Some(HealRuntimeState::Active) => HealStatusIndicator::Progress("In Progress"), Some(HealRuntimeState::Idle) => HealStatusIndicator::Date("Idle"), - None => match status.summary.as_deref() { + Some(HealRuntimeState::Unknown) | None => match status.summary.as_deref() { Some("running") => HealStatusIndicator::Progress("In Progress"), Some("finished") => HealStatusIndicator::Progress("Finished"), Some("stopped") => HealStatusIndicator::Date("Stopped"), diff --git a/crates/core/src/admin/cluster.rs b/crates/core/src/admin/cluster.rs index 0084052..844310b 100644 --- a/crates/core/src/admin/cluster.rs +++ b/crates/core/src/admin/cluster.rs @@ -463,6 +463,8 @@ pub enum HealRuntimeState { Uninitialized, Idle, Active, + #[serde(other)] + Unknown, } /// Request to start a heal operation @@ -1072,6 +1074,7 @@ mod tests { let status = HealStatus::default(); assert!(status.heal_id.is_empty()); assert!(!status.healing); + assert!(status.state.is_none()); assert!(status.scan_mode.is_none()); assert_eq!(status.scan_cycle, 0); assert_eq!(status.heal_queue_length, 0); @@ -1079,6 +1082,13 @@ mod tests { assert_eq!(status.items_scanned, 0); } + #[test] + fn test_heal_runtime_state_unknown_value_is_preserved() { + let state: HealRuntimeState = serde_json::from_str(r#""future""#).unwrap(); + + assert_eq!(state, HealRuntimeState::Unknown); + } + #[test] fn test_pool_status_deserialization() { let json = r#"{"id":1,"cmdline":"/data/pool1/disk{1...4}","lastUpdate":"2026-05-06T00:00:00Z","status":"decommissioning","decommissionStatus":"running","rebalanceStatus":"none","totalSize":1000,"currentSize":600,"usedSize":400,"used":0.4,"decommissionInfo":{"startTime":"2026-05-06T00:00:01Z","startSize":100,"totalSize":1000,"currentSize":600,"complete":false,"failed":false,"canceled":false,"queued":true,"queuedBuckets":["bucket-a"],"decommissionedBuckets":["bucket-b"],"bucket":"bucket-a","prefix":"","object":"object.txt","stage":"migrate_object","objectsDecommissioned":2,"objectsDecommissionedFailed":1,"bytesDecommissioned":128,"bytesDecommissionedFailed":64,"waitingReason":"queued"}}"#; diff --git a/schemas/output_v2.json b/schemas/output_v2.json index f294223..6fa40bc 100644 --- a/schemas/output_v2.json +++ b/schemas/output_v2.json @@ -262,6 +262,17 @@ "type": "boolean", "description": "Whether a heal operation is active" }, + "state": { + "type": "string", + "enum": [ + "disabled", + "uninitialized", + "idle", + "active", + "unknown" + ], + "description": "Runtime state reported by the heal service" + }, "summary": { "type": "string", "description": "Token-scoped manual heal task summary"