4runr-os 2.10.49 → 2.10.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,439 +1,439 @@
1
- // Phase 0: Portal Monitoring State Management
2
- // Structures for section-based, interactive monitoring
3
-
4
- use serde::{Deserialize, Serialize};
5
- use std::collections::HashMap;
6
- use std::time::{Duration, Instant};
7
-
8
- /// Main sections of the monitoring view
9
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10
- pub enum MonitoringSection {
11
- Overview,
12
- Health,
13
- Dependencies,
14
- Metrics,
15
- Queue,
16
- Logs,
17
- System,
18
- }
19
-
20
- impl MonitoringSection {
21
- pub fn all() -> Vec<Self> {
22
- vec![
23
- Self::Overview,
24
- Self::Health,
25
- Self::Dependencies,
26
- Self::Metrics,
27
- Self::Queue,
28
- Self::Logs,
29
- Self::System,
30
- ]
31
- }
32
-
33
- pub fn display_name(&self) -> &'static str {
34
- match self {
35
- Self::Overview => "Overview",
36
- Self::Health => "Health",
37
- Self::Dependencies => "Dependencies",
38
- Self::Metrics => "Metrics",
39
- Self::Queue => "Queue",
40
- Self::Logs => "Logs",
41
- Self::System => "System",
42
- }
43
- }
44
-
45
- /// Lowercase slug sent on `monitoring.refresh` (`section` field).
46
- pub fn cli_slug(&self) -> &'static str {
47
- match self {
48
- Self::Overview => "overview",
49
- Self::Health => "health",
50
- Self::Dependencies => "dependencies",
51
- Self::Metrics => "metrics",
52
- Self::Queue => "queue",
53
- Self::Logs => "logs",
54
- Self::System => "system",
55
- }
56
- }
57
-
58
- pub fn from_cli_slug(s: &str) -> Option<Self> {
59
- let t = s.trim().to_lowercase();
60
- match t.as_str() {
61
- "overview" => Some(Self::Overview),
62
- "health" => Some(Self::Health),
63
- "dependencies" => Some(Self::Dependencies),
64
- "metrics" => Some(Self::Metrics),
65
- "queue" => Some(Self::Queue),
66
- "logs" => Some(Self::Logs),
67
- "system" => Some(Self::System),
68
- _ => None,
69
- }
70
- }
71
- }
72
-
73
- /// Phase 3: Metrics section drill targets (`monitoring.drill` `target` field).
74
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75
- pub enum MetricsDrillPanel {
76
- Http,
77
- Runs,
78
- Queue,
79
- Sse,
80
- }
81
-
82
- impl MetricsDrillPanel {
83
- pub fn next(self) -> Self {
84
- match self {
85
- Self::Http => Self::Runs,
86
- Self::Runs => Self::Queue,
87
- Self::Queue => Self::Sse,
88
- Self::Sse => Self::Http,
89
- }
90
- }
91
-
92
- pub fn cli_target(&self) -> &'static str {
93
- match self {
94
- Self::Http => "metrics_http",
95
- Self::Runs => "metrics_runs",
96
- Self::Queue => "metrics_queue",
97
- Self::Sse => "metrics_sse",
98
- }
99
- }
100
-
101
- pub fn label(&self) -> &'static str {
102
- match self {
103
- Self::Http => "HTTP",
104
- Self::Runs => "Runs",
105
- Self::Queue => "Queue",
106
- Self::Sse => "SSE",
107
- }
108
- }
109
-
110
- pub fn from_cli_target(s: &str) -> Option<Self> {
111
- match s.trim() {
112
- "metrics_http" => Some(Self::Http),
113
- "metrics_runs" => Some(Self::Runs),
114
- "metrics_queue" => Some(Self::Queue),
115
- "metrics_sse" => Some(Self::Sse),
116
- _ => None,
117
- }
118
- }
119
- }
120
-
121
- /// Status indicator for sections (determines color)
122
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123
- pub enum SectionStatus {
124
- Healthy, // Green
125
- Degraded, // Yellow
126
- Unhealthy, // Red
127
- Unknown, // Gray
128
- Loading, // Blue/Cyan
129
- }
130
-
131
- impl SectionStatus {
132
- pub fn symbol(&self) -> &'static str {
133
- match self {
134
- Self::Healthy => "●",
135
- Self::Degraded => "⚠",
136
- Self::Unhealthy => "✖",
137
- Self::Unknown => "○",
138
- Self::Loading => "◉",
139
- }
140
- }
141
- }
142
-
143
- /// State for each individual section.
144
- ///
145
- /// **Phase 1 Portal Monitoring:** only `expanded` is authoritative for the UI. Status/summary strings
146
- /// here are not driven by `portal_monitoring` render (that path recomputes from the CLI snapshot each
147
- /// frame). Future phases may use `update_section` to populate these; until then do not assume headers
148
- /// reflect `SectionState::status` / `summary`.
149
- #[derive(Debug, Clone)]
150
- pub struct SectionState {
151
- /// Whether section is expanded (shows detail) or collapsed (shows summary)
152
- pub expanded: bool,
153
- /// Current status (for color coding header)
154
- pub status: SectionStatus,
155
- /// One-line summary shown when collapsed
156
- pub summary: String,
157
- /// When this section was last refreshed
158
- pub last_refresh: Option<Instant>,
159
- /// Whether section is currently loading new data
160
- pub loading: bool,
161
- /// Error message if last refresh failed
162
- pub error: Option<String>,
163
- /// Arbitrary data payload (parsed from Gateway response)
164
- pub data: Option<serde_json::Value>,
165
- }
166
-
167
- impl Default for SectionState {
168
- fn default() -> Self {
169
- Self {
170
- expanded: false,
171
- status: SectionStatus::Unknown,
172
- summary: String::new(),
173
- last_refresh: None,
174
- loading: false,
175
- error: None,
176
- data: None,
177
- }
178
- }
179
- }
180
-
181
- /// Main monitoring state (Phase 0 foundation).
182
- ///
183
- /// **Phase 1:** `selected_index` / `selected_section` and `sections[..].expanded` drive the TUI;
184
- /// per-section `SectionState::status` / `summary` are not used for drawing (see `build_body_lines` in
185
- /// `ui/portal_monitoring.rs` — status and one-line summaries come from `parse_snapshot` only).
186
- #[derive(Debug, Clone)]
187
- pub struct MonitoringState {
188
- /// Currently selected section (for keyboard navigation)
189
- pub selected_section: Option<MonitoringSection>,
190
- /// Index in the section list (0 = Overview, 1 = Health, etc.)
191
- pub selected_index: usize,
192
- /// Per-section state
193
- pub sections: HashMap<MonitoringSection, SectionState>,
194
- /// Auto-refresh enabled/disabled
195
- pub live_mode: bool,
196
- /// Auto-refresh interval (default 5s)
197
- pub refresh_interval: Duration,
198
- /// When auto-refresh last triggered
199
- pub last_auto_refresh: Option<Instant>,
200
- /// Gateway URL we're monitoring
201
- pub gateway_url: Option<String>,
202
- }
203
-
204
- impl Default for MonitoringState {
205
- fn default() -> Self {
206
- let mut sections = HashMap::new();
207
- for section in MonitoringSection::all() {
208
- sections.insert(section, SectionState::default());
209
- }
210
-
211
- Self {
212
- selected_section: Some(MonitoringSection::Overview),
213
- selected_index: 0,
214
- sections,
215
- live_mode: false, // Start with auto-refresh off
216
- refresh_interval: Duration::from_secs(5),
217
- last_auto_refresh: None,
218
- gateway_url: None,
219
- }
220
- }
221
- }
222
-
223
- impl MonitoringState {
224
- pub fn new(gateway_url: Option<String>) -> Self {
225
- let mut state = Self::default();
226
- state.gateway_url = gateway_url;
227
- state
228
- }
229
-
230
- /// Get the currently selected section
231
- pub fn current_section(&self) -> Option<MonitoringSection> {
232
- self.selected_section
233
- }
234
-
235
- /// Move selection up (previous section)
236
- pub fn select_previous(&mut self) {
237
- if self.selected_index > 0 {
238
- self.selected_index -= 1;
239
- self.selected_section = Some(MonitoringSection::all()[self.selected_index]);
240
- }
241
- }
242
-
243
- /// Move selection down (next section)
244
- pub fn select_next(&mut self) {
245
- let sections = MonitoringSection::all();
246
- if self.selected_index < sections.len() - 1 {
247
- self.selected_index += 1;
248
- self.selected_section = Some(sections[self.selected_index]);
249
- }
250
- }
251
-
252
- /// Toggle expand/collapse for currently selected section
253
- pub fn toggle_current_section(&mut self) {
254
- if let Some(section) = self.selected_section {
255
- if let Some(state) = self.sections.get_mut(&section) {
256
- state.expanded = !state.expanded;
257
- }
258
- }
259
- }
260
-
261
- /// Toggle expand/collapse for the section at `index` (Phase 1 Portal Monitoring).
262
- pub fn toggle_section_at_index(&mut self, index: usize) {
263
- let sections = MonitoringSection::all();
264
- if let Some(&section) = sections.get(index) {
265
- if let Some(state) = self.sections.get_mut(&section) {
266
- state.expanded = !state.expanded;
267
- }
268
- }
269
- }
270
-
271
- /// Move selection with wrap (Phase 1).
272
- pub fn select_previous_wrapped(&mut self) {
273
- let n = MonitoringSection::all().len();
274
- if n == 0 {
275
- return;
276
- }
277
- self.selected_index = (self.selected_index + n - 1) % n;
278
- self.selected_section = MonitoringSection::all().get(self.selected_index).copied();
279
- }
280
-
281
- pub fn select_next_wrapped(&mut self) {
282
- let n = MonitoringSection::all().len();
283
- if n == 0 {
284
- return;
285
- }
286
- self.selected_index = (self.selected_index + 1) % n;
287
- self.selected_section = MonitoringSection::all().get(self.selected_index).copied();
288
- }
289
-
290
- /// Expand a specific section
291
- pub fn expand_section(&mut self, section: MonitoringSection) {
292
- if let Some(state) = self.sections.get_mut(&section) {
293
- state.expanded = true;
294
- }
295
- }
296
-
297
- /// Move keyboard focus directly to a specific section.
298
- pub fn select_section(&mut self, section: MonitoringSection) {
299
- if let Some(index) = MonitoringSection::all()
300
- .iter()
301
- .position(|candidate| *candidate == section)
302
- {
303
- self.selected_index = index;
304
- self.selected_section = Some(section);
305
- }
306
- }
307
-
308
- /// Collapse a specific section
309
- pub fn collapse_section(&mut self, section: MonitoringSection) {
310
- if let Some(state) = self.sections.get_mut(&section) {
311
- state.expanded = false;
312
- }
313
- }
314
-
315
- /// Collapse all sections
316
- pub fn collapse_all(&mut self) {
317
- for state in self.sections.values_mut() {
318
- state.expanded = false;
319
- }
320
- }
321
-
322
- /// Toggle auto-refresh (live mode)
323
- pub fn toggle_live_mode(&mut self) {
324
- self.live_mode = !self.live_mode;
325
- if self.live_mode {
326
- self.last_auto_refresh = Some(Instant::now());
327
- }
328
- }
329
-
330
- /// Check if it's time for auto-refresh
331
- pub fn should_auto_refresh(&self) -> bool {
332
- if !self.live_mode {
333
- return false;
334
- }
335
- match self.last_auto_refresh {
336
- Some(last) => last.elapsed() >= self.refresh_interval,
337
- None => true,
338
- }
339
- }
340
-
341
- /// Mark that auto-refresh just happened
342
- pub fn mark_auto_refresh(&mut self) {
343
- self.last_auto_refresh = Some(Instant::now());
344
- }
345
-
346
- /// Set loading state for a section
347
- pub fn set_loading(&mut self, section: MonitoringSection, loading: bool) {
348
- if let Some(state) = self.sections.get_mut(&section) {
349
- state.loading = loading;
350
- if loading {
351
- state.status = SectionStatus::Loading;
352
- state.error = None;
353
- }
354
- }
355
- }
356
-
357
- /// Update section data after successful refresh
358
- pub fn update_section(
359
- &mut self,
360
- section: MonitoringSection,
361
- status: SectionStatus,
362
- summary: String,
363
- data: Option<serde_json::Value>,
364
- ) {
365
- if let Some(state) = self.sections.get_mut(&section) {
366
- state.status = status;
367
- state.summary = summary;
368
- state.data = data;
369
- state.last_refresh = Some(Instant::now());
370
- state.loading = false;
371
- state.error = None;
372
- }
373
- }
374
-
375
- /// Set error state for a section
376
- pub fn set_error(&mut self, section: MonitoringSection, error: String) {
377
- if let Some(state) = self.sections.get_mut(&section) {
378
- state.status = SectionStatus::Unhealthy;
379
- state.error = Some(error);
380
- state.loading = false;
381
- }
382
- }
383
-
384
- /// Get section state (immutable)
385
- pub fn get_section(&self, section: MonitoringSection) -> Option<&SectionState> {
386
- self.sections.get(&section)
387
- }
388
-
389
- /// Get time since last refresh for a section (for display)
390
- pub fn time_since_refresh(&self, section: MonitoringSection) -> Option<Duration> {
391
- self.sections
392
- .get(&section)
393
- .and_then(|s| s.last_refresh)
394
- .map(|t| t.elapsed())
395
- }
396
- }
397
-
398
- #[cfg(test)]
399
- mod tests {
400
- use super::*;
401
-
402
- #[test]
403
- fn test_section_navigation() {
404
- let mut state = MonitoringState::default();
405
- assert_eq!(state.selected_index, 0);
406
- assert_eq!(state.current_section(), Some(MonitoringSection::Overview));
407
-
408
- state.select_next();
409
- assert_eq!(state.selected_index, 1);
410
- assert_eq!(state.current_section(), Some(MonitoringSection::Health));
411
-
412
- state.select_previous();
413
- assert_eq!(state.selected_index, 0);
414
- assert_eq!(state.current_section(), Some(MonitoringSection::Overview));
415
- }
416
-
417
- #[test]
418
- fn test_toggle_expansion() {
419
- let mut state = MonitoringState::default();
420
- state.selected_section = Some(MonitoringSection::Health);
421
-
422
- let health = state.get_section(MonitoringSection::Health).unwrap();
423
- assert!(!health.expanded);
424
-
425
- state.toggle_current_section();
426
- let health = state.get_section(MonitoringSection::Health).unwrap();
427
- assert!(health.expanded);
428
- }
429
-
430
- #[test]
431
- fn test_live_mode() {
432
- let mut state = MonitoringState::default();
433
- assert!(!state.live_mode);
434
-
435
- state.toggle_live_mode();
436
- assert!(state.live_mode);
437
- assert!(state.last_auto_refresh.is_some());
438
- }
439
- }
1
+ // Phase 0: Portal Monitoring State Management
2
+ // Structures for section-based, interactive monitoring
3
+
4
+ use serde::{Deserialize, Serialize};
5
+ use std::collections::HashMap;
6
+ use std::time::{Duration, Instant};
7
+
8
+ /// Main sections of the monitoring view
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10
+ pub enum MonitoringSection {
11
+ Overview,
12
+ Health,
13
+ Dependencies,
14
+ Metrics,
15
+ Queue,
16
+ Logs,
17
+ System,
18
+ }
19
+
20
+ impl MonitoringSection {
21
+ pub fn all() -> Vec<Self> {
22
+ vec![
23
+ Self::Overview,
24
+ Self::Health,
25
+ Self::Dependencies,
26
+ Self::Metrics,
27
+ Self::Queue,
28
+ Self::Logs,
29
+ Self::System,
30
+ ]
31
+ }
32
+
33
+ pub fn display_name(&self) -> &'static str {
34
+ match self {
35
+ Self::Overview => "Overview",
36
+ Self::Health => "Health",
37
+ Self::Dependencies => "Dependencies",
38
+ Self::Metrics => "Metrics",
39
+ Self::Queue => "Queue",
40
+ Self::Logs => "Logs",
41
+ Self::System => "System",
42
+ }
43
+ }
44
+
45
+ /// Lowercase slug sent on `monitoring.refresh` (`section` field).
46
+ pub fn cli_slug(&self) -> &'static str {
47
+ match self {
48
+ Self::Overview => "overview",
49
+ Self::Health => "health",
50
+ Self::Dependencies => "dependencies",
51
+ Self::Metrics => "metrics",
52
+ Self::Queue => "queue",
53
+ Self::Logs => "logs",
54
+ Self::System => "system",
55
+ }
56
+ }
57
+
58
+ pub fn from_cli_slug(s: &str) -> Option<Self> {
59
+ let t = s.trim().to_lowercase();
60
+ match t.as_str() {
61
+ "overview" => Some(Self::Overview),
62
+ "health" => Some(Self::Health),
63
+ "dependencies" => Some(Self::Dependencies),
64
+ "metrics" => Some(Self::Metrics),
65
+ "queue" => Some(Self::Queue),
66
+ "logs" => Some(Self::Logs),
67
+ "system" => Some(Self::System),
68
+ _ => None,
69
+ }
70
+ }
71
+ }
72
+
73
+ /// Phase 3: Metrics section drill targets (`monitoring.drill` `target` field).
74
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75
+ pub enum MetricsDrillPanel {
76
+ Http,
77
+ Runs,
78
+ Queue,
79
+ Sse,
80
+ }
81
+
82
+ impl MetricsDrillPanel {
83
+ pub fn next(self) -> Self {
84
+ match self {
85
+ Self::Http => Self::Runs,
86
+ Self::Runs => Self::Queue,
87
+ Self::Queue => Self::Sse,
88
+ Self::Sse => Self::Http,
89
+ }
90
+ }
91
+
92
+ pub fn cli_target(&self) -> &'static str {
93
+ match self {
94
+ Self::Http => "metrics_http",
95
+ Self::Runs => "metrics_runs",
96
+ Self::Queue => "metrics_queue",
97
+ Self::Sse => "metrics_sse",
98
+ }
99
+ }
100
+
101
+ pub fn label(&self) -> &'static str {
102
+ match self {
103
+ Self::Http => "HTTP",
104
+ Self::Runs => "Runs",
105
+ Self::Queue => "Queue",
106
+ Self::Sse => "SSE",
107
+ }
108
+ }
109
+
110
+ pub fn from_cli_target(s: &str) -> Option<Self> {
111
+ match s.trim() {
112
+ "metrics_http" => Some(Self::Http),
113
+ "metrics_runs" => Some(Self::Runs),
114
+ "metrics_queue" => Some(Self::Queue),
115
+ "metrics_sse" => Some(Self::Sse),
116
+ _ => None,
117
+ }
118
+ }
119
+ }
120
+
121
+ /// Status indicator for sections (determines color)
122
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123
+ pub enum SectionStatus {
124
+ Healthy, // Green
125
+ Degraded, // Yellow
126
+ Unhealthy, // Red
127
+ Unknown, // Gray
128
+ Loading, // Blue/Cyan
129
+ }
130
+
131
+ impl SectionStatus {
132
+ pub fn symbol(&self) -> &'static str {
133
+ match self {
134
+ Self::Healthy => "●",
135
+ Self::Degraded => "⚠",
136
+ Self::Unhealthy => "✖",
137
+ Self::Unknown => "○",
138
+ Self::Loading => "◉",
139
+ }
140
+ }
141
+ }
142
+
143
+ /// State for each individual section.
144
+ ///
145
+ /// **Phase 1 Portal Monitoring:** only `expanded` is authoritative for the UI. Status/summary strings
146
+ /// here are not driven by `portal_monitoring` render (that path recomputes from the CLI snapshot each
147
+ /// frame). Future phases may use `update_section` to populate these; until then do not assume headers
148
+ /// reflect `SectionState::status` / `summary`.
149
+ #[derive(Debug, Clone)]
150
+ pub struct SectionState {
151
+ /// Whether section is expanded (shows detail) or collapsed (shows summary)
152
+ pub expanded: bool,
153
+ /// Current status (for color coding header)
154
+ pub status: SectionStatus,
155
+ /// One-line summary shown when collapsed
156
+ pub summary: String,
157
+ /// When this section was last refreshed
158
+ pub last_refresh: Option<Instant>,
159
+ /// Whether section is currently loading new data
160
+ pub loading: bool,
161
+ /// Error message if last refresh failed
162
+ pub error: Option<String>,
163
+ /// Arbitrary data payload (parsed from Gateway response)
164
+ pub data: Option<serde_json::Value>,
165
+ }
166
+
167
+ impl Default for SectionState {
168
+ fn default() -> Self {
169
+ Self {
170
+ expanded: false,
171
+ status: SectionStatus::Unknown,
172
+ summary: String::new(),
173
+ last_refresh: None,
174
+ loading: false,
175
+ error: None,
176
+ data: None,
177
+ }
178
+ }
179
+ }
180
+
181
+ /// Main monitoring state (Phase 0 foundation).
182
+ ///
183
+ /// **Phase 1:** `selected_index` / `selected_section` and `sections[..].expanded` drive the TUI;
184
+ /// per-section `SectionState::status` / `summary` are not used for drawing (see `build_body_lines` in
185
+ /// `ui/portal_monitoring.rs` — status and one-line summaries come from `parse_snapshot` only).
186
+ #[derive(Debug, Clone)]
187
+ pub struct MonitoringState {
188
+ /// Currently selected section (for keyboard navigation)
189
+ pub selected_section: Option<MonitoringSection>,
190
+ /// Index in the section list (0 = Overview, 1 = Health, etc.)
191
+ pub selected_index: usize,
192
+ /// Per-section state
193
+ pub sections: HashMap<MonitoringSection, SectionState>,
194
+ /// Auto-refresh enabled/disabled
195
+ pub live_mode: bool,
196
+ /// Auto-refresh interval (default 5s)
197
+ pub refresh_interval: Duration,
198
+ /// When auto-refresh last triggered
199
+ pub last_auto_refresh: Option<Instant>,
200
+ /// Gateway URL we're monitoring
201
+ pub gateway_url: Option<String>,
202
+ }
203
+
204
+ impl Default for MonitoringState {
205
+ fn default() -> Self {
206
+ let mut sections = HashMap::new();
207
+ for section in MonitoringSection::all() {
208
+ sections.insert(section, SectionState::default());
209
+ }
210
+
211
+ Self {
212
+ selected_section: Some(MonitoringSection::Overview),
213
+ selected_index: 0,
214
+ sections,
215
+ live_mode: false, // Start with auto-refresh off
216
+ refresh_interval: Duration::from_secs(5),
217
+ last_auto_refresh: None,
218
+ gateway_url: None,
219
+ }
220
+ }
221
+ }
222
+
223
+ impl MonitoringState {
224
+ pub fn new(gateway_url: Option<String>) -> Self {
225
+ let mut state = Self::default();
226
+ state.gateway_url = gateway_url;
227
+ state
228
+ }
229
+
230
+ /// Get the currently selected section
231
+ pub fn current_section(&self) -> Option<MonitoringSection> {
232
+ self.selected_section
233
+ }
234
+
235
+ /// Move selection up (previous section)
236
+ pub fn select_previous(&mut self) {
237
+ if self.selected_index > 0 {
238
+ self.selected_index -= 1;
239
+ self.selected_section = Some(MonitoringSection::all()[self.selected_index]);
240
+ }
241
+ }
242
+
243
+ /// Move selection down (next section)
244
+ pub fn select_next(&mut self) {
245
+ let sections = MonitoringSection::all();
246
+ if self.selected_index < sections.len() - 1 {
247
+ self.selected_index += 1;
248
+ self.selected_section = Some(sections[self.selected_index]);
249
+ }
250
+ }
251
+
252
+ /// Toggle expand/collapse for currently selected section
253
+ pub fn toggle_current_section(&mut self) {
254
+ if let Some(section) = self.selected_section {
255
+ if let Some(state) = self.sections.get_mut(&section) {
256
+ state.expanded = !state.expanded;
257
+ }
258
+ }
259
+ }
260
+
261
+ /// Toggle expand/collapse for the section at `index` (Phase 1 Portal Monitoring).
262
+ pub fn toggle_section_at_index(&mut self, index: usize) {
263
+ let sections = MonitoringSection::all();
264
+ if let Some(&section) = sections.get(index) {
265
+ if let Some(state) = self.sections.get_mut(&section) {
266
+ state.expanded = !state.expanded;
267
+ }
268
+ }
269
+ }
270
+
271
+ /// Move selection with wrap (Phase 1).
272
+ pub fn select_previous_wrapped(&mut self) {
273
+ let n = MonitoringSection::all().len();
274
+ if n == 0 {
275
+ return;
276
+ }
277
+ self.selected_index = (self.selected_index + n - 1) % n;
278
+ self.selected_section = MonitoringSection::all().get(self.selected_index).copied();
279
+ }
280
+
281
+ pub fn select_next_wrapped(&mut self) {
282
+ let n = MonitoringSection::all().len();
283
+ if n == 0 {
284
+ return;
285
+ }
286
+ self.selected_index = (self.selected_index + 1) % n;
287
+ self.selected_section = MonitoringSection::all().get(self.selected_index).copied();
288
+ }
289
+
290
+ /// Expand a specific section
291
+ pub fn expand_section(&mut self, section: MonitoringSection) {
292
+ if let Some(state) = self.sections.get_mut(&section) {
293
+ state.expanded = true;
294
+ }
295
+ }
296
+
297
+ /// Move keyboard focus directly to a specific section.
298
+ pub fn select_section(&mut self, section: MonitoringSection) {
299
+ if let Some(index) = MonitoringSection::all()
300
+ .iter()
301
+ .position(|candidate| *candidate == section)
302
+ {
303
+ self.selected_index = index;
304
+ self.selected_section = Some(section);
305
+ }
306
+ }
307
+
308
+ /// Collapse a specific section
309
+ pub fn collapse_section(&mut self, section: MonitoringSection) {
310
+ if let Some(state) = self.sections.get_mut(&section) {
311
+ state.expanded = false;
312
+ }
313
+ }
314
+
315
+ /// Collapse all sections
316
+ pub fn collapse_all(&mut self) {
317
+ for state in self.sections.values_mut() {
318
+ state.expanded = false;
319
+ }
320
+ }
321
+
322
+ /// Toggle auto-refresh (live mode)
323
+ pub fn toggle_live_mode(&mut self) {
324
+ self.live_mode = !self.live_mode;
325
+ if self.live_mode {
326
+ self.last_auto_refresh = Some(Instant::now());
327
+ }
328
+ }
329
+
330
+ /// Check if it's time for auto-refresh
331
+ pub fn should_auto_refresh(&self) -> bool {
332
+ if !self.live_mode {
333
+ return false;
334
+ }
335
+ match self.last_auto_refresh {
336
+ Some(last) => last.elapsed() >= self.refresh_interval,
337
+ None => true,
338
+ }
339
+ }
340
+
341
+ /// Mark that auto-refresh just happened
342
+ pub fn mark_auto_refresh(&mut self) {
343
+ self.last_auto_refresh = Some(Instant::now());
344
+ }
345
+
346
+ /// Set loading state for a section
347
+ pub fn set_loading(&mut self, section: MonitoringSection, loading: bool) {
348
+ if let Some(state) = self.sections.get_mut(&section) {
349
+ state.loading = loading;
350
+ if loading {
351
+ state.status = SectionStatus::Loading;
352
+ state.error = None;
353
+ }
354
+ }
355
+ }
356
+
357
+ /// Update section data after successful refresh
358
+ pub fn update_section(
359
+ &mut self,
360
+ section: MonitoringSection,
361
+ status: SectionStatus,
362
+ summary: String,
363
+ data: Option<serde_json::Value>,
364
+ ) {
365
+ if let Some(state) = self.sections.get_mut(&section) {
366
+ state.status = status;
367
+ state.summary = summary;
368
+ state.data = data;
369
+ state.last_refresh = Some(Instant::now());
370
+ state.loading = false;
371
+ state.error = None;
372
+ }
373
+ }
374
+
375
+ /// Set error state for a section
376
+ pub fn set_error(&mut self, section: MonitoringSection, error: String) {
377
+ if let Some(state) = self.sections.get_mut(&section) {
378
+ state.status = SectionStatus::Unhealthy;
379
+ state.error = Some(error);
380
+ state.loading = false;
381
+ }
382
+ }
383
+
384
+ /// Get section state (immutable)
385
+ pub fn get_section(&self, section: MonitoringSection) -> Option<&SectionState> {
386
+ self.sections.get(&section)
387
+ }
388
+
389
+ /// Get time since last refresh for a section (for display)
390
+ pub fn time_since_refresh(&self, section: MonitoringSection) -> Option<Duration> {
391
+ self.sections
392
+ .get(&section)
393
+ .and_then(|s| s.last_refresh)
394
+ .map(|t| t.elapsed())
395
+ }
396
+ }
397
+
398
+ #[cfg(test)]
399
+ mod tests {
400
+ use super::*;
401
+
402
+ #[test]
403
+ fn test_section_navigation() {
404
+ let mut state = MonitoringState::default();
405
+ assert_eq!(state.selected_index, 0);
406
+ assert_eq!(state.current_section(), Some(MonitoringSection::Overview));
407
+
408
+ state.select_next();
409
+ assert_eq!(state.selected_index, 1);
410
+ assert_eq!(state.current_section(), Some(MonitoringSection::Health));
411
+
412
+ state.select_previous();
413
+ assert_eq!(state.selected_index, 0);
414
+ assert_eq!(state.current_section(), Some(MonitoringSection::Overview));
415
+ }
416
+
417
+ #[test]
418
+ fn test_toggle_expansion() {
419
+ let mut state = MonitoringState::default();
420
+ state.selected_section = Some(MonitoringSection::Health);
421
+
422
+ let health = state.get_section(MonitoringSection::Health).unwrap();
423
+ assert!(!health.expanded);
424
+
425
+ state.toggle_current_section();
426
+ let health = state.get_section(MonitoringSection::Health).unwrap();
427
+ assert!(health.expanded);
428
+ }
429
+
430
+ #[test]
431
+ fn test_live_mode() {
432
+ let mut state = MonitoringState::default();
433
+ assert!(!state.live_mode);
434
+
435
+ state.toggle_live_mode();
436
+ assert!(state.live_mode);
437
+ assert!(state.last_auto_refresh.is_some());
438
+ }
439
+ }