Skip to main content

rc_core/admin/
cluster.rs

1//! Cluster management type definitions
2//!
3//! This module contains data structures for cluster management operations
4//! including server information, disk status, and heal operations.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Server information representing a RustFS node
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11#[serde(rename_all = "camelCase")]
12pub struct ServerInfo {
13    /// Server state (online, offline, initializing)
14    #[serde(default)]
15    pub state: String,
16
17    /// Server endpoint URL
18    #[serde(default)]
19    pub endpoint: String,
20
21    /// Connection scheme (http/https)
22    #[serde(default)]
23    pub scheme: String,
24
25    /// Uptime in seconds
26    #[serde(default)]
27    pub uptime: u64,
28
29    /// Server version
30    #[serde(default)]
31    pub version: String,
32
33    /// Git commit ID
34    #[serde(default, rename = "commitID")]
35    pub commit_id: String,
36
37    /// Network interfaces
38    #[serde(default)]
39    pub network: HashMap<String, String>,
40
41    /// Attached drives
42    #[serde(default, rename = "drives")]
43    pub disks: Vec<DiskInfo>,
44
45    /// Pool number
46    #[serde(default, rename = "poolNumber")]
47    pub pool_number: i32,
48
49    /// Memory statistics
50    #[serde(default, rename = "mem_stats")]
51    pub mem_stats: MemStats,
52}
53
54/// Disk information
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56#[serde(rename_all = "camelCase")]
57pub struct DiskInfo {
58    /// Disk endpoint
59    #[serde(default)]
60    pub endpoint: String,
61
62    /// Whether this is a root disk
63    #[serde(default, rename = "rootDisk")]
64    pub root_disk: bool,
65
66    /// Drive path
67    #[serde(default, rename = "path")]
68    pub drive_path: String,
69
70    /// Whether healing is in progress
71    #[serde(default)]
72    pub healing: bool,
73
74    /// Whether scanning is in progress
75    #[serde(default)]
76    pub scanning: bool,
77
78    /// Disk state (online, offline)
79    #[serde(default)]
80    pub state: String,
81
82    /// Disk UUID
83    #[serde(default)]
84    pub uuid: String,
85
86    /// Total space in bytes
87    #[serde(default, rename = "totalspace")]
88    pub total_space: u64,
89
90    /// Used space in bytes
91    #[serde(default, rename = "usedspace")]
92    pub used_space: u64,
93
94    /// Available space in bytes
95    #[serde(default, rename = "availspace")]
96    pub available_space: u64,
97
98    /// Pool index
99    #[serde(default)]
100    pub pool_index: i32,
101
102    /// Set index
103    #[serde(default)]
104    pub set_index: i32,
105
106    /// Disk index within set
107    #[serde(default)]
108    pub disk_index: i32,
109
110    /// Healing info if disk is being healed
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub heal_info: Option<HealingDiskInfo>,
113}
114
115/// Healing disk information
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117#[serde(rename_all = "camelCase")]
118pub struct HealingDiskInfo {
119    /// Heal ID
120    #[serde(default)]
121    pub id: String,
122
123    /// Heal session ID
124    #[serde(default)]
125    pub heal_id: String,
126
127    /// Pool index
128    #[serde(default)]
129    pub pool_index: Option<usize>,
130
131    /// Set index
132    #[serde(default)]
133    pub set_index: Option<usize>,
134
135    /// Disk index
136    #[serde(default)]
137    pub disk_index: Option<usize>,
138
139    /// Endpoint being healed
140    #[serde(default)]
141    pub endpoint: String,
142
143    /// Path being healed
144    #[serde(default)]
145    pub path: String,
146
147    /// Objects total count
148    #[serde(default)]
149    pub objects_total_count: u64,
150
151    /// Objects total size
152    #[serde(default)]
153    pub objects_total_size: u64,
154
155    /// Items healed count
156    #[serde(default)]
157    pub items_healed: u64,
158
159    /// Items failed count
160    #[serde(default)]
161    pub items_failed: u64,
162
163    /// Bytes done
164    #[serde(default)]
165    pub bytes_done: u64,
166
167    /// Whether healing is finished
168    #[serde(default)]
169    pub finished: bool,
170
171    /// Current bucket being healed
172    #[serde(default)]
173    pub bucket: String,
174
175    /// Current object being healed
176    #[serde(default)]
177    pub object: String,
178}
179
180/// Memory statistics
181#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182pub struct MemStats {
183    /// Current allocated memory
184    #[serde(default)]
185    pub alloc: u64,
186
187    /// Total allocated memory over lifetime
188    #[serde(default)]
189    pub total_alloc: u64,
190
191    /// Heap allocated memory
192    #[serde(default)]
193    pub heap_alloc: u64,
194}
195
196/// Storage backend type
197#[derive(Debug, Clone, Serialize, Deserialize, Default)]
198#[serde(rename_all = "lowercase")]
199pub enum BackendType {
200    /// Filesystem backend (single drive)
201    #[default]
202    #[serde(rename = "FS")]
203    Fs,
204    /// Erasure coding backend (distributed)
205    #[serde(rename = "Erasure")]
206    Erasure,
207}
208
209impl std::fmt::Display for BackendType {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        match self {
212            BackendType::Fs => write!(f, "FS"),
213            BackendType::Erasure => write!(f, "Erasure"),
214        }
215    }
216}
217
218/// Backend information
219#[derive(Debug, Clone, Serialize, Deserialize, Default)]
220#[serde(rename_all = "camelCase")]
221pub struct BackendInfo {
222    /// Backend type
223    #[serde(default, rename = "backendType")]
224    pub backend_type: BackendType,
225
226    /// Number of online disks
227    #[serde(default, rename = "onlineDisks")]
228    pub online_disks: usize,
229
230    /// Number of offline disks
231    #[serde(default, rename = "offlineDisks")]
232    pub offline_disks: usize,
233
234    /// Standard storage class parity
235    #[serde(default, rename = "standardSCParity")]
236    pub standard_sc_parity: Option<usize>,
237
238    /// Reduced redundancy storage class parity
239    #[serde(default, rename = "rrSCParity")]
240    pub rr_sc_parity: Option<usize>,
241
242    /// Total erasure sets
243    #[serde(default, rename = "totalSets")]
244    pub total_sets: Vec<usize>,
245
246    /// Drives per erasure set
247    #[serde(default, rename = "totalDrivesPerSet")]
248    pub drives_per_set: Vec<usize>,
249}
250
251/// Cluster usage statistics
252#[derive(Debug, Clone, Serialize, Deserialize, Default)]
253pub struct UsageInfo {
254    /// Total storage size in bytes
255    #[serde(default)]
256    pub size: u64,
257
258    /// Error message if any
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub error: Option<String>,
261}
262
263/// Bucket count information
264#[derive(Debug, Clone, Serialize, Deserialize, Default)]
265pub struct BucketsInfo {
266    /// Number of buckets
267    #[serde(default)]
268    pub count: u64,
269
270    /// Error message if any
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub error: Option<String>,
273}
274
275/// Object count information
276#[derive(Debug, Clone, Serialize, Deserialize, Default)]
277pub struct ObjectsInfo {
278    /// Number of objects
279    #[serde(default)]
280    pub count: u64,
281
282    /// Error message if any
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub error: Option<String>,
285}
286
287/// Complete cluster information response
288#[derive(Debug, Clone, Serialize, Deserialize, Default)]
289#[serde(rename_all = "camelCase")]
290pub struct ClusterInfo {
291    /// Deployment mode (distributed, standalone)
292    #[serde(default)]
293    pub mode: Option<String>,
294
295    /// Domain names
296    #[serde(default)]
297    pub domain: Option<Vec<String>>,
298
299    /// Region
300    #[serde(default)]
301    pub region: Option<String>,
302
303    /// Deployment ID
304    #[serde(default, rename = "deploymentID")]
305    pub deployment_id: Option<String>,
306
307    /// Bucket information
308    #[serde(default)]
309    pub buckets: Option<BucketsInfo>,
310
311    /// Object information
312    #[serde(default)]
313    pub objects: Option<ObjectsInfo>,
314
315    /// Storage usage
316    #[serde(default)]
317    pub usage: Option<UsageInfo>,
318
319    /// Backend information
320    #[serde(default)]
321    pub backend: Option<BackendInfo>,
322
323    /// Server information
324    #[serde(default)]
325    pub servers: Option<Vec<ServerInfo>>,
326}
327
328impl ClusterInfo {
329    /// Get the total number of online disks across all servers
330    pub fn online_disks(&self) -> usize {
331        self.servers
332            .as_ref()
333            .map(|servers| {
334                servers
335                    .iter()
336                    .flat_map(|s| &s.disks)
337                    .filter(|d| d.state == "online" || d.state == "ok")
338                    .count()
339            })
340            .unwrap_or(0)
341    }
342
343    /// Get the total number of offline disks across all servers
344    pub fn offline_disks(&self) -> usize {
345        self.servers
346            .as_ref()
347            .map(|servers| {
348                servers
349                    .iter()
350                    .flat_map(|s| &s.disks)
351                    .filter(|d| d.state == "offline")
352                    .count()
353            })
354            .unwrap_or(0)
355    }
356
357    /// Get total storage capacity in bytes
358    pub fn total_capacity(&self) -> u64 {
359        self.servers
360            .as_ref()
361            .map(|servers| {
362                servers
363                    .iter()
364                    .flat_map(|s| &s.disks)
365                    .map(|d| d.total_space)
366                    .sum()
367            })
368            .unwrap_or(0)
369    }
370
371    /// Get used storage in bytes
372    pub fn used_capacity(&self) -> u64 {
373        self.servers
374            .as_ref()
375            .map(|servers| {
376                servers
377                    .iter()
378                    .flat_map(|s| &s.disks)
379                    .map(|d| d.used_space)
380                    .sum()
381            })
382            .unwrap_or(0)
383    }
384}
385
386/// Heal operation mode
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
388#[serde(rename_all = "lowercase")]
389pub enum HealScanMode {
390    /// Normal scan (default)
391    #[default]
392    Normal,
393    /// Deep scan (slower but more thorough)
394    Deep,
395}
396
397impl std::fmt::Display for HealScanMode {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            HealScanMode::Normal => write!(f, "normal"),
401            HealScanMode::Deep => write!(f, "deep"),
402        }
403    }
404}
405
406impl std::str::FromStr for HealScanMode {
407    type Err = String;
408
409    fn from_str(s: &str) -> Result<Self, Self::Err> {
410        match s.to_lowercase().as_str() {
411            "normal" => Ok(HealScanMode::Normal),
412            "deep" => Ok(HealScanMode::Deep),
413            _ => Err(format!("Invalid heal scan mode: {s}")),
414        }
415    }
416}
417
418/// Request to start a heal operation
419#[derive(Debug, Clone, Serialize, Deserialize, Default)]
420#[serde(rename_all = "camelCase")]
421pub struct HealStartRequest {
422    /// Bucket to heal (empty for all buckets)
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub bucket: Option<String>,
425
426    /// Object prefix to heal
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub prefix: Option<String>,
429
430    /// Scan mode
431    #[serde(default)]
432    pub scan_mode: HealScanMode,
433
434    /// Whether to remove dangling objects
435    #[serde(default)]
436    pub remove: bool,
437
438    /// Whether to recreate missing data
439    #[serde(default)]
440    pub recreate: bool,
441
442    /// Dry run mode (don't actually heal)
443    #[serde(default)]
444    pub dry_run: bool,
445}
446
447/// Information about a single heal drive
448#[derive(Debug, Clone, Serialize, Deserialize, Default)]
449pub struct HealDriveInfo {
450    /// Drive UUID
451    #[serde(default)]
452    pub uuid: String,
453
454    /// Drive endpoint
455    #[serde(default)]
456    pub endpoint: String,
457
458    /// Drive state
459    #[serde(default)]
460    pub state: String,
461}
462
463/// Result of a heal operation on a single item
464#[derive(Debug, Clone, Serialize, Deserialize, Default)]
465#[serde(rename_all = "camelCase")]
466pub struct HealResultItem {
467    /// Result index
468    #[serde(default, rename = "resultId")]
469    pub result_index: usize,
470
471    /// Type of item healed (bucket, object, metadata)
472    #[serde(default, rename = "type")]
473    pub item_type: String,
474
475    /// Bucket name
476    #[serde(default)]
477    pub bucket: String,
478
479    /// Object key
480    #[serde(default)]
481    pub object: String,
482
483    /// Version ID
484    #[serde(default, rename = "versionId")]
485    pub version_id: String,
486
487    /// Detail message
488    #[serde(default)]
489    pub detail: String,
490
491    /// Number of parity blocks
492    #[serde(default, rename = "parityBlocks")]
493    pub parity_blocks: usize,
494
495    /// Number of data blocks
496    #[serde(default, rename = "dataBlocks")]
497    pub data_blocks: usize,
498
499    /// Object size
500    #[serde(default, rename = "objectSize")]
501    pub object_size: u64,
502
503    /// Drive info before healing
504    #[serde(default)]
505    pub before: HealDriveInfos,
506
507    /// Drive info after healing
508    #[serde(default)]
509    pub after: HealDriveInfos,
510}
511
512/// Collection of heal drive infos
513#[derive(Debug, Clone, Serialize, Deserialize, Default)]
514pub struct HealDriveInfos {
515    /// Drive information
516    #[serde(default)]
517    pub drives: Vec<HealDriveInfo>,
518}
519
520/// Status of a heal operation
521#[derive(Debug, Clone, Serialize, Deserialize, Default)]
522#[serde(rename_all = "camelCase")]
523pub struct HealStatus {
524    /// Heal ID
525    #[serde(default)]
526    pub heal_id: String,
527
528    /// Whether healing is in progress
529    #[serde(default)]
530    pub healing: bool,
531
532    /// Current bucket being healed
533    #[serde(default)]
534    pub bucket: String,
535
536    /// Current object being healed
537    #[serde(default)]
538    pub object: String,
539
540    /// Number of items scanned
541    #[serde(default)]
542    pub items_scanned: u64,
543
544    /// Number of items healed
545    #[serde(default)]
546    pub items_healed: u64,
547
548    /// Number of items failed
549    #[serde(default)]
550    pub items_failed: u64,
551
552    /// Bytes scanned
553    #[serde(default)]
554    pub bytes_scanned: u64,
555
556    /// Bytes healed
557    #[serde(default)]
558    pub bytes_healed: u64,
559
560    /// Start time
561    #[serde(default)]
562    pub started: Option<String>,
563
564    /// Last update time
565    #[serde(default)]
566    pub last_update: Option<String>,
567}
568
569/// Request targeting a storage pool by command line or numeric ID.
570#[derive(Debug, Clone, Serialize, Deserialize, Default)]
571#[serde(rename_all = "camelCase")]
572pub struct PoolTarget {
573    /// Pool command line, or zero-based pool ID when `by_id` is true.
574    pub pool: String,
575
576    /// Interpret `pool` as a zero-based pool ID.
577    #[serde(default)]
578    pub by_id: bool,
579}
580
581/// Status of a server pool.
582#[derive(Debug, Clone, Serialize, Deserialize, Default)]
583pub struct PoolStatus {
584    /// Zero-based pool ID.
585    #[serde(default)]
586    pub id: usize,
587
588    /// Pool command line used by the server process.
589    #[serde(default, rename = "cmdline")]
590    pub cmd_line: String,
591
592    /// Last pool metadata update timestamp.
593    #[serde(default, rename = "lastUpdate")]
594    pub last_update: String,
595
596    /// Decommission status and progress for this pool.
597    #[serde(default, rename = "decommissionInfo")]
598    pub decommission: Option<PoolDecommissionInfo>,
599}
600
601/// Decommission state and progress for a server pool.
602#[derive(Debug, Clone, Serialize, Deserialize, Default)]
603pub struct PoolDecommissionInfo {
604    /// Decommission start timestamp.
605    #[serde(default, rename = "startTime")]
606    pub start_time: Option<String>,
607
608    /// Free bytes when decommission started.
609    #[serde(default, rename = "startSize")]
610    pub start_size: u64,
611
612    /// Total pool size in bytes.
613    #[serde(default, rename = "totalSize")]
614    pub total_size: u64,
615
616    /// Current free size in bytes.
617    #[serde(default, rename = "currentSize")]
618    pub current_size: u64,
619
620    /// Whether decommission completed.
621    #[serde(default)]
622    pub complete: bool,
623
624    /// Whether decommission failed.
625    #[serde(default)]
626    pub failed: bool,
627
628    /// Whether decommission was canceled.
629    #[serde(default)]
630    pub canceled: bool,
631
632    /// Number of successfully decommissioned objects.
633    #[serde(default, rename = "objectsDecommissioned")]
634    pub objects_decommissioned: u64,
635
636    /// Number of objects that failed to decommission.
637    #[serde(default, rename = "objectsDecommissionedFailed")]
638    pub objects_decommissioned_failed: u64,
639
640    /// Bytes successfully moved off the pool.
641    #[serde(default, rename = "bytesDecommissioned")]
642    pub bytes_decommissioned: u64,
643
644    /// Bytes that failed to move off the pool.
645    #[serde(default, rename = "bytesDecommissionedFailed")]
646    pub bytes_decommissioned_failed: u64,
647}
648
649/// Response from starting a rebalance operation.
650#[derive(Debug, Clone, Serialize, Deserialize, Default)]
651pub struct RebalanceStartResult {
652    /// Rebalance operation ID.
653    #[serde(default)]
654    pub id: String,
655}
656
657/// Cluster-wide rebalance status.
658#[derive(Debug, Clone, Serialize, Deserialize, Default)]
659pub struct RebalanceStatus {
660    /// Rebalance operation ID.
661    #[serde(default)]
662    pub id: String,
663
664    /// Per-pool rebalance status.
665    #[serde(default)]
666    pub pools: Vec<RebalancePoolStatus>,
667
668    /// Timestamp when rebalance was stopped.
669    #[serde(default, rename = "stoppedAt")]
670    pub stopped_at: Option<String>,
671}
672
673/// Rebalance status for a single pool.
674#[derive(Debug, Clone, Serialize, Deserialize, Default)]
675pub struct RebalancePoolStatus {
676    /// Zero-based pool ID.
677    #[serde(default)]
678    pub id: usize,
679
680    /// Rebalance status for this pool.
681    #[serde(default)]
682    pub status: String,
683
684    /// Used capacity ratio in the range 0.0..=1.0.
685    #[serde(default)]
686    pub used: f64,
687
688    /// Last rebalance error, if any.
689    #[serde(default, rename = "lastError")]
690    pub last_error: Option<String>,
691
692    /// Rebalance progress, if this pool is active.
693    #[serde(default)]
694    pub progress: Option<RebalancePoolProgress>,
695}
696
697/// Rebalance progress for a single pool.
698#[derive(Debug, Clone, Serialize, Deserialize, Default)]
699pub struct RebalancePoolProgress {
700    /// Number of objects moved.
701    #[serde(default, rename = "objects")]
702    pub num_objects: u64,
703
704    /// Number of object versions moved.
705    #[serde(default, rename = "versions")]
706    pub num_versions: u64,
707
708    /// Number of bytes moved.
709    #[serde(default)]
710    pub bytes: u64,
711
712    /// Number of buckets remaining.
713    #[serde(default, rename = "remainingBuckets")]
714    pub remaining_buckets: usize,
715
716    /// Current bucket.
717    #[serde(default)]
718    pub bucket: String,
719
720    /// Current object.
721    #[serde(default)]
722    pub object: String,
723
724    /// Elapsed seconds.
725    #[serde(default)]
726    pub elapsed: u64,
727
728    /// Estimated seconds remaining.
729    #[serde(default)]
730    pub eta: u64,
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    #[test]
738    fn test_backend_type_display() {
739        assert_eq!(BackendType::Fs.to_string(), "FS");
740        assert_eq!(BackendType::Erasure.to_string(), "Erasure");
741    }
742
743    #[test]
744    fn test_heal_scan_mode_display() {
745        assert_eq!(HealScanMode::Normal.to_string(), "normal");
746        assert_eq!(HealScanMode::Deep.to_string(), "deep");
747    }
748
749    #[test]
750    fn test_heal_scan_mode_from_str() {
751        assert_eq!(
752            "normal".parse::<HealScanMode>().unwrap(),
753            HealScanMode::Normal
754        );
755        assert_eq!("deep".parse::<HealScanMode>().unwrap(), HealScanMode::Deep);
756        assert!("invalid".parse::<HealScanMode>().is_err());
757    }
758
759    #[test]
760    fn test_cluster_info_default() {
761        let info = ClusterInfo::default();
762        assert!(info.mode.is_none());
763        assert!(info.servers.is_none());
764        assert_eq!(info.online_disks(), 0);
765        assert_eq!(info.offline_disks(), 0);
766    }
767
768    #[test]
769    fn test_cluster_info_disk_counts() {
770        let info = ClusterInfo {
771            servers: Some(vec![ServerInfo {
772                disks: vec![
773                    DiskInfo {
774                        state: "online".to_string(),
775                        ..Default::default()
776                    },
777                    DiskInfo {
778                        state: "online".to_string(),
779                        ..Default::default()
780                    },
781                    DiskInfo {
782                        state: "offline".to_string(),
783                        ..Default::default()
784                    },
785                ],
786                ..Default::default()
787            }]),
788            ..Default::default()
789        };
790
791        assert_eq!(info.online_disks(), 2);
792        assert_eq!(info.offline_disks(), 1);
793    }
794
795    #[test]
796    fn test_cluster_info_capacity() {
797        let info = ClusterInfo {
798            servers: Some(vec![ServerInfo {
799                disks: vec![
800                    DiskInfo {
801                        total_space: 1000,
802                        used_space: 300,
803                        ..Default::default()
804                    },
805                    DiskInfo {
806                        total_space: 2000,
807                        used_space: 500,
808                        ..Default::default()
809                    },
810                ],
811                ..Default::default()
812            }]),
813            ..Default::default()
814        };
815
816        assert_eq!(info.total_capacity(), 3000);
817        assert_eq!(info.used_capacity(), 800);
818    }
819
820    #[test]
821    fn test_disk_info_default() {
822        let disk = DiskInfo::default();
823        assert!(disk.endpoint.is_empty());
824        assert!(!disk.healing);
825        assert!(!disk.scanning);
826        assert_eq!(disk.total_space, 0);
827    }
828
829    #[test]
830    fn test_server_info_default() {
831        let server = ServerInfo::default();
832        assert!(server.state.is_empty());
833        assert!(server.endpoint.is_empty());
834        assert_eq!(server.uptime, 0);
835    }
836
837    #[test]
838    fn test_heal_start_request_default() {
839        let req = HealStartRequest::default();
840        assert!(req.bucket.is_none());
841        assert!(req.prefix.is_none());
842        assert_eq!(req.scan_mode, HealScanMode::Normal);
843        assert!(!req.remove);
844        assert!(!req.dry_run);
845    }
846
847    #[test]
848    fn test_heal_status_default() {
849        let status = HealStatus::default();
850        assert!(status.heal_id.is_empty());
851        assert!(!status.healing);
852        assert_eq!(status.items_scanned, 0);
853    }
854
855    #[test]
856    fn test_pool_status_deserialization() {
857        let json = r#"{"id":1,"cmdline":"/data/pool1/disk{1...4}","lastUpdate":"2026-05-06T00:00:00Z","decommissionInfo":{"startTime":"2026-05-06T00:00:01Z","startSize":100,"totalSize":1000,"currentSize":600,"complete":false,"failed":false,"canceled":false,"objectsDecommissioned":2,"objectsDecommissionedFailed":1,"bytesDecommissioned":128,"bytesDecommissionedFailed":64}}"#;
858
859        let status: PoolStatus = serde_json::from_str(json).unwrap();
860
861        assert_eq!(status.id, 1);
862        assert_eq!(status.cmd_line, "/data/pool1/disk{1...4}");
863        let info = status.decommission.expect("decommission info exists");
864        assert_eq!(info.objects_decommissioned, 2);
865        assert_eq!(info.bytes_decommissioned_failed, 64);
866    }
867
868    #[test]
869    fn test_rebalance_status_deserialization() {
870        let json = r#"{"id":"rebalance-1","pools":[{"id":0,"status":"Started","used":0.5,"lastError":null,"progress":{"objects":3,"versions":4,"bytes":1024,"remainingBuckets":2,"bucket":"bucket","object":"object","elapsed":10,"eta":20}}],"stoppedAt":null}"#;
871
872        let status: RebalanceStatus = serde_json::from_str(json).unwrap();
873
874        assert_eq!(status.id, "rebalance-1");
875        assert_eq!(status.pools.len(), 1);
876        assert_eq!(status.pools[0].used, 0.5);
877        let progress = status.pools[0]
878            .progress
879            .as_ref()
880            .expect("progress should exist");
881        assert_eq!(progress.num_objects, 3);
882        assert_eq!(progress.remaining_buckets, 2);
883    }
884
885    #[test]
886    fn test_serialization() {
887        let info = ClusterInfo {
888            mode: Some("distributed".to_string()),
889            deployment_id: Some("test-123".to_string()),
890            ..Default::default()
891        };
892
893        let json = serde_json::to_string(&info).unwrap();
894        assert!(json.contains("distributed"));
895        assert!(json.contains("test-123"));
896
897        let deserialized: ClusterInfo = serde_json::from_str(&json).unwrap();
898        assert_eq!(deserialized.mode, Some("distributed".to_string()));
899    }
900}