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.
- package/apps/gateway/src/metrics/monitoring-detail.ts +162 -162
- package/apps/gateway/src/middleware/log-capture.ts +70 -70
- package/apps/gateway/src/routes/monitoring.ts +298 -298
- package/mk3-tui/src/app.rs +37 -47
- package/mk3-tui/src/monitoring/mod.rs +439 -439
- package/mk3-tui/src/ui/portal_monitoring.rs +3 -2
- package/package.json +2 -2
|
@@ -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(§ion) {
|
|
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(§ion) = sections.get(index) {
|
|
265
|
-
if let Some(state) = self.sections.get_mut(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion)
|
|
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(§ion)
|
|
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(§ion) {
|
|
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(§ion) = sections.get(index) {
|
|
265
|
+
if let Some(state) = self.sections.get_mut(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion) {
|
|
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(§ion)
|
|
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(§ion)
|
|
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
|
+
}
|