4runr-os 2.10.39 → 2.10.41

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.
Files changed (51) hide show
  1. package/apps/gateway/dist/apps/gateway/src/index.js +14 -4
  2. package/apps/gateway/dist/apps/gateway/src/index.js.map +1 -1
  3. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts +18 -0
  4. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts.map +1 -0
  5. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js +117 -0
  6. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js.map +1 -0
  7. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts +2 -0
  8. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts.map +1 -0
  9. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js +54 -0
  10. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js.map +1 -0
  11. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts +15 -0
  12. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts.map +1 -0
  13. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js +164 -0
  14. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js.map +1 -0
  15. package/apps/gateway/package-lock.json +204 -353
  16. package/apps/gateway/src/index.ts +27 -8
  17. package/apps/gateway/src/metrics/monitoring-detail.ts +162 -0
  18. package/apps/gateway/src/middleware/log-capture.ts +70 -0
  19. package/apps/gateway/src/routes/monitoring.ts +298 -0
  20. package/dist/gateway-client.d.ts +2 -0
  21. package/dist/gateway-client.d.ts.map +1 -1
  22. package/dist/gateway-client.js +22 -0
  23. package/dist/gateway-client.js.map +1 -1
  24. package/dist/tui-handlers.js +498 -0
  25. package/dist/tui-handlers.js.map +1 -1
  26. package/mk3-tui/src/app/render_scheduler.rs +111 -112
  27. package/mk3-tui/src/app.rs +1078 -295
  28. package/mk3-tui/src/debug_log.rs +131 -124
  29. package/mk3-tui/src/io/mod.rs +63 -66
  30. package/mk3-tui/src/io/protocol.rs +14 -15
  31. package/mk3-tui/src/io/stdio.rs +31 -32
  32. package/mk3-tui/src/io/ws.rs +25 -32
  33. package/mk3-tui/src/main.rs +774 -212
  34. package/mk3-tui/src/monitoring/mod.rs +428 -0
  35. package/mk3-tui/src/screens/mod.rs +53 -39
  36. package/mk3-tui/src/storage/cache.rs +221 -224
  37. package/mk3-tui/src/storage/mod.rs +5 -6
  38. package/mk3-tui/src/ui/agent_builder.rs +1148 -922
  39. package/mk3-tui/src/ui/agent_list.rs +344 -295
  40. package/mk3-tui/src/ui/boot.rs +145 -148
  41. package/mk3-tui/src/ui/connection_portal.rs +121 -98
  42. package/mk3-tui/src/ui/help.rs +340 -284
  43. package/mk3-tui/src/ui/layout.rs +966 -803
  44. package/mk3-tui/src/ui/mod.rs +1 -1
  45. package/mk3-tui/src/ui/portal_monitoring.rs +1027 -147
  46. package/mk3-tui/src/ui/run_manager.rs +784 -764
  47. package/mk3-tui/src/ui/safe_viewport.rs +236 -235
  48. package/mk3-tui/src/ui/settings.rs +414 -362
  49. package/mk3-tui/src/ui/setup_portal.rs +158 -101
  50. package/mk3-tui/src/websocket.rs +315 -308
  51. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  use crate::io::IoHandler;
2
+ use crate::monitoring::{MetricsDrillPanel, MonitoringSection, MonitoringState};
2
3
  use crate::screens::{NavigationState, Screen};
3
4
  use crate::storage::Cache;
4
5
  use crate::ui::agent_builder::AgentBuilderState;
@@ -8,7 +9,7 @@ use crate::websocket::WebSocketClient;
8
9
  use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
9
10
  use ratatui::prelude::*;
10
11
  use serde::{Deserialize, Serialize};
11
- use std::collections::VecDeque;
12
+ use std::collections::{HashMap, VecDeque};
12
13
  use std::time::{Duration, Instant};
13
14
 
14
15
  /// `HH:MM:SS` for Connection Portal activity log (UTC clock; avoids pulling in `chrono`).
@@ -47,8 +48,8 @@ pub enum AppMode {
47
48
 
48
49
  #[derive(Debug, Clone, PartialEq)]
49
50
  pub enum OperationMode {
50
- Local, // No Gateway connection
51
- Connected, // Connected to Gateway
51
+ Local, // No Gateway connection
52
+ Connected, // Connected to Gateway
52
53
  }
53
54
 
54
55
  impl OperationMode {
@@ -60,6 +61,36 @@ impl OperationMode {
60
61
  }
61
62
  }
62
63
 
64
+ #[cfg(test)]
65
+ mod portal_monitoring_tests {
66
+ use super::*;
67
+
68
+ #[test]
69
+ fn metric_history_totals_contract_keys() {
70
+ let totals = serde_json::json!({
71
+ "httpRequests": 10,
72
+ "httpRequestErrors": 1,
73
+ "queueJobsWaiting": 2,
74
+ "queueJobsActive": 3,
75
+ "queueJobsFailed": 4,
76
+ "runsActive": 5,
77
+ "sseActive": 6
78
+ });
79
+ let entry = metric_history_entry_from_totals(
80
+ "2026-01-01T00:00:00.000Z".to_string(),
81
+ totals.as_object().expect("totals object"),
82
+ );
83
+
84
+ assert_eq!(entry.http_requests, 10);
85
+ assert_eq!(entry.http_errors, 1);
86
+ assert_eq!(entry.queue_waiting, 2);
87
+ assert_eq!(entry.queue_active, 3);
88
+ assert_eq!(entry.queue_failed, 4);
89
+ assert_eq!(entry.runs_active, 5);
90
+ assert_eq!(entry.sse_active, 6);
91
+ }
92
+ }
93
+
63
94
  #[derive(Debug, Clone, PartialEq)]
64
95
  pub enum PortalField {
65
96
  GatewayUrl,
@@ -68,9 +99,9 @@ pub enum PortalField {
68
99
 
69
100
  #[derive(Debug, Clone, PartialEq)]
70
101
  pub enum GatewayType {
71
- FourRunrServer, // External 4Runr cloud server
72
- LocalBundle, // Local Gateway bundle (included with Full OS)
73
- CustomUrl, // User's own Gateway server
102
+ FourRunrServer, // External 4Runr cloud server
103
+ LocalBundle, // Local Gateway bundle (included with Full OS)
104
+ CustomUrl, // User's own Gateway server
74
105
  }
75
106
 
76
107
  impl GatewayType {
@@ -81,7 +112,7 @@ impl GatewayType {
81
112
  GatewayType::CustomUrl => "Custom URL",
82
113
  }
83
114
  }
84
-
115
+
85
116
  pub fn default_url(&self) -> &str {
86
117
  match self {
87
118
  GatewayType::FourRunrServer => "https://gateway.4runr.com",
@@ -94,7 +125,7 @@ impl GatewayType {
94
125
  // Activity Log for Connection Portal
95
126
  #[derive(Debug, Clone)]
96
127
  pub struct ActivityLogEntry {
97
- pub timestamp: String, // HH:MM:SS format
128
+ pub timestamp: String, // HH:MM:SS format
98
129
  pub level: LogLevel,
99
130
  pub message: String,
100
131
  }
@@ -121,7 +152,6 @@ pub struct ConnectionPortalState {
121
152
  pub activity_log: Vec<ActivityLogEntry>,
122
153
  }
123
154
 
124
-
125
155
  impl Default for ConnectionPortalState {
126
156
  fn default() -> Self {
127
157
  Self {
@@ -140,7 +170,10 @@ impl Default for ConnectionPortalState {
140
170
 
141
171
  impl ConnectionPortalState {
142
172
  pub fn reset(&mut self) {
143
- self.gateway_url = self.last_successful_url.clone().unwrap_or_else(|| "http://localhost:3001".to_string());
173
+ self.gateway_url = self
174
+ .last_successful_url
175
+ .clone()
176
+ .unwrap_or_else(|| "http://localhost:3001".to_string());
144
177
  self.username = String::new();
145
178
  self.focused_field = PortalField::GatewayUrl;
146
179
  self.connecting = false;
@@ -159,21 +192,21 @@ impl ConnectionPortalState {
159
192
  self.connecting = false;
160
193
  self.connecting_started = None;
161
194
  }
162
-
195
+
163
196
  pub fn toggle_field(&mut self) {
164
197
  self.focused_field = match self.focused_field {
165
198
  PortalField::GatewayUrl => PortalField::Username,
166
199
  PortalField::Username => PortalField::GatewayUrl,
167
200
  };
168
201
  }
169
-
202
+
170
203
  pub fn add_log_entry(&mut self, timestamp: String, level: LogLevel, message: String) {
171
204
  self.activity_log.push(ActivityLogEntry {
172
205
  timestamp,
173
206
  level,
174
207
  message,
175
208
  });
176
-
209
+
177
210
  // Keep last N lines so the portal can show a full connect trace on small terminals
178
211
  const MAX_ACTIVITY: usize = 80;
179
212
  if self.activity_log.len() > MAX_ACTIVITY {
@@ -183,6 +216,50 @@ impl ConnectionPortalState {
183
216
  }
184
217
 
185
218
  /// Gateway traffic snapshot (Prometheus `/metrics` via CLI WebSocket handler).
219
+ #[derive(Debug, Clone, Serialize)]
220
+ pub struct MetricHistoryEntry {
221
+ pub timestamp: String,
222
+ pub http_requests: u64,
223
+ pub http_errors: u64,
224
+ pub queue_waiting: u64,
225
+ pub queue_active: u64,
226
+ pub queue_failed: u64,
227
+ pub runs_active: u64,
228
+ pub sse_active: u64,
229
+ }
230
+
231
+ /// Phase 5 contract with `packages/os-cli/src/gateway-observability.ts` `totals`.
232
+ /// If CLI keys change, update this mapping and `metric_history_totals_contract_keys`.
233
+ pub fn metric_history_entry_from_totals(
234
+ timestamp: String,
235
+ totals: &serde_json::Map<String, serde_json::Value>,
236
+ ) -> MetricHistoryEntry {
237
+ fn read_u64(totals: &serde_json::Map<String, serde_json::Value>, key: &str) -> u64 {
238
+ totals
239
+ .get(key)
240
+ .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f.max(0.0) as u64)))
241
+ .unwrap_or(0)
242
+ }
243
+
244
+ MetricHistoryEntry {
245
+ timestamp,
246
+ http_requests: read_u64(totals, "httpRequests"),
247
+ http_errors: read_u64(totals, "httpRequestErrors"),
248
+ queue_waiting: read_u64(totals, "queueJobsWaiting"),
249
+ queue_active: read_u64(totals, "queueJobsActive"),
250
+ queue_failed: read_u64(totals, "queueJobsFailed"),
251
+ runs_active: read_u64(totals, "runsActive"),
252
+ sse_active: read_u64(totals, "sseActive"),
253
+ }
254
+ }
255
+
256
+ #[derive(Debug, Clone, Serialize)]
257
+ pub struct DependencyAlertEntry {
258
+ pub timestamp: String,
259
+ pub previous: String,
260
+ pub current: String,
261
+ }
262
+
186
263
  #[derive(Debug, Clone)]
187
264
  pub struct PortalMonitoringState {
188
265
  pub loading: bool,
@@ -190,6 +267,8 @@ pub struct PortalMonitoringState {
190
267
  pub last_updated: Option<String>,
191
268
  pub content_lines: Vec<String>,
192
269
  pub scroll_offset: usize,
270
+ /// Width-based clip for section summaries; updated each render (Phase 1).
271
+ pub summary_clip_width: usize,
193
272
  /// Body inner height from last render (for scroll bounds).
194
273
  pub viewport_lines: usize,
195
274
  /// Auto-refresh timer
@@ -200,6 +279,24 @@ pub struct PortalMonitoringState {
200
279
  pub last_http_for_delta: Option<u64>,
201
280
  /// Wall clock at last successful observability poll (for rate estimate).
202
281
  pub last_instant_for_delta: Option<Instant>,
282
+ /// Phase 2: per-section lines from `monitoring.refresh` / `monitoring.logs` (merged over snapshot).
283
+ pub section_overrides: HashMap<MonitoringSection, Vec<String>>,
284
+ /// Which section is waiting on `monitoring.refresh`.
285
+ pub section_refresh_loading: Option<MonitoringSection>,
286
+ pub logs_fetch_loading: bool,
287
+ /// Phase 3: drill-down inside Metrics (`→` / `←`); which prom-client slice is shown.
288
+ pub metrics_drill: Option<MetricsDrillPanel>,
289
+ pub metrics_drill_lines: Vec<String>,
290
+ pub metrics_drill_loading: bool,
291
+ /// Last N HTTP request totals from full snapshots (for ASCII sparkline).
292
+ pub http_total_sparkline: VecDeque<u64>,
293
+ /// Phase 5: bounded sample history (720 samples; wall-clock span depends on refresh cadence).
294
+ pub metric_history: VecDeque<MetricHistoryEntry>,
295
+ pub dependency_alerts: VecDeque<DependencyAlertEntry>,
296
+ pub last_dependency_status: Option<String>,
297
+ pub trend_view: bool,
298
+ pub last_export_path: Option<String>,
299
+ pub help_visible: bool,
203
300
  }
204
301
 
205
302
  impl Default for PortalMonitoringState {
@@ -210,12 +307,54 @@ impl Default for PortalMonitoringState {
210
307
  last_updated: None,
211
308
  content_lines: Vec::new(),
212
309
  scroll_offset: 0,
310
+ summary_clip_width: 80,
213
311
  viewport_lines: 18,
214
312
  last_refresh: None,
215
313
  auto_refresh_interval: Duration::from_secs(5),
216
314
  auto_refresh_enabled: true,
217
315
  last_http_for_delta: None,
218
316
  last_instant_for_delta: None,
317
+ section_overrides: HashMap::new(),
318
+ section_refresh_loading: None,
319
+ logs_fetch_loading: false,
320
+ metrics_drill: None,
321
+ metrics_drill_lines: Vec::new(),
322
+ metrics_drill_loading: false,
323
+ http_total_sparkline: VecDeque::new(),
324
+ metric_history: VecDeque::new(),
325
+ dependency_alerts: VecDeque::new(),
326
+ last_dependency_status: None,
327
+ trend_view: false,
328
+ last_export_path: None,
329
+ help_visible: false,
330
+ }
331
+ }
332
+ }
333
+
334
+ /// Section-based monitoring UI state (Phase 1).
335
+ #[derive(Debug, Clone)]
336
+ pub struct AdvancedMonitoringState {
337
+ pub monitoring_state: MonitoringState,
338
+ /// Optional mirror of the connected Gateway URL at construction time. **Live URL for UI is always
339
+ /// `AppState.gateway_url`** (and `MonitoringState::gateway_url` is synced from there in Portal Monitoring render).
340
+ #[allow(dead_code)]
341
+ pub gateway_url: Option<String>,
342
+ }
343
+
344
+ impl Default for AdvancedMonitoringState {
345
+ fn default() -> Self {
346
+ Self {
347
+ monitoring_state: MonitoringState::default(),
348
+ gateway_url: None,
349
+ }
350
+ }
351
+ }
352
+
353
+ impl AdvancedMonitoringState {
354
+ pub fn new(gateway_url: Option<String>) -> Self {
355
+ Self {
356
+ monitoring_state: MonitoringState::new(gateway_url.clone()),
357
+ gateway_url,
219
358
  }
220
359
  }
221
360
  }
@@ -224,12 +363,12 @@ impl Default for PortalMonitoringState {
224
363
  pub struct AppState {
225
364
  // Navigation state (NEW - replaces simple mode)
226
365
  pub navigation: NavigationState,
227
-
366
+
228
367
  // App mode (DEPRECATED - keeping for backward compatibility during transition)
229
368
  pub mode: AppMode,
230
-
369
+
231
370
  // Boot state
232
- pub boot_progress: u16, // 0-100
371
+ pub boot_progress: u16, // 0-100
233
372
  pub boot_lines: VecDeque<String>,
234
373
  pub boot_done: bool,
235
374
  pub boot_started_at: Instant,
@@ -237,7 +376,7 @@ pub struct AppState {
237
376
  pub connected: bool,
238
377
  #[allow(dead_code)]
239
378
  pub gateway_url: Option<String>,
240
-
379
+
241
380
  // Real component status
242
381
  #[allow(dead_code)]
243
382
  pub gateway_healthy: bool,
@@ -248,35 +387,35 @@ pub struct AppState {
248
387
  pub shield_detectors: Vec<String>, // ["pii", "injection", "hallucination"]
249
388
  pub sentinel_state: String, // "idle", "watching", "triggered"
250
389
  pub sentinel_active_runs: usize,
251
-
390
+
252
391
  // Real metrics
253
392
  pub total_runs: u64,
254
393
  #[allow(dead_code)]
255
394
  pub active_sse_connections: u64,
256
395
  #[allow(dead_code)]
257
396
  pub idempotency_store_size: u64,
258
-
397
+
259
398
  // System resources
260
399
  pub cpu: f64,
261
400
  pub mem: f64,
262
401
  pub network_status: String,
263
-
402
+
264
403
  // Real logs
265
404
  pub logs: VecDeque<String>,
266
-
405
+
267
406
  // Capabilities
268
407
  pub capabilities: Vec<String>,
269
-
408
+
270
409
  // UI state
271
410
  pub command_input: String,
272
411
  pub command_focused: bool,
273
412
  pub log_scroll: usize,
274
-
413
+
275
414
  // Animation state
276
415
  pub tick: u64,
277
416
  pub spinner_frame: usize,
278
417
  pub uptime_secs: u64, // Updated from real clock in main loop, not from tick()
279
-
418
+
280
419
  // Perf overlay
281
420
  pub perf_overlay: bool,
282
421
  pub render_count: u64,
@@ -284,7 +423,7 @@ pub struct AppState {
284
423
  pub render_durations: VecDeque<u64>, // ms
285
424
  pub render_scheduled_count: u64,
286
425
  pub log_write_count: u64,
287
-
426
+
288
427
  // Screen-specific state
289
428
  pub agent_builder: AgentBuilderState,
290
429
  pub run_manager: RunManagerState,
@@ -295,28 +434,34 @@ pub struct AppState {
295
434
  pub portal_monitoring: PortalMonitoringState,
296
435
  /// Last successful observability pull (for auto-refresh interval).
297
436
  pub portal_observability_last_poll: Option<Instant>,
298
-
437
+
438
+ /// Section selection / expand state for Portal Monitoring (Phase 1).
439
+ pub advanced_monitoring: AdvancedMonitoringState,
440
+
299
441
  // Command tracking for response handling (Step 5.1)
300
442
  pub pending_agent_create_id: Option<String>,
301
443
  pub pending_agent_list_id: Option<String>,
302
444
  pub pending_agent_delete_id: Option<String>,
303
445
  pub pending_gateway_connect_id: Option<String>,
304
- pub pending_gateway_health_id: Option<String>, // Phase 2.1: Track health checks
446
+ pub pending_gateway_health_id: Option<String>, // Phase 2.1: Track health checks
305
447
  pub pending_gateway_observability_id: Option<String>,
306
- pub pending_setup_detect_id: Option<String>, // Track setup.detect command
307
-
448
+ pub pending_monitoring_refresh_id: Option<String>,
449
+ pub pending_monitoring_logs_id: Option<String>,
450
+ pub pending_monitoring_drill_id: Option<String>,
451
+ pub pending_setup_detect_id: Option<String>, // Track setup.detect command
452
+
308
453
  // Step 6: Gateway runs
309
454
  pub pending_run_list_id: Option<String>,
310
455
  pub pending_run_get_id: Option<String>,
311
456
  pub pending_run_cancel_id: Option<String>,
312
457
  pub pending_run_quick_id: Option<String>,
313
-
458
+
314
459
  // Deletion confirmation (Step 5.5)
315
460
  pub agent_delete_requested: bool,
316
-
461
+
317
462
  // Mode tracking (Step 7)
318
463
  pub operation_mode: OperationMode,
319
-
464
+
320
465
  // Local cache
321
466
  pub cache: Option<Cache>,
322
467
  pub cache_loaded: bool,
@@ -380,12 +525,16 @@ impl Default for AppState {
380
525
  setup_portal: SetupPortalState::default(),
381
526
  portal_monitoring: PortalMonitoringState::default(),
382
527
  portal_observability_last_poll: None,
528
+ advanced_monitoring: AdvancedMonitoringState::default(),
383
529
  pending_agent_create_id: None,
384
530
  pending_agent_list_id: None,
385
531
  pending_agent_delete_id: None,
386
532
  pending_gateway_connect_id: None,
387
- pending_gateway_health_id: None, // Phase 2.1: Initialize health check tracking
533
+ pending_gateway_health_id: None, // Phase 2.1: Initialize health check tracking
388
534
  pending_gateway_observability_id: None,
535
+ pending_monitoring_refresh_id: None,
536
+ pending_monitoring_logs_id: None,
537
+ pending_monitoring_drill_id: None,
389
538
  pending_setup_detect_id: None,
390
539
  pending_run_list_id: None,
391
540
  pending_run_get_id: None,
@@ -414,7 +563,7 @@ impl GatewayOption {
414
563
  GatewayOption::CustomUrl => "Custom URL",
415
564
  }
416
565
  }
417
-
566
+
418
567
  pub fn default_url(&self) -> &str {
419
568
  match self {
420
569
  GatewayOption::LocalBundle => "http://localhost:3001",
@@ -479,15 +628,15 @@ impl SetupPortalState {
479
628
  // Forward (Down key, Scroll Down)
480
629
  (LocalBundle, 1) => CloudServer,
481
630
  (CloudServer, 1) => CustomUrl,
482
- (CustomUrl, 1) => LocalBundle, // Wrap to top
631
+ (CustomUrl, 1) => LocalBundle, // Wrap to top
483
632
  // Backward (Up key, Scroll Up)
484
- (LocalBundle, -1) => CustomUrl, // Wrap to bottom
633
+ (LocalBundle, -1) => CustomUrl, // Wrap to bottom
485
634
  (CloudServer, -1) => LocalBundle,
486
635
  (CustomUrl, -1) => CloudServer,
487
636
  _ => return, // No change for invalid direction
488
637
  };
489
638
  }
490
-
639
+
491
640
  /// Clear detection state and errors (full reset; prefer [`soft_open`](Self::soft_open) when reopening Setup).
492
641
  #[allow(dead_code)]
493
642
  pub fn reset(&mut self) {
@@ -502,7 +651,7 @@ impl SetupPortalState {
502
651
  pub struct AgentListState {
503
652
  pub agents: Vec<AgentInfo>,
504
653
  pub selected_index: usize,
505
- pub detail_view: Option<usize>, // None = list view, Some(index) = detail popup
654
+ pub detail_view: Option<usize>, // None = list view, Some(index) = detail popup
506
655
  }
507
656
 
508
657
  impl Default for AgentListState {
@@ -538,16 +687,16 @@ impl App {
538
687
  pub fn new() -> Self {
539
688
  let render_scheduler = RenderScheduler::new();
540
689
  let run_mode = render_scheduler.run_mode();
541
-
690
+
542
691
  // Input debounce: browser 10ms, local 5ms (minimal delay for smooth typing)
543
692
  let input_debounce_duration = match run_mode {
544
693
  RunMode::Browser => Duration::from_millis(10),
545
694
  RunMode::Local => Duration::from_millis(5),
546
695
  };
547
-
696
+
548
697
  let mut state = AppState::default();
549
698
  Self::load_cached_data(&mut state);
550
-
699
+
551
700
  Self {
552
701
  state,
553
702
  render_scheduler,
@@ -557,10 +706,21 @@ impl App {
557
706
  }
558
707
 
559
708
  /// Ask CLI to pull Gateway `GET /metrics` and return a condensed snapshot for Portal Monitoring.
709
+ ///
710
+ /// **Intentional command split:** the TUI uses this `gateway.observability` path for the
711
+ /// **Overview** row (full health + Prometheus snapshot in one response). Per-section refresh
712
+ /// uses `begin_monitoring_refresh_request` → `monitoring.refresh` instead. The CLI also maps
713
+ /// `monitoring.refresh` with `section: "overview"` to the same handler for non-TUI callers.
560
714
  pub fn begin_portal_observability_request(&mut self, ws: &WebSocketClient) {
561
715
  if self.state.pending_gateway_observability_id.is_some() {
562
716
  return;
563
717
  }
718
+ if self.state.pending_monitoring_refresh_id.is_some()
719
+ || self.state.pending_monitoring_logs_id.is_some()
720
+ || self.state.pending_monitoring_drill_id.is_some()
721
+ {
722
+ return;
723
+ }
564
724
  self.state.portal_monitoring.loading = true;
565
725
  self.state.portal_monitoring.error = None;
566
726
  let nonce = std::time::SystemTime::now()
@@ -580,6 +740,188 @@ impl App {
580
740
  }
581
741
  }
582
742
 
743
+ /// Phase 2: refresh one Portal Monitoring section via CLI (`monitoring.refresh` → Gateway APIs).
744
+ pub fn begin_monitoring_refresh_request(
745
+ &mut self,
746
+ ws: &WebSocketClient,
747
+ section: MonitoringSection,
748
+ ) {
749
+ if self.state.pending_gateway_observability_id.is_some()
750
+ || self.state.pending_monitoring_refresh_id.is_some()
751
+ || self.state.pending_monitoring_logs_id.is_some()
752
+ || self.state.pending_monitoring_drill_id.is_some()
753
+ {
754
+ return;
755
+ }
756
+ let nonce = std::time::SystemTime::now()
757
+ .duration_since(std::time::UNIX_EPOCH)
758
+ .map(|d| d.as_millis())
759
+ .unwrap_or(0);
760
+ let slug = section.cli_slug();
761
+ let data = serde_json::json!({ "section": slug, "nonce": nonce });
762
+ match ws.send_command("monitoring.refresh", Some(data)) {
763
+ Ok(id) => {
764
+ self.state.pending_monitoring_refresh_id = Some(id);
765
+ self.state.portal_monitoring.section_refresh_loading = Some(section);
766
+ self.state.portal_monitoring.error = None;
767
+ }
768
+ Err(e) => {
769
+ self.state.portal_monitoring.section_refresh_loading = None;
770
+ self.state.portal_monitoring.error = Some(format!("{}", e));
771
+ self.request_immediate_render("monitoring_refresh_send_err");
772
+ }
773
+ }
774
+ }
775
+
776
+ /// Phase 2: fetch Gateway log buffer (`monitoring.logs`).
777
+ pub fn begin_monitoring_logs_request(&mut self, ws: &WebSocketClient) {
778
+ if self.state.pending_gateway_observability_id.is_some()
779
+ || self.state.pending_monitoring_refresh_id.is_some()
780
+ || self.state.pending_monitoring_logs_id.is_some()
781
+ || self.state.pending_monitoring_drill_id.is_some()
782
+ {
783
+ return;
784
+ }
785
+ let nonce = std::time::SystemTime::now()
786
+ .duration_since(std::time::UNIX_EPOCH)
787
+ .map(|d| d.as_millis())
788
+ .unwrap_or(0);
789
+ let data = serde_json::json!({ "limit": 100u32, "nonce": nonce });
790
+ match ws.send_command("monitoring.logs", Some(data)) {
791
+ Ok(id) => {
792
+ self.state.pending_monitoring_logs_id = Some(id);
793
+ self.state.portal_monitoring.logs_fetch_loading = true;
794
+ self.state.portal_monitoring.error = None;
795
+ }
796
+ Err(e) => {
797
+ self.state.portal_monitoring.logs_fetch_loading = false;
798
+ self.state.portal_monitoring.error = Some(format!("{}", e));
799
+ self.request_immediate_render("monitoring_logs_send_err");
800
+ }
801
+ }
802
+ }
803
+
804
+ /// Phase 3: Gateway drill-down (`monitoring.drill`) for Metrics sub-panels (HTTP / Runs / Queue / SSE).
805
+ pub fn begin_monitoring_drill_request(
806
+ &mut self,
807
+ ws: &WebSocketClient,
808
+ panel: MetricsDrillPanel,
809
+ ) {
810
+ if self.state.pending_gateway_observability_id.is_some()
811
+ || self.state.pending_monitoring_refresh_id.is_some()
812
+ || self.state.pending_monitoring_logs_id.is_some()
813
+ || self.state.pending_monitoring_drill_id.is_some()
814
+ {
815
+ return;
816
+ }
817
+ let nonce = std::time::SystemTime::now()
818
+ .duration_since(std::time::UNIX_EPOCH)
819
+ .map(|d| d.as_millis())
820
+ .unwrap_or(0);
821
+ let target = panel.cli_target();
822
+ let data = serde_json::json!({ "target": target, "nonce": nonce });
823
+ match ws.send_command("monitoring.drill", Some(data)) {
824
+ Ok(id) => {
825
+ self.state.pending_monitoring_drill_id = Some(id);
826
+ self.state.portal_monitoring.metrics_drill = Some(panel);
827
+ self.state.portal_monitoring.metrics_drill_loading = true;
828
+ self.state.portal_monitoring.metrics_drill_lines.clear();
829
+ self.state.portal_monitoring.error = None;
830
+ }
831
+ Err(e) => {
832
+ self.state.portal_monitoring.metrics_drill_loading = false;
833
+ self.state.portal_monitoring.error = Some(format!("{}", e));
834
+ self.request_immediate_render("monitoring_drill_send_err");
835
+ }
836
+ }
837
+ }
838
+
839
+ /// Phase 3: dependency pools + queue counts (`dependencies/detail`).
840
+ pub fn begin_dependencies_detail_drill(&mut self, ws: &WebSocketClient) {
841
+ if self.state.pending_gateway_observability_id.is_some()
842
+ || self.state.pending_monitoring_refresh_id.is_some()
843
+ || self.state.pending_monitoring_logs_id.is_some()
844
+ || self.state.pending_monitoring_drill_id.is_some()
845
+ {
846
+ return;
847
+ }
848
+ let nonce = std::time::SystemTime::now()
849
+ .duration_since(std::time::UNIX_EPOCH)
850
+ .map(|d| d.as_millis())
851
+ .unwrap_or(0);
852
+ let data = serde_json::json!({ "target": "dependencies_detail", "nonce": nonce });
853
+ match ws.send_command("monitoring.drill", Some(data)) {
854
+ Ok(id) => {
855
+ self.state.pending_monitoring_drill_id = Some(id);
856
+ self.state.portal_monitoring.section_refresh_loading =
857
+ Some(MonitoringSection::Dependencies);
858
+ self.state.portal_monitoring.error = None;
859
+ }
860
+ Err(e) => {
861
+ self.state.portal_monitoring.section_refresh_loading = None;
862
+ self.state.portal_monitoring.error = Some(format!("{}", e));
863
+ self.request_immediate_render("deps_drill_send_err");
864
+ }
865
+ }
866
+ }
867
+
868
+ /// Phase 4: run local CLI/host diagnostics for the System section.
869
+ pub fn begin_system_diagnostics_request(&mut self, ws: &WebSocketClient) {
870
+ if self.state.pending_gateway_observability_id.is_some()
871
+ || self.state.pending_monitoring_refresh_id.is_some()
872
+ || self.state.pending_monitoring_logs_id.is_some()
873
+ || self.state.pending_monitoring_drill_id.is_some()
874
+ {
875
+ return;
876
+ }
877
+ let nonce = std::time::SystemTime::now()
878
+ .duration_since(std::time::UNIX_EPOCH)
879
+ .map(|d| d.as_millis())
880
+ .unwrap_or(0);
881
+ let data = serde_json::json!({ "target": "system_diagnostics", "nonce": nonce });
882
+ match ws.send_command("monitoring.drill", Some(data)) {
883
+ Ok(id) => {
884
+ self.state.pending_monitoring_drill_id = Some(id);
885
+ self.state.portal_monitoring.section_refresh_loading =
886
+ Some(MonitoringSection::System);
887
+ self.state.portal_monitoring.error = None;
888
+ }
889
+ Err(e) => {
890
+ self.state.portal_monitoring.section_refresh_loading = None;
891
+ self.state.portal_monitoring.error = Some(format!("{}", e));
892
+ self.request_immediate_render("system_diagnostics_send_err");
893
+ }
894
+ }
895
+ }
896
+
897
+ pub fn export_portal_monitoring_snapshot(&mut self) -> anyhow::Result<String> {
898
+ let mut overrides = serde_json::Map::new();
899
+ for (section, lines) in &self.state.portal_monitoring.section_overrides {
900
+ overrides.insert(section.cli_slug().to_string(), serde_json::json!(lines));
901
+ }
902
+ let payload = serde_json::json!({
903
+ "exportedAt": wall_clock_hms(),
904
+ "gatewayUrl": self.state.gateway_url.clone(),
905
+ "lastUpdated": self.state.portal_monitoring.last_updated.clone(),
906
+ "contentLines": self.state.portal_monitoring.content_lines.clone(),
907
+ "sectionOverrides": overrides,
908
+ "metricHistory": self.state.portal_monitoring.metric_history.clone(),
909
+ "dependencyAlerts": self.state.portal_monitoring.dependency_alerts.clone(),
910
+ "trendView": self.state.portal_monitoring.trend_view,
911
+ });
912
+ let unix = std::time::SystemTime::now()
913
+ .duration_since(std::time::UNIX_EPOCH)
914
+ .map(|d| d.as_secs())
915
+ .unwrap_or(0);
916
+ let mut path = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
917
+ path.push(format!("4runr-monitoring-export-{unix}.json"));
918
+ let body = serde_json::to_string_pretty(&payload)?;
919
+ std::fs::write(&path, body)?;
920
+ let display = path.display().to_string();
921
+ self.state.portal_monitoring.last_export_path = Some(display.clone());
922
+ Ok(display)
923
+ }
924
+
583
925
  /// CLI ↔ TUI WebSocket lost: drop Gateway link UI and leave Portal Monitoring / Connection Portal so the session does not appear still "connected".
584
926
  pub fn on_cli_backend_disconnect(&mut self) {
585
927
  use crate::screens::Screen;
@@ -591,8 +933,12 @@ impl App {
591
933
  self.state.pending_gateway_connect_id = None;
592
934
  self.state.pending_gateway_health_id = None;
593
935
  self.state.pending_gateway_observability_id = None;
936
+ self.state.pending_monitoring_refresh_id = None;
937
+ self.state.pending_monitoring_logs_id = None;
938
+ self.state.pending_monitoring_drill_id = None;
594
939
  self.state.portal_observability_last_poll = None;
595
940
  self.state.portal_monitoring = PortalMonitoringState::default();
941
+ self.state.advanced_monitoring = AdvancedMonitoringState::default();
596
942
  self.state.connection_portal.finish_connecting();
597
943
  self.state.connection_portal.connection_success = false;
598
944
  self.state.connection_portal.connecting = false;
@@ -615,33 +961,35 @@ impl App {
615
961
  // Load cached agents
616
962
  let agents = cache.get_agents();
617
963
  if !agents.is_empty() {
618
- state.capabilities = agents.iter()
619
- .map(|a| a.name.clone())
620
- .collect();
964
+ state.capabilities = agents.iter().map(|a| a.name.clone()).collect();
621
965
  state.cache_loaded = true;
622
966
  }
623
-
967
+
624
968
  // Load cached system status
625
969
  if let Some(status) = cache.get_system_status() {
626
- state.network_status = if status.connected { "Connected (cached)".to_string() } else { "Disconnected (cached)".to_string() };
970
+ state.network_status = if status.connected {
971
+ "Connected (cached)".to_string()
972
+ } else {
973
+ "Disconnected (cached)".to_string()
974
+ };
627
975
  state.posture_status = format!("{} (cached)", status.posture);
628
976
  }
629
977
  }
630
978
  }
631
-
979
+
632
980
  pub fn request_render(&mut self, reason: &str) -> bool {
633
981
  self.render_scheduler.request_render(reason)
634
982
  }
635
-
983
+
636
984
  /// Request immediate render (bypass throttling) - for critical input events
637
985
  pub fn request_immediate_render(&mut self, reason: &str) -> bool {
638
986
  self.render_scheduler.request_immediate_render(reason)
639
987
  }
640
-
988
+
641
989
  pub fn should_render(&mut self) -> bool {
642
990
  self.render_scheduler.should_render()
643
991
  }
644
-
992
+
645
993
  pub fn record_render(&mut self, duration_ms: u64) {
646
994
  self.state.render_count += 1;
647
995
  self.state.render_durations.push_back(duration_ms);
@@ -650,30 +998,30 @@ impl App {
650
998
  }
651
999
  self.state.last_render_time = Instant::now();
652
1000
  }
653
-
1001
+
654
1002
  pub fn run_mode(&self) -> RunMode {
655
1003
  self.render_scheduler.run_mode()
656
1004
  }
657
-
1005
+
658
1006
  pub fn render_scheduler_stats(&self) -> (u64, u64) {
659
1007
  (
660
1008
  self.render_scheduler.render_scheduled_count(),
661
1009
  self.render_scheduler.min_render_interval_ms(),
662
1010
  )
663
1011
  }
664
-
1012
+
665
1013
  /// IMPORTANT: Only call tick() when poll() times out (NO keyboard input)!
666
1014
  /// Otherwise animations will flash weirdly when typing.
667
- ///
1015
+ ///
668
1016
  /// NOTE: Uptime is now tracked via Instant::now() in main loop,
669
1017
  /// so we don't increment it here anymore.
670
1018
  pub fn tick(&mut self) {
671
1019
  self.state.tick = self.state.tick.wrapping_add(1);
672
-
1020
+
673
1021
  // Update spinner frame (for "Processing..." indicator only)
674
1022
  // Cycle through 8 braille spinner frames
675
1023
  self.state.spinner_frame = (self.state.spinner_frame + 1) % 8;
676
-
1024
+
677
1025
  // Boot timeline: update progress every ~150-250ms
678
1026
  if self.state.mode == AppMode::Boot && !self.state.boot_done {
679
1027
  let elapsed = self.state.boot_started_at.elapsed();
@@ -684,15 +1032,15 @@ impl App {
684
1032
  (Duration::from_millis(1100), "Telemetry connected…"),
685
1033
  (Duration::from_millis(1400), "System ready."),
686
1034
  ];
687
-
1035
+
688
1036
  let mut current_progress = 0;
689
1037
  let mut step_idx = 0;
690
-
1038
+
691
1039
  for (i, (delay, msg)) in boot_steps.iter().enumerate() {
692
1040
  if elapsed >= *delay {
693
1041
  step_idx = i + 1;
694
1042
  current_progress = ((i + 1) * 100 / boot_steps.len()) as u16;
695
-
1043
+
696
1044
  // Add line if not already added
697
1045
  if self.state.boot_lines.len() <= i {
698
1046
  self.state.boot_lines.push_back(msg.to_string());
@@ -700,23 +1048,28 @@ impl App {
700
1048
  }
701
1049
  }
702
1050
  }
703
-
1051
+
704
1052
  self.state.boot_progress = current_progress.min(100);
705
-
1053
+
706
1054
  // Mark boot done after last step
707
1055
  if step_idx >= boot_steps.len() && self.state.boot_progress >= 100 {
708
1056
  self.state.boot_done = true;
709
1057
  self.request_render("boot_done");
710
1058
  }
711
1059
  }
712
-
1060
+
713
1061
  // NOTE: pulse_frame is NOT updated here - we use static dots for status indicators
714
1062
  // to prevent flashing when typing. Only use animated pulse for non-status elements.
715
1063
  }
716
1064
 
717
- pub fn handle_input(&mut self, key: KeyEvent, _io: &mut IoHandler, ws_client: Option<&WebSocketClient>) -> anyhow::Result<bool> {
1065
+ pub fn handle_input(
1066
+ &mut self,
1067
+ key: KeyEvent,
1068
+ _io: &mut IoHandler,
1069
+ ws_client: Option<&WebSocketClient>,
1070
+ ) -> anyhow::Result<bool> {
718
1071
  use crossterm::event::KeyModifiers;
719
-
1072
+
720
1073
  // === EXIT SHORTCUTS (always checked first) ===
721
1074
  if key.modifiers.contains(KeyModifiers::CONTROL) {
722
1075
  match key.code {
@@ -726,45 +1079,45 @@ impl App {
726
1079
  _ => return Ok(false), // Ignore other Ctrl combos
727
1080
  }
728
1081
  }
729
-
1082
+
730
1083
  if key.code == KeyCode::F(10) {
731
1084
  return Ok(true); // Exit
732
1085
  }
733
-
1086
+
734
1087
  // === BOOT MODE: Any key to continue ===
735
1088
  if self.state.mode == AppMode::Boot && self.state.boot_done {
736
1089
  // Any key press switches to main dashboard
737
1090
  self.navigate_to(Screen::Main);
738
1091
  return Ok(false);
739
1092
  }
740
-
1093
+
741
1094
  // In boot mode, ignore other input until boot is done
742
1095
  if self.state.mode == AppMode::Boot {
743
1096
  return Ok(false);
744
1097
  }
745
-
1098
+
746
1099
  // F12 - Toggle perf overlay
747
1100
  if key.code == KeyCode::F(12) {
748
1101
  self.state.perf_overlay = !self.state.perf_overlay;
749
1102
  self.request_render("perf_toggle");
750
1103
  return Ok(false);
751
1104
  }
752
-
1105
+
753
1106
  // === AGENT BUILDER INPUT HANDLING ===
754
1107
  if self.state.navigation.current_screen() == &Screen::AgentBuilder {
755
1108
  return self.handle_agent_builder_input(key, ws_client);
756
1109
  }
757
-
1110
+
758
1111
  // === RUN MANAGER INPUT HANDLING ===
759
1112
  if self.state.navigation.current_screen() == &Screen::RunManager {
760
1113
  return self.handle_run_manager_input(key, ws_client);
761
1114
  }
762
-
1115
+
763
1116
  // === SETTINGS INPUT HANDLING ===
764
1117
  if self.state.navigation.current_screen() == &Screen::Settings {
765
1118
  return self.handle_settings_input(key);
766
1119
  }
767
-
1120
+
768
1121
  // === CONNECTION PORTAL INPUT HANDLING ===
769
1122
  if self.state.navigation.current_screen() == &Screen::ConnectionPortal {
770
1123
  return self.handle_connection_portal_input(key, ws_client);
@@ -774,17 +1127,17 @@ impl App {
774
1127
  if self.state.navigation.current_screen() == &Screen::PortalMonitoring {
775
1128
  return self.handle_portal_monitoring_input(key, ws_client);
776
1129
  }
777
-
1130
+
778
1131
  // === SETUP PORTAL INPUT HANDLING ===
779
1132
  if self.state.navigation.current_screen() == &Screen::SetupPortal {
780
1133
  return self.handle_setup_portal_input(key, ws_client);
781
1134
  }
782
-
1135
+
783
1136
  // === AGENT LIST INPUT HANDLING ===
784
1137
  if self.state.navigation.current_screen() == &Screen::AgentList {
785
1138
  return self.handle_agent_list_input(key, ws_client);
786
1139
  }
787
-
1140
+
788
1141
  // === MAIN INPUT HANDLING ===
789
1142
  match key.code {
790
1143
  // F2 - Open Setup Portal (Gateway options) from main screen
@@ -800,7 +1153,8 @@ impl App {
800
1153
  } else {
801
1154
  self.state.setup_portal.detecting = false;
802
1155
  self.state.setup_portal.detecting_since = None;
803
- self.state.setup_portal.error = Some("Failed to start detection".to_string());
1156
+ self.state.setup_portal.error =
1157
+ Some("Failed to start detection".to_string());
804
1158
  }
805
1159
  } else {
806
1160
  self.state.setup_portal.error = Some(
@@ -808,7 +1162,9 @@ impl App {
808
1162
  .into(),
809
1163
  );
810
1164
  }
811
- self.state.logs.push_back("[NAV] Opening Setup Portal (F2)...".into());
1165
+ self.state
1166
+ .logs
1167
+ .push_back("[NAV] Opening Setup Portal (F2)...".into());
812
1168
  self.request_immediate_render("open_setup_portal");
813
1169
  }
814
1170
  // Typing - ALL characters go to command input (immediate render for responsiveness)
@@ -819,14 +1175,14 @@ impl App {
819
1175
  // OPTIMIZATION: Immediate render for instant typing feedback
820
1176
  self.request_immediate_render("typing_input");
821
1177
  }
822
-
1178
+
823
1179
  // Submit command
824
1180
  KeyCode::Enter => {
825
1181
  if !self.state.command_input.is_empty() {
826
1182
  let cmd: String = self.state.command_input.drain(..).collect();
827
1183
  self.state.command_focused = false;
828
1184
  self.input_debounce = None;
829
-
1185
+
830
1186
  match cmd.to_lowercase().as_str() {
831
1187
  "quit" | "exit" => return Ok(true),
832
1188
  "clear" => self.state.logs.clear(),
@@ -834,59 +1190,131 @@ impl App {
834
1190
  // Display help in operations log - clean and organized
835
1191
  // NOTE: VecDeque displays newest items first, so we push in REVERSE order
836
1192
  // (Last item pushed appears at top of log)
837
-
1193
+
838
1194
  // Push Keyboard Shortcuts FIRST (will appear at BOTTOM)
839
- self.state.logs.push_back(" Settings: ↑/↓=Navigate, Space=Toggle, Enter=Save".to_string());
840
- self.state.logs.push_back(" Run Manager: ↑/↓=Navigate, F=Filter, S=Sort, R=Refresh".to_string());
841
- self.state.logs.push_back(" Agent Builder: Enter=Next, Backspace=Back, ESC=Cancel".to_string());
842
- self.state.logs.push_back("────────────────────────────────────────────────────────────────".to_string());
843
- self.state.logs.push_back("⌨️ KEYBOARD SHORTCUTS".to_string());
1195
+ self.state.logs.push_back(
1196
+ " Settings: ↑/↓=Navigate, Space=Toggle, Enter=Save"
1197
+ .to_string(),
1198
+ );
1199
+ self.state.logs.push_back(
1200
+ " Run Manager: ↑/↓=Navigate, F=Filter, S=Sort, R=Refresh"
1201
+ .to_string(),
1202
+ );
1203
+ self.state.logs.push_back(
1204
+ " Agent Builder: Enter=Next, Backspace=Back, ESC=Cancel"
1205
+ .to_string(),
1206
+ );
1207
+ self.state.logs.push_back(
1208
+ "────────────────────────────────────────────────────────────────"
1209
+ .to_string(),
1210
+ );
1211
+ self.state
1212
+ .logs
1213
+ .push_back("⌨️ KEYBOARD SHORTCUTS".to_string());
844
1214
  self.state.logs.push_back("".to_string());
845
-
1215
+
846
1216
  // WebSocket Commands
847
- self.state.logs.push_back(" tool.list - List available tools".to_string());
848
- self.state.logs.push_back(" run.list - List runs (requires gateway)".to_string());
849
- self.state.logs.push_back(" system.status - Get system status".to_string());
850
- self.state.logs.push_back(" agent.delete - Delete agent (data: {name})".to_string());
851
- self.state.logs.push_back(" agent.create - Create new agent (use Agent Builder)".to_string());
852
- self.state.logs.push_back(" agent.get - Get agent details (data: {name})".to_string());
853
- self.state.logs.push_back(" agent.list - List all agents".to_string());
854
- self.state.logs.push_back("────────────────────────────────────────────────────────────────".to_string());
855
- self.state.logs.push_back("🌐 WEBSOCKET COMMANDS (requires connection)".to_string());
1217
+ self.state.logs.push_back(
1218
+ " tool.list - List available tools".to_string(),
1219
+ );
1220
+ self.state.logs.push_back(
1221
+ " run.list - List runs (requires gateway)".to_string(),
1222
+ );
1223
+ self.state
1224
+ .logs
1225
+ .push_back(" system.status - Get system status".to_string());
1226
+ self.state.logs.push_back(
1227
+ " agent.delete - Delete agent (data: {name})".to_string(),
1228
+ );
1229
+ self.state.logs.push_back(
1230
+ " agent.create - Create new agent (use Agent Builder)"
1231
+ .to_string(),
1232
+ );
1233
+ self.state.logs.push_back(
1234
+ " agent.get - Get agent details (data: {name})"
1235
+ .to_string(),
1236
+ );
1237
+ self.state
1238
+ .logs
1239
+ .push_back(" agent.list - List all agents".to_string());
1240
+ self.state.logs.push_back(
1241
+ "────────────────────────────────────────────────────────────────"
1242
+ .to_string(),
1243
+ );
1244
+ self.state.logs.push_back(
1245
+ "🌐 WEBSOCKET COMMANDS (requires connection)".to_string(),
1246
+ );
856
1247
  self.state.logs.push_back("".to_string());
857
-
1248
+
858
1249
  // Local Commands
859
- self.state.logs.push_back(" :perf - Show performance stats".to_string());
860
- self.state.logs.push_back(" help - Show this help".to_string());
861
- self.state.logs.push_back(" clear - Clear operations log".to_string());
862
- self.state.logs.push_back(" quit, exit - Exit application".to_string());
863
- self.state.logs.push_back("────────────────────────────────────────────────────────────────".to_string());
1250
+ self.state.logs.push_back(
1251
+ " :perf - Show performance stats".to_string(),
1252
+ );
1253
+ self.state
1254
+ .logs
1255
+ .push_back(" help - Show this help".to_string());
1256
+ self.state.logs.push_back(
1257
+ " clear - Clear operations log".to_string(),
1258
+ );
1259
+ self.state
1260
+ .logs
1261
+ .push_back(" quit, exit - Exit application".to_string());
1262
+ self.state.logs.push_back(
1263
+ "────────────────────────────────────────────────────────────────"
1264
+ .to_string(),
1265
+ );
864
1266
  self.state.logs.push_back("💻 LOCAL COMMANDS".to_string());
865
1267
  self.state.logs.push_back("".to_string());
866
-
1268
+
867
1269
  // Navigation Commands
868
1270
  self.state.logs.push_back(" disconnect - Disconnect from Gateway (return to LOCAL mode)".to_string());
869
1271
  self.state.logs.push_back(" portal monitoring - Gateway /metrics traffic (R refresh, ↑↓ scroll)".to_string());
870
1272
  self.state.logs.push_back(" connect portal - Open Connection Portal (connect to Gateway)".to_string());
871
- self.state.logs.push_back(" a, agents - Open Agent List (view, select, delete)".to_string());
872
- self.state.logs.push_back(" config, settings - Open Settings (mode, AI provider)".to_string());
873
- self.state.logs.push_back(" runs - Open Run Manager (list, filter, sort)".to_string());
874
- self.state.logs.push_back(" build - Open Agent Builder (6-step wizard)".to_string());
875
- self.state.logs.push_back("────────────────────────────────────────────────────────────────".to_string());
876
- self.state.logs.push_back("🧭 NAVIGATION COMMANDS".to_string());
1273
+ self.state.logs.push_back(
1274
+ " a, agents - Open Agent List (view, select, delete)"
1275
+ .to_string(),
1276
+ );
1277
+ self.state.logs.push_back(
1278
+ " config, settings - Open Settings (mode, AI provider)"
1279
+ .to_string(),
1280
+ );
1281
+ self.state.logs.push_back(
1282
+ " runs - Open Run Manager (list, filter, sort)"
1283
+ .to_string(),
1284
+ );
1285
+ self.state.logs.push_back(
1286
+ " build - Open Agent Builder (6-step wizard)"
1287
+ .to_string(),
1288
+ );
1289
+ self.state.logs.push_back(
1290
+ "────────────────────────────────────────────────────────────────"
1291
+ .to_string(),
1292
+ );
1293
+ self.state
1294
+ .logs
1295
+ .push_back("🧭 NAVIGATION COMMANDS".to_string());
877
1296
  self.state.logs.push_back("".to_string());
878
-
1297
+
879
1298
  // Push Header LAST (will appear at TOP)
880
- self.state.logs.push_back("════════════════════════════════════════════════════════════════".to_string());
881
- self.state.logs.push_back("📖 4RUNR AI AGENT OS - COMMAND REFERENCE".to_string());
882
- self.state.logs.push_back("════════════════════════════════════════════════════════════════".to_string());
883
-
1299
+ self.state.logs.push_back(
1300
+ "════════════════════════════════════════════════════════════════"
1301
+ .to_string(),
1302
+ );
1303
+ self.state
1304
+ .logs
1305
+ .push_back("📖 4RUNR AI AGENT OS - COMMAND REFERENCE".to_string());
1306
+ self.state.logs.push_back(
1307
+ "════════════════════════════════════════════════════════════════"
1308
+ .to_string(),
1309
+ );
1310
+
884
1311
  self.request_render("help_command");
885
1312
  }
886
1313
  ":perf" => {
887
1314
  // Perf self-check command
888
1315
  let rps = if !self.state.render_durations.is_empty() {
889
- let avg_ms: f64 = self.state.render_durations.iter().sum::<u64>() as f64
1316
+ let avg_ms: f64 = self.state.render_durations.iter().sum::<u64>()
1317
+ as f64
890
1318
  / self.state.render_durations.len() as f64;
891
1319
  if avg_ms > 0.0 {
892
1320
  (1000.0 / avg_ms) as u64
@@ -896,30 +1324,31 @@ impl App {
896
1324
  } else {
897
1325
  0
898
1326
  };
899
-
1327
+
900
1328
  let mode_str = match self.run_mode() {
901
1329
  RunMode::Browser => "browser",
902
1330
  RunMode::Local => "local",
903
1331
  };
904
-
1332
+
905
1333
  let (scheduled_count, interval_ms) = self.render_scheduler_stats();
906
-
1334
+
907
1335
  self.state.logs.push_back(format!(
908
1336
  "[PERF] Mode: {} | RPS: {} | Render interval: {}ms | Scheduled: {}",
909
- mode_str,
910
- rps,
911
- interval_ms,
912
- scheduled_count
1337
+ mode_str, rps, interval_ms, scheduled_count
913
1338
  ));
914
1339
  }
915
1340
  // Navigation commands
916
1341
  "build" | "build new" | "agent new" => {
917
1342
  self.push_overlay(Screen::AgentBuilder);
918
- self.state.logs.push_back("[NAV] Opening Agent Builder...".into());
1343
+ self.state
1344
+ .logs
1345
+ .push_back("[NAV] Opening Agent Builder...".into());
919
1346
  }
920
1347
  "runs" | "run list" | "run manager" => {
921
1348
  self.push_overlay(Screen::RunManager);
922
- self.state.logs.push_back("[NAV] Opening Run Manager...".into());
1349
+ self.state
1350
+ .logs
1351
+ .push_back("[NAV] Opening Run Manager...".into());
923
1352
  if self.state.operation_mode == OperationMode::Connected {
924
1353
  if let Some(ws) = ws_client {
925
1354
  let data = serde_json::json!({ "limit": 50 });
@@ -929,16 +1358,23 @@ impl App {
929
1358
  }
930
1359
  }
931
1360
  } else {
932
- self.add_log("[RUN] Connect to Gateway (connect portal) to load live runs.".to_string());
1361
+ self.add_log(
1362
+ "[RUN] Connect to Gateway (connect portal) to load live runs."
1363
+ .to_string(),
1364
+ );
933
1365
  }
934
1366
  }
935
1367
  "config" | "settings" => {
936
1368
  self.push_overlay(Screen::Settings);
937
- self.state.logs.push_back("[NAV] Opening Settings...".into());
1369
+ self.state
1370
+ .logs
1371
+ .push_back("[NAV] Opening Settings...".into());
938
1372
  }
939
1373
  "a" | "agents" | "agent list" => {
940
1374
  self.push_overlay(Screen::AgentList);
941
- self.state.logs.push_back("[NAV] Opening Agent List...".into());
1375
+ self.state
1376
+ .logs
1377
+ .push_back("[NAV] Opening Agent List...".into());
942
1378
  // Request agent list from backend
943
1379
  if let Some(ws) = ws_client {
944
1380
  if let Ok(list_id) = ws.send_command("agent.list", None) {
@@ -951,7 +1387,12 @@ impl App {
951
1387
  self.state
952
1388
  .navigation
953
1389
  .navigate_to_base(Screen::PortalMonitoring);
954
- self.state.logs.push_back("[NAV] Opening Portal Monitoring...".into());
1390
+ self.state.advanced_monitoring =
1391
+ AdvancedMonitoringState::new(self.state.gateway_url.clone());
1392
+ self.state.portal_monitoring.scroll_offset = 0;
1393
+ self.state
1394
+ .logs
1395
+ .push_back("[NAV] Opening Portal Monitoring...".into());
955
1396
  if let Some(ws) = ws_client {
956
1397
  self.begin_portal_observability_request(ws);
957
1398
  }
@@ -969,25 +1410,31 @@ impl App {
969
1410
  self.state
970
1411
  .navigation
971
1412
  .navigate_to_base(Screen::ConnectionPortal);
972
- self.state.logs.push_back("[NAV] Opening Connection Portal...".into());
1413
+ self.state
1414
+ .logs
1415
+ .push_back("[NAV] Opening Connection Portal...".into());
973
1416
  self.request_immediate_render("open_connection_portal");
974
1417
  }
975
1418
  "setup" | "setup gateway" | "setup portal" => {
976
1419
  // Navigate to portal as base screen (standalone, not overlay)
977
1420
  self.state.navigation.navigate_to_base(Screen::SetupPortal);
978
1421
  self.state.setup_portal.soft_open();
979
-
1422
+
980
1423
  // Auto-detect Gateway options when portal opens
981
1424
  if let Some(ws) = ws_client {
982
1425
  self.state.setup_portal.detecting = true;
983
- self.state.setup_portal.detecting_since = Some(std::time::Instant::now());
1426
+ self.state.setup_portal.detecting_since =
1427
+ Some(std::time::Instant::now());
984
1428
  if let Ok(id) = ws.send_command("setup.detect", None) {
985
1429
  self.state.pending_setup_detect_id = Some(id);
986
- self.add_log("[SETUP] Detecting Gateway options...".to_string());
1430
+ self.add_log(
1431
+ "[SETUP] Detecting Gateway options...".to_string(),
1432
+ );
987
1433
  } else {
988
1434
  self.state.setup_portal.detecting = false;
989
1435
  self.state.setup_portal.detecting_since = None;
990
- self.state.setup_portal.error = Some("Failed to start detection".to_string());
1436
+ self.state.setup_portal.error =
1437
+ Some("Failed to start detection".to_string());
991
1438
  }
992
1439
  } else {
993
1440
  self.state.setup_portal.error = Some(
@@ -995,8 +1442,10 @@ impl App {
995
1442
  .into(),
996
1443
  );
997
1444
  }
998
-
999
- self.state.logs.push_back("[NAV] Opening Setup Portal...".into());
1445
+
1446
+ self.state
1447
+ .logs
1448
+ .push_back("[NAV] Opening Setup Portal...".into());
1000
1449
  self.request_immediate_render("open_setup_portal");
1001
1450
  }
1002
1451
  "disconnect" => {
@@ -1005,15 +1454,23 @@ impl App {
1005
1454
  match ws.send_command("gateway.disconnect", None) {
1006
1455
  Ok(id) => {
1007
1456
  let short_id = &id[id.len().saturating_sub(8)..];
1008
- self.state.logs.push_back(format!("[GATEWAY] Disconnecting (id: {})", short_id));
1457
+ self.state.logs.push_back(format!(
1458
+ "[GATEWAY] Disconnecting (id: {})",
1459
+ short_id
1460
+ ));
1009
1461
  // Mode will be updated when response is received
1010
1462
  }
1011
1463
  Err(e) => {
1012
- self.state.logs.push_back(format!("[ERROR] Failed to disconnect: {}", e));
1464
+ self.state.logs.push_back(format!(
1465
+ "[ERROR] Failed to disconnect: {}",
1466
+ e
1467
+ ));
1013
1468
  }
1014
1469
  }
1015
1470
  } else {
1016
- self.state.logs.push_back("[ERROR] WebSocket not connected".into());
1471
+ self.state
1472
+ .logs
1473
+ .push_back("[ERROR] WebSocket not connected".into());
1017
1474
  }
1018
1475
  }
1019
1476
  _ => {
@@ -1025,29 +1482,40 @@ impl App {
1025
1482
  match ws.send_command(&cmd, None) {
1026
1483
  Ok(id) => {
1027
1484
  let short_id = &id[id.len().saturating_sub(8)..];
1028
- self.state.logs.push_back(format!("> {} [{}]", cmd, short_id));
1485
+ self.state
1486
+ .logs
1487
+ .push_back(format!("> {} [{}]", cmd, short_id));
1029
1488
  }
1030
1489
  Err(e) => {
1031
- self.state.logs.push_back(format!("[ERROR] Failed to send '{}': {}", cmd, e));
1490
+ self.state.logs.push_back(format!(
1491
+ "[ERROR] Failed to send '{}': {}",
1492
+ cmd, e
1493
+ ));
1032
1494
  }
1033
1495
  }
1034
1496
  } else {
1035
1497
  // Unknown local command - provide helpful guidance
1036
- self.state.logs.push_back(format!("> {} (unknown command)", cmd));
1498
+ self.state
1499
+ .logs
1500
+ .push_back(format!("> {} (unknown command)", cmd));
1037
1501
  self.state.logs.push_back("[HELP] Try: help, build, runs, config, agent.list, system.status".into());
1038
1502
  }
1039
1503
  } else {
1040
1504
  // No WebSocket connection - guide to local commands
1041
- self.state.logs.push_back(format!("> {} (WebSocket not connected)", cmd));
1505
+ self.state
1506
+ .logs
1507
+ .push_back(format!("> {} (WebSocket not connected)", cmd));
1042
1508
  self.state.logs.push_back("[WARN] Backend not running. Start backend or use local commands.".into());
1043
- self.state.logs.push_back("[HELP] Local: help, build, runs, config, clear".into());
1509
+ self.state.logs.push_back(
1510
+ "[HELP] Local: help, build, runs, config, clear".into(),
1511
+ );
1044
1512
  }
1045
1513
  }
1046
1514
  }
1047
1515
  self.request_render("command_submit");
1048
1516
  }
1049
1517
  }
1050
-
1518
+
1051
1519
  // Delete character
1052
1520
  KeyCode::Backspace => {
1053
1521
  self.state.command_input.pop();
@@ -1057,7 +1525,7 @@ impl App {
1057
1525
  // OPTIMIZATION: Immediate render for instant feedback
1058
1526
  self.request_immediate_render("backspace_input");
1059
1527
  }
1060
-
1528
+
1061
1529
  // Clear input or close overlay/popup
1062
1530
  KeyCode::Esc => {
1063
1531
  // Priority: close popup > close overlay > clear input
@@ -1072,41 +1540,41 @@ impl App {
1072
1540
  self.request_render("clear_input");
1073
1541
  }
1074
1542
  }
1075
-
1543
+
1076
1544
  // === SCROLL: Arrow keys (only when not typing) ===
1077
1545
  // Calculate max valid scroll based on visible height
1078
1546
  KeyCode::Up if self.state.command_input.is_empty() => {
1079
1547
  let visible_height = 15; // Approximate visible log height (adjust based on actual panel size)
1080
1548
  let total_logs = self.state.logs.len();
1081
1549
  let max_scroll = total_logs.saturating_sub(visible_height.max(1));
1082
-
1550
+
1083
1551
  // Clamp scroll to valid range [0, max_scroll]
1084
1552
  self.state.log_scroll = (self.state.log_scroll + 1).min(max_scroll);
1085
1553
  self.request_render("scroll_up");
1086
1554
  }
1087
-
1555
+
1088
1556
  KeyCode::Down if self.state.command_input.is_empty() => {
1089
1557
  self.state.log_scroll = self.state.log_scroll.saturating_sub(1);
1090
1558
  self.request_render("scroll_down");
1091
1559
  }
1092
-
1560
+
1093
1561
  KeyCode::PageUp if self.state.command_input.is_empty() => {
1094
1562
  let visible_height = 15;
1095
1563
  let total_logs = self.state.logs.len();
1096
1564
  let max_scroll = total_logs.saturating_sub(visible_height.max(1));
1097
-
1565
+
1098
1566
  self.state.log_scroll = (self.state.log_scroll + 10).min(max_scroll);
1099
1567
  self.request_render("scroll_page_up");
1100
1568
  }
1101
-
1569
+
1102
1570
  KeyCode::PageDown if self.state.command_input.is_empty() => {
1103
1571
  self.state.log_scroll = self.state.log_scroll.saturating_sub(10);
1104
1572
  self.request_render("scroll_page_down");
1105
1573
  }
1106
-
1574
+
1107
1575
  _ => {}
1108
1576
  }
1109
-
1577
+
1110
1578
  Ok(false)
1111
1579
  }
1112
1580
 
@@ -1138,15 +1606,21 @@ impl App {
1138
1606
  pub fn render(&mut self, f: &mut Frame) {
1139
1607
  // Use new navigation system - render based on current screen
1140
1608
  let current = self.state.navigation.current_screen();
1141
-
1609
+
1142
1610
  // DEBUG: Log render decisions
1143
1611
  #[cfg(debug_assertions)]
1144
1612
  {
1145
1613
  eprintln!("[RENDER] Current screen: {:?}", current);
1146
- eprintln!("[RENDER] Base screen: {:?}", self.state.navigation.base_screen);
1147
- eprintln!("[RENDER] Overlay stack: {:?}", self.state.navigation.overlay_stack);
1614
+ eprintln!(
1615
+ "[RENDER] Base screen: {:?}",
1616
+ self.state.navigation.base_screen
1617
+ );
1618
+ eprintln!(
1619
+ "[RENDER] Overlay stack: {:?}",
1620
+ self.state.navigation.overlay_stack
1621
+ );
1148
1622
  }
1149
-
1623
+
1150
1624
  match current {
1151
1625
  Screen::Boot => {
1152
1626
  use crate::ui::boot;
@@ -1210,7 +1684,7 @@ impl App {
1210
1684
  }
1211
1685
  }
1212
1686
  }
1213
-
1687
+
1214
1688
  /// Add a log message
1215
1689
  pub fn add_log(&mut self, message: String) {
1216
1690
  self.state.logs.push_back(message);
@@ -1219,27 +1693,27 @@ impl App {
1219
1693
  }
1220
1694
  self.state.log_write_count += 1;
1221
1695
  }
1222
-
1696
+
1223
1697
  // ============================================================
1224
1698
  // NAVIGATION METHODS (NEW - Step 4.1)
1225
1699
  // ============================================================
1226
-
1700
+
1227
1701
  /// Navigate to a base screen (Boot or Main)
1228
1702
  pub fn navigate_to(&mut self, screen: Screen) {
1229
1703
  if screen.is_base() {
1230
1704
  self.state.navigation.navigate_to_base(screen.clone());
1231
-
1705
+
1232
1706
  // Update legacy mode field for backward compatibility
1233
1707
  self.state.mode = match screen {
1234
1708
  Screen::Boot => AppMode::Boot,
1235
1709
  Screen::Main => AppMode::Main,
1236
1710
  _ => self.state.mode.clone(),
1237
1711
  };
1238
-
1712
+
1239
1713
  self.request_render("navigate_to");
1240
1714
  }
1241
1715
  }
1242
-
1716
+
1243
1717
  /// Push an overlay screen (Agent Builder, Run Manager, Settings)
1244
1718
  pub fn push_overlay(&mut self, screen: Screen) {
1245
1719
  if screen.is_overlay() {
@@ -1247,7 +1721,7 @@ impl App {
1247
1721
  self.request_render("push_overlay");
1248
1722
  }
1249
1723
  }
1250
-
1724
+
1251
1725
  /// Pop the current overlay and return to previous screen
1252
1726
  pub fn pop_overlay(&mut self) -> Option<Screen> {
1253
1727
  let result = self.state.navigation.pop_overlay();
@@ -1256,7 +1730,7 @@ impl App {
1256
1730
  }
1257
1731
  result
1258
1732
  }
1259
-
1733
+
1260
1734
  /// Push a popup screen (Confirmation, Alert, Help)
1261
1735
  pub fn push_popup(&mut self, screen: Screen) {
1262
1736
  if screen.is_popup() {
@@ -1264,7 +1738,7 @@ impl App {
1264
1738
  self.request_render("push_popup");
1265
1739
  }
1266
1740
  }
1267
-
1741
+
1268
1742
  /// Pop the current popup
1269
1743
  pub fn pop_popup(&mut self) -> Option<Screen> {
1270
1744
  let result = self.state.navigation.pop_popup();
@@ -1273,34 +1747,38 @@ impl App {
1273
1747
  }
1274
1748
  result
1275
1749
  }
1276
-
1750
+
1277
1751
  /// Close all overlays and popups (return to base screen)
1278
1752
  #[allow(dead_code)]
1279
1753
  pub fn close_all_overlays(&mut self) {
1280
1754
  self.state.navigation.close_all();
1281
1755
  self.request_render("close_all");
1282
1756
  }
1283
-
1757
+
1284
1758
  /// Get the currently visible screen
1285
1759
  #[allow(dead_code)]
1286
1760
  pub fn current_screen(&self) -> &Screen {
1287
1761
  self.state.navigation.current_screen()
1288
1762
  }
1289
-
1763
+
1290
1764
  /// Check if we're on the base screen (no overlays/popups)
1291
1765
  #[allow(dead_code)]
1292
1766
  pub fn is_on_base_screen(&self) -> bool {
1293
1767
  self.state.navigation.is_on_base()
1294
1768
  }
1295
-
1769
+
1296
1770
  // ============================================================
1297
1771
  // AGENT BUILDER INPUT HANDLING (Step 4.5)
1298
1772
  // ============================================================
1299
-
1773
+
1300
1774
  /// Handle input when Agent Builder screen is active
1301
- fn handle_agent_builder_input(&mut self, key: KeyEvent, ws_client: Option<&WebSocketClient>) -> anyhow::Result<bool> {
1775
+ fn handle_agent_builder_input(
1776
+ &mut self,
1777
+ key: KeyEvent,
1778
+ ws_client: Option<&WebSocketClient>,
1779
+ ) -> anyhow::Result<bool> {
1302
1780
  use crossterm::event::KeyModifiers;
1303
-
1781
+
1304
1782
  // Ctrl+C/Q to exit
1305
1783
  if key.modifiers.contains(KeyModifiers::CONTROL) {
1306
1784
  match key.code {
@@ -1308,7 +1786,7 @@ impl App {
1308
1786
  _ => {}
1309
1787
  }
1310
1788
  }
1311
-
1789
+
1312
1790
  match key.code {
1313
1791
  // Character input - route to appropriate field
1314
1792
  KeyCode::Char(c) => {
@@ -1385,15 +1863,15 @@ impl App {
1385
1863
  }
1386
1864
  _ => {}
1387
1865
  }
1388
-
1866
+
1389
1867
  Ok(false)
1390
1868
  }
1391
-
1869
+
1392
1870
  /// Handle character input for Agent Builder fields
1393
1871
  fn handle_agent_builder_char_input(&mut self, c: char) {
1394
1872
  let step = self.state.agent_builder.current_step;
1395
1873
  let field = self.state.agent_builder.focused_field;
1396
-
1874
+
1397
1875
  match step {
1398
1876
  1 => {
1399
1877
  // Step 1: Basic Info
@@ -1451,7 +1929,10 @@ impl App {
1451
1929
  if field < tools.len() {
1452
1930
  let tool = tools[field].clone();
1453
1931
  if self.state.agent_builder.selected_tools.contains(&tool) {
1454
- self.state.agent_builder.selected_tools.retain(|t| t != &tool);
1932
+ self.state
1933
+ .agent_builder
1934
+ .selected_tools
1935
+ .retain(|t| t != &tool);
1455
1936
  } else {
1456
1937
  self.state.agent_builder.selected_tools.push(tool);
1457
1938
  }
@@ -1461,30 +1942,40 @@ impl App {
1461
1942
  _ => {}
1462
1943
  }
1463
1944
  }
1464
-
1945
+
1465
1946
  /// Handle backspace for Agent Builder fields
1466
1947
  fn handle_agent_builder_backspace(&mut self) {
1467
1948
  let step = self.state.agent_builder.current_step;
1468
1949
  let field = self.state.agent_builder.focused_field;
1469
-
1950
+
1470
1951
  match step {
1471
- 1 => {
1472
- match field {
1473
- 0 => { self.state.agent_builder.name.pop(); }
1474
- 1 => { self.state.agent_builder.description.pop(); }
1475
- _ => {}
1952
+ 1 => match field {
1953
+ 0 => {
1954
+ self.state.agent_builder.name.pop();
1476
1955
  }
1477
- }
1478
- 2 => {
1479
- match field {
1480
- 0 => { self.state.agent_builder.provider.pop(); }
1481
- 1 => { self.state.agent_builder.model.pop(); }
1482
- 2 => { self.state.agent_builder.local_provider.pop(); }
1483
- 3 => { self.state.agent_builder.local_model.pop(); }
1484
- 4 => { self.state.agent_builder.local_url.pop(); }
1485
- _ => {}
1956
+ 1 => {
1957
+ self.state.agent_builder.description.pop();
1486
1958
  }
1487
- }
1959
+ _ => {}
1960
+ },
1961
+ 2 => match field {
1962
+ 0 => {
1963
+ self.state.agent_builder.provider.pop();
1964
+ }
1965
+ 1 => {
1966
+ self.state.agent_builder.model.pop();
1967
+ }
1968
+ 2 => {
1969
+ self.state.agent_builder.local_provider.pop();
1970
+ }
1971
+ 3 => {
1972
+ self.state.agent_builder.local_model.pop();
1973
+ }
1974
+ 4 => {
1975
+ self.state.agent_builder.local_url.pop();
1976
+ }
1977
+ _ => {}
1978
+ },
1488
1979
  3 => {
1489
1980
  self.state.agent_builder.system_prompt.pop();
1490
1981
  }
@@ -1515,12 +2006,14 @@ impl App {
1515
2006
  _ => {}
1516
2007
  }
1517
2008
  }
1518
-
2009
+
1519
2010
  /// Create agent from builder state and send to backend
1520
2011
  fn create_agent_from_builder(&mut self, ws_client: Option<&WebSocketClient>) {
1521
2012
  // Validate one final time
1522
2013
  if !self.state.agent_builder.validate_step() {
1523
- self.add_log("[ERROR] Agent validation failed. Please fix errors and try again.".to_string());
2014
+ self.add_log(
2015
+ "[ERROR] Agent validation failed. Please fix errors and try again.".to_string(),
2016
+ );
1524
2017
  // Clone errors to avoid borrow checker issues
1525
2018
  let errors = self.state.agent_builder.validation_errors.clone();
1526
2019
  for error in &errors {
@@ -1528,16 +2021,16 @@ impl App {
1528
2021
  }
1529
2022
  return;
1530
2023
  }
1531
-
2024
+
1532
2025
  // Clone name before borrowing mutably
1533
2026
  let agent_name = self.state.agent_builder.name.clone();
1534
-
2027
+
1535
2028
  // Validate name is not empty
1536
2029
  if agent_name.trim().is_empty() {
1537
2030
  self.add_log("[ERROR] Agent name cannot be empty".to_string());
1538
2031
  return;
1539
2032
  }
1540
-
2033
+
1541
2034
  // Build agent data
1542
2035
  let builder = &self.state.agent_builder;
1543
2036
  let agent_data = serde_json::json!({
@@ -1572,10 +2065,10 @@ impl App {
1572
2065
  "presencePenalty": builder.presence_penalty,
1573
2066
  "tools": builder.selected_tools,
1574
2067
  });
1575
-
2068
+
1576
2069
  // Log the creation attempt
1577
2070
  self.add_log(format!("[AGENT] Creating agent '{}'...", agent_name));
1578
-
2071
+
1579
2072
  // Send agent.create command via WebSocket
1580
2073
  if let Some(ws) = ws_client {
1581
2074
  match ws.send_command("agent.create", Some(agent_data)) {
@@ -1595,21 +2088,21 @@ impl App {
1595
2088
  self.add_log("[HELP] Start the backend server and reconnect.".to_string());
1596
2089
  return;
1597
2090
  }
1598
-
2091
+
1599
2092
  // Close the builder
1600
2093
  self.pop_overlay();
1601
-
2094
+
1602
2095
  // Reset wizard state
1603
2096
  self.state.agent_builder = AgentBuilderState::default();
1604
-
2097
+
1605
2098
  // Suppress unused variable warning
1606
2099
  let _ = agent_data;
1607
2100
  }
1608
-
2101
+
1609
2102
  // ============================================================
1610
2103
  // RUN MANAGER INPUT HANDLING (Step 4.7)
1611
2104
  // ============================================================
1612
-
2105
+
1613
2106
  /// Handle input when Run Manager screen is active
1614
2107
  fn handle_run_manager_input(
1615
2108
  &mut self,
@@ -1617,7 +2110,7 @@ impl App {
1617
2110
  ws_client: Option<&WebSocketClient>,
1618
2111
  ) -> anyhow::Result<bool> {
1619
2112
  use crossterm::event::KeyModifiers;
1620
-
2113
+
1621
2114
  // Ctrl+C/Q to exit
1622
2115
  if key.modifiers.contains(KeyModifiers::CONTROL) {
1623
2116
  match key.code {
@@ -1625,7 +2118,7 @@ impl App {
1625
2118
  _ => return Ok(false),
1626
2119
  }
1627
2120
  }
1628
-
2121
+
1629
2122
  match key.code {
1630
2123
  // Navigation
1631
2124
  KeyCode::Up => {
@@ -1636,19 +2129,19 @@ impl App {
1636
2129
  self.state.run_manager.select_next();
1637
2130
  self.request_render("run_manager_down");
1638
2131
  }
1639
-
2132
+
1640
2133
  // Filter
1641
2134
  KeyCode::Char('f') | KeyCode::Char('F') => {
1642
2135
  self.state.run_manager.next_filter();
1643
2136
  self.request_render("run_manager_filter");
1644
2137
  }
1645
-
2138
+
1646
2139
  // Sort
1647
2140
  KeyCode::Char('s') | KeyCode::Char('S') => {
1648
2141
  self.state.run_manager.next_sort();
1649
2142
  self.request_render("run_manager_sort");
1650
2143
  }
1651
-
2144
+
1652
2145
  // Refresh
1653
2146
  KeyCode::Char('r') | KeyCode::Char('R') => {
1654
2147
  if self.state.operation_mode == OperationMode::Connected {
@@ -1667,7 +2160,7 @@ impl App {
1667
2160
  }
1668
2161
  self.request_render("run_manager_refresh");
1669
2162
  }
1670
-
2163
+
1671
2164
  // View details / Close detail view
1672
2165
  KeyCode::Enter => {
1673
2166
  if self.state.run_manager.is_detail_view() {
@@ -1695,11 +2188,15 @@ impl App {
1695
2188
  }
1696
2189
  self.request_render("run_manager_view");
1697
2190
  }
1698
-
2191
+
1699
2192
  // Cancel run
1700
2193
  KeyCode::Char('c') | KeyCode::Char('C') => {
1701
2194
  if let Some(run) = self.state.run_manager.selected_run() {
1702
- if matches!(run.status, crate::ui::run_manager::RunStatus::Running | crate::ui::run_manager::RunStatus::Pending) {
2195
+ if matches!(
2196
+ run.status,
2197
+ crate::ui::run_manager::RunStatus::Running
2198
+ | crate::ui::run_manager::RunStatus::Pending
2199
+ ) {
1703
2200
  if self.state.operation_mode == OperationMode::Connected {
1704
2201
  if let Some(ws) = ws_client {
1705
2202
  let data = serde_json::json!({ "runId": run.id.clone() });
@@ -1719,13 +2216,13 @@ impl App {
1719
2216
  }
1720
2217
  self.request_render("run_manager_cancel");
1721
2218
  }
1722
-
2219
+
1723
2220
  // Delete run
1724
2221
  KeyCode::Char('d') | KeyCode::Char('D') => {
1725
2222
  self.add_log("[RUN] Delete run is not supported by the Gateway API (use cancel for active runs).".to_string());
1726
2223
  self.request_render("run_manager_delete");
1727
2224
  }
1728
-
2225
+
1729
2226
  // Close detail view or Run Manager
1730
2227
  KeyCode::Esc => {
1731
2228
  if self.state.run_manager.is_detail_view() {
@@ -1738,21 +2235,21 @@ impl App {
1738
2235
  self.pop_overlay();
1739
2236
  }
1740
2237
  }
1741
-
2238
+
1742
2239
  _ => {}
1743
2240
  }
1744
-
2241
+
1745
2242
  Ok(false)
1746
2243
  }
1747
-
2244
+
1748
2245
  // ============================================================
1749
2246
  // SETTINGS INPUT HANDLING (Step 4.8)
1750
2247
  // ============================================================
1751
-
2248
+
1752
2249
  /// Handle input when Settings screen is active
1753
2250
  fn handle_settings_input(&mut self, key: KeyEvent) -> anyhow::Result<bool> {
1754
2251
  use crossterm::event::KeyModifiers;
1755
-
2252
+
1756
2253
  // Ctrl+C/Q to exit
1757
2254
  if key.modifiers.contains(KeyModifiers::CONTROL) {
1758
2255
  match key.code {
@@ -1760,7 +2257,7 @@ impl App {
1760
2257
  _ => return Ok(false),
1761
2258
  }
1762
2259
  }
1763
-
2260
+
1764
2261
  match key.code {
1765
2262
  // Navigation
1766
2263
  KeyCode::Up => {
@@ -1771,27 +2268,36 @@ impl App {
1771
2268
  self.state.settings.next_section();
1772
2269
  self.request_render("settings_down");
1773
2270
  }
1774
-
2271
+
1775
2272
  // Toggle current setting
1776
2273
  KeyCode::Char(' ') => {
1777
2274
  match self.state.settings.focused_section {
1778
2275
  crate::ui::settings::SettingsSection::Mode => {
1779
2276
  self.state.settings.toggle_mode();
1780
- self.add_log(format!("[SETTINGS] Mode: {}", self.state.settings.mode.as_str()));
2277
+ self.add_log(format!(
2278
+ "[SETTINGS] Mode: {}",
2279
+ self.state.settings.mode.as_str()
2280
+ ));
1781
2281
  }
1782
2282
  crate::ui::settings::SettingsSection::AIProvider => {
1783
2283
  self.state.settings.next_provider();
1784
- self.add_log(format!("[SETTINGS] Provider: {}", self.state.settings.ai_provider));
2284
+ self.add_log(format!(
2285
+ "[SETTINGS] Provider: {}",
2286
+ self.state.settings.ai_provider
2287
+ ));
1785
2288
  }
1786
2289
  crate::ui::settings::SettingsSection::AutoUpdate => {
1787
2290
  self.state.settings.toggle_auto_update();
1788
- self.add_log(format!("[SETTINGS] Auto-update: {}", self.state.settings.auto_update));
2291
+ self.add_log(format!(
2292
+ "[SETTINGS] Auto-update: {}",
2293
+ self.state.settings.auto_update
2294
+ ));
1789
2295
  }
1790
2296
  _ => {}
1791
2297
  }
1792
2298
  self.request_render("settings_toggle");
1793
2299
  }
1794
-
2300
+
1795
2301
  // Save settings
1796
2302
  KeyCode::Enter => {
1797
2303
  if self.state.settings.has_changes {
@@ -1801,7 +2307,7 @@ impl App {
1801
2307
  self.request_render("settings_save");
1802
2308
  }
1803
2309
  }
1804
-
2310
+
1805
2311
  // Close (with discard warning if changes)
1806
2312
  KeyCode::Esc => {
1807
2313
  if self.state.settings.has_changes {
@@ -1810,13 +2316,13 @@ impl App {
1810
2316
  }
1811
2317
  self.pop_overlay();
1812
2318
  }
1813
-
2319
+
1814
2320
  _ => {}
1815
2321
  }
1816
-
2322
+
1817
2323
  Ok(false)
1818
2324
  }
1819
-
2325
+
1820
2326
  /// Handle input when Agent List screen is active
1821
2327
  fn handle_agent_list_input(
1822
2328
  &mut self,
@@ -1824,7 +2330,7 @@ impl App {
1824
2330
  ws_client: Option<&WebSocketClient>,
1825
2331
  ) -> anyhow::Result<bool> {
1826
2332
  use crossterm::event::KeyModifiers;
1827
-
2333
+
1828
2334
  // Ctrl+C/Q to exit
1829
2335
  if key.modifiers.contains(KeyModifiers::CONTROL) {
1830
2336
  match key.code {
@@ -1832,7 +2338,7 @@ impl App {
1832
2338
  _ => return Ok(false),
1833
2339
  }
1834
2340
  }
1835
-
2341
+
1836
2342
  match key.code {
1837
2343
  // Navigation
1838
2344
  KeyCode::Up => {
@@ -1848,7 +2354,7 @@ impl App {
1848
2354
  }
1849
2355
  self.request_render("agent_list_down");
1850
2356
  }
1851
-
2357
+
1852
2358
  // View details / Close detail view
1853
2359
  KeyCode::Enter => {
1854
2360
  if self.state.agent_list.detail_view.is_some() {
@@ -1860,7 +2366,7 @@ impl App {
1860
2366
  }
1861
2367
  self.request_render("agent_list_toggle_detail");
1862
2368
  }
1863
-
2369
+
1864
2370
  // Close
1865
2371
  KeyCode::Esc => {
1866
2372
  if self.state.agent_list.detail_view.is_some() {
@@ -1873,24 +2379,30 @@ impl App {
1873
2379
  self.request_render("agent_list_close");
1874
2380
  }
1875
2381
  }
1876
-
2382
+
1877
2383
  // Delete agent (Step 5.5)
1878
2384
  KeyCode::Delete | KeyCode::Char('d') | KeyCode::Char('D') => {
1879
2385
  // Only allow deletion in list view (not in detail view)
1880
- if self.state.agent_list.detail_view.is_none() && !self.state.agent_list.agents.is_empty() {
2386
+ if self.state.agent_list.detail_view.is_none()
2387
+ && !self.state.agent_list.agents.is_empty()
2388
+ {
1881
2389
  self.state.agent_delete_requested = true;
1882
2390
  self.request_render("agent_delete_requested");
1883
2391
  }
1884
2392
  }
1885
-
2393
+
1886
2394
  // Step 6: queue a Gateway run (uses built-in `test` agent executor on the server)
1887
2395
  KeyCode::Char('x') | KeyCode::Char('X') => {
1888
2396
  if self.state.operation_mode != OperationMode::Connected {
1889
- self.add_log("[AGENT] Connect to Gateway first to run on the server (key x).".to_string());
2397
+ self.add_log(
2398
+ "[AGENT] Connect to Gateway first to run on the server (key x)."
2399
+ .to_string(),
2400
+ );
1890
2401
  } else if self.state.agent_list.agents.is_empty() {
1891
2402
  self.add_log("[AGENT] No agents in list.".to_string());
1892
- } else if let Some((agent_name, agent_model)) =
1893
- self.get_selected_agent().map(|a| (a.name.clone(), a.model.clone()))
2403
+ } else if let Some((agent_name, agent_model)) = self
2404
+ .get_selected_agent()
2405
+ .map(|a| (a.name.clone(), a.model.clone()))
1894
2406
  {
1895
2407
  if let Some(ws) = ws_client {
1896
2408
  let name = format!("TUI: {}", agent_name);
@@ -1904,7 +2416,10 @@ impl App {
1904
2416
  match ws.send_command("run.quick", Some(data)) {
1905
2417
  Ok(id) => {
1906
2418
  self.state.pending_run_quick_id = Some(id);
1907
- self.add_log(format!("[AGENT] Queued Gateway run for profile '{}' (x)", agent_name));
2419
+ self.add_log(format!(
2420
+ "[AGENT] Queued Gateway run for profile '{}' (x)",
2421
+ agent_name
2422
+ ));
1908
2423
  }
1909
2424
  Err(e) => self.add_log(format!("[ERROR] run.quick: {}", e)),
1910
2425
  }
@@ -1912,31 +2427,36 @@ impl App {
1912
2427
  }
1913
2428
  self.request_render("agent_list_quick_run");
1914
2429
  }
1915
-
2430
+
1916
2431
  _ => {}
1917
2432
  }
1918
-
2433
+
1919
2434
  Ok(false)
1920
2435
  }
1921
-
2436
+
1922
2437
  /// Delete selected agent (Step 5.5)
1923
2438
  pub fn delete_selected_agent(&mut self, ws_client: &Option<crate::websocket::WebSocketClient>) {
1924
2439
  if self.state.agent_list.agents.is_empty() {
1925
2440
  return;
1926
2441
  }
1927
-
1928
- let agent_name = self.state.agent_list.agents[self.state.agent_list.selected_index].name.clone();
1929
-
2442
+
2443
+ let agent_name = self.state.agent_list.agents[self.state.agent_list.selected_index]
2444
+ .name
2445
+ .clone();
2446
+
1930
2447
  // Send agent.delete command via WebSocket
1931
2448
  if let Some(ws) = ws_client {
1932
2449
  let delete_data = serde_json::json!({
1933
2450
  "name": agent_name
1934
2451
  });
1935
-
2452
+
1936
2453
  match ws.send_command("agent.delete", Some(delete_data)) {
1937
2454
  Ok(id) => {
1938
2455
  let short_id = &id[id.len().saturating_sub(8)..];
1939
- self.add_log(format!("[AGENT] Sent agent.delete for '{}' (id: {})", agent_name, short_id));
2456
+ self.add_log(format!(
2457
+ "[AGENT] Sent agent.delete for '{}' (id: {})",
2458
+ agent_name, short_id
2459
+ ));
1940
2460
  // Store command ID for response tracking
1941
2461
  self.state.pending_agent_delete_id = Some(id);
1942
2462
  }
@@ -1948,24 +2468,31 @@ impl App {
1948
2468
  self.add_log("[ERROR] WebSocket not connected. Cannot delete agent.".to_string());
1949
2469
  }
1950
2470
  }
1951
-
2471
+
1952
2472
  /// Get selected agent (Step 5.6)
1953
2473
  pub fn get_selected_agent(&self) -> Option<&AgentInfo> {
1954
2474
  if self.state.agent_list.agents.is_empty() {
1955
2475
  return None;
1956
2476
  }
1957
- self.state.agent_list.agents.get(self.state.agent_list.selected_index)
2477
+ self.state
2478
+ .agent_list
2479
+ .agents
2480
+ .get(self.state.agent_list.selected_index)
1958
2481
  }
1959
-
2482
+
1960
2483
  /// Get selected agent name (Step 5.6)
1961
2484
  pub fn get_selected_agent_name(&self) -> Option<String> {
1962
2485
  self.get_selected_agent().map(|agent| agent.name.clone())
1963
2486
  }
1964
-
2487
+
1965
2488
  /// Handle input when Connection Portal screen is active (Step 7)
1966
- fn handle_connection_portal_input(&mut self, key: KeyEvent, ws_client: Option<&WebSocketClient>) -> anyhow::Result<bool> {
2489
+ fn handle_connection_portal_input(
2490
+ &mut self,
2491
+ key: KeyEvent,
2492
+ ws_client: Option<&WebSocketClient>,
2493
+ ) -> anyhow::Result<bool> {
1967
2494
  use crossterm::event::KeyModifiers;
1968
-
2495
+
1969
2496
  // Ctrl+C/Q to exit
1970
2497
  if key.modifiers.contains(KeyModifiers::CONTROL) {
1971
2498
  match key.code {
@@ -1973,12 +2500,12 @@ impl App {
1973
2500
  _ => return Ok(false),
1974
2501
  }
1975
2502
  }
1976
-
2503
+
1977
2504
  // Disable input if connecting
1978
2505
  if self.state.connection_portal.connecting {
1979
2506
  return Ok(false);
1980
2507
  }
1981
-
2508
+
1982
2509
  match key.code {
1983
2510
  // ESC - Close portal and return to Main
1984
2511
  KeyCode::Esc => {
@@ -1999,13 +2526,16 @@ impl App {
1999
2526
  self.state
2000
2527
  .navigation
2001
2528
  .navigate_to_base(Screen::PortalMonitoring);
2529
+ self.state.advanced_monitoring =
2530
+ AdvancedMonitoringState::new(self.state.gateway_url.clone());
2531
+ self.state.portal_monitoring.scroll_offset = 0;
2002
2532
  self.add_log("[NAV] Opening Portal Monitoring".to_string());
2003
2533
  if let Some(ws) = ws_client {
2004
2534
  self.begin_portal_observability_request(ws);
2005
2535
  }
2006
2536
  self.request_immediate_render("open_portal_monitoring");
2007
2537
  }
2008
-
2538
+
2009
2539
  // L - Continue in local-only mode (no Gateway connection)
2010
2540
  KeyCode::Char('l') | KeyCode::Char('L') => {
2011
2541
  use crate::app::OperationMode;
@@ -2020,13 +2550,17 @@ impl App {
2020
2550
  self.state.pending_gateway_connect_id = None;
2021
2551
  self.state.pending_gateway_health_id = None;
2022
2552
  self.state.pending_gateway_observability_id = None;
2553
+ self.state.pending_monitoring_refresh_id = None;
2554
+ self.state.pending_monitoring_logs_id = None;
2555
+ self.state.pending_monitoring_drill_id = None;
2023
2556
  self.state.portal_monitoring = PortalMonitoringState::default();
2557
+ self.state.advanced_monitoring = AdvancedMonitoringState::default();
2024
2558
  self.state.portal_observability_last_poll = None;
2025
2559
  self.state.navigation.navigate_to_base(Screen::Main);
2026
2560
  self.add_log("[MODE] Continuing in LOCAL mode (no Gateway connection)".to_string());
2027
2561
  self.request_immediate_render("portal_local_mode");
2028
2562
  }
2029
-
2563
+
2030
2564
  // Tab - Switch between URL and Username fields
2031
2565
  KeyCode::Tab | KeyCode::BackTab => {
2032
2566
  if self.state.connection_portal.connection_success {
@@ -2035,7 +2569,7 @@ impl App {
2035
2569
  self.state.connection_portal.toggle_field();
2036
2570
  self.request_immediate_render("portal_field_toggle");
2037
2571
  }
2038
-
2572
+
2039
2573
  // Up/Down - Navigate between fields
2040
2574
  KeyCode::Up | KeyCode::Down => {
2041
2575
  if self.state.connection_portal.connection_success {
@@ -2044,7 +2578,7 @@ impl App {
2044
2578
  self.state.connection_portal.toggle_field();
2045
2579
  self.request_immediate_render("portal_navigate");
2046
2580
  }
2047
-
2581
+
2048
2582
  // Enter - Connect to Gateway
2049
2583
  KeyCode::Enter => {
2050
2584
  let raw = self.state.connection_portal.gateway_url.clone();
@@ -2058,49 +2592,56 @@ impl App {
2058
2592
 
2059
2593
  // Validate URL
2060
2594
  if url.is_empty() {
2061
- self.state.connection_portal.error = Some("Gateway URL is required".to_string());
2595
+ self.state.connection_portal.error =
2596
+ Some("Gateway URL is required".to_string());
2062
2597
  self.request_immediate_render("portal_error");
2063
2598
  return Ok(false);
2064
2599
  }
2065
-
2600
+
2066
2601
  if !url.starts_with("http://") && !url.starts_with("https://") {
2067
- self.state.connection_portal.error = Some("URL must start with http:// or https://".to_string());
2602
+ self.state.connection_portal.error =
2603
+ Some("URL must start with http:// or https://".to_string());
2068
2604
  self.request_immediate_render("portal_error");
2069
2605
  return Ok(false);
2070
2606
  }
2071
-
2607
+
2072
2608
  // Connect to Gateway
2073
2609
  self.state.connection_portal.error = None;
2074
2610
  self.state.connection_portal.begin_connecting();
2075
-
2611
+
2076
2612
  if let Some(ws) = ws_client {
2077
2613
  let connect_data = serde_json::json!({
2078
2614
  "url": url,
2079
2615
  "username": self.state.connection_portal.username
2080
2616
  });
2081
-
2617
+
2082
2618
  match ws.send_command("gateway.connect", Some(connect_data)) {
2083
2619
  Ok(id) => {
2084
2620
  let short_id = &id[id.len().saturating_sub(8)..];
2085
- self.add_log(format!("[GATEWAY] Connecting to {} (id: {})", url, short_id));
2621
+ self.add_log(format!(
2622
+ "[GATEWAY] Connecting to {} (id: {})",
2623
+ url, short_id
2624
+ ));
2086
2625
  self.state.pending_gateway_connect_id = Some(id);
2087
2626
  }
2088
2627
  Err(e) => {
2089
2628
  self.state.connection_portal.finish_connecting();
2090
- self.state.connection_portal.error = Some(format!("Failed to connect: {}", e));
2629
+ self.state.connection_portal.error =
2630
+ Some(format!("Failed to connect: {}", e));
2091
2631
  self.add_log(format!("[ERROR] Gateway connection failed: {}", e));
2092
2632
  }
2093
2633
  }
2094
2634
  } else {
2095
2635
  self.state.connection_portal.finish_connecting();
2096
- self.state.connection_portal.error = Some("WebSocket not connected".to_string());
2636
+ self.state.connection_portal.error =
2637
+ Some("WebSocket not connected".to_string());
2097
2638
  self.add_log("[ERROR] WebSocket not connected".to_string());
2098
2639
  }
2099
-
2640
+
2100
2641
  // CRITICAL: Use immediate render for instant status update
2101
2642
  self.request_immediate_render("portal_connect");
2102
2643
  }
2103
-
2644
+
2104
2645
  // Typing - edit focused field (immediate render for responsiveness)
2105
2646
  KeyCode::Char(c) => {
2106
2647
  if self.state.connection_portal.connection_success {
@@ -2122,7 +2663,7 @@ impl App {
2122
2663
  }
2123
2664
  }
2124
2665
  }
2125
-
2666
+
2126
2667
  // Backspace - delete character (immediate render for responsiveness)
2127
2668
  KeyCode::Backspace => {
2128
2669
  if self.state.connection_portal.connection_success {
@@ -2140,10 +2681,10 @@ impl App {
2140
2681
  // OPTIMIZATION: Immediate render for instant feedback
2141
2682
  self.request_immediate_render("portal_delete");
2142
2683
  }
2143
-
2684
+
2144
2685
  _ => {}
2145
2686
  }
2146
-
2687
+
2147
2688
  Ok(false)
2148
2689
  }
2149
2690
 
@@ -2152,6 +2693,7 @@ impl App {
2152
2693
  key: KeyEvent,
2153
2694
  ws_client: Option<&WebSocketClient>,
2154
2695
  ) -> anyhow::Result<bool> {
2696
+ use crate::ui::portal_monitoring::portal_monitoring_scroll_max;
2155
2697
  use crossterm::event::KeyModifiers;
2156
2698
  if key.modifiers.contains(KeyModifiers::CONTROL) {
2157
2699
  match key.code {
@@ -2162,19 +2704,67 @@ impl App {
2162
2704
  if key.code == KeyCode::F(10) {
2163
2705
  return Ok(true);
2164
2706
  }
2707
+ if key.kind != KeyEventKind::Press {
2708
+ return Ok(false);
2709
+ }
2710
+ if self.state.portal_monitoring.help_visible {
2711
+ if key.code == KeyCode::Esc || key.code == KeyCode::Char('?') {
2712
+ self.state.portal_monitoring.help_visible = false;
2713
+ self.request_immediate_render("portal_help_close");
2714
+ }
2715
+ return Ok(false);
2716
+ }
2165
2717
  if key.code == KeyCode::Esc {
2166
2718
  self.state.pending_gateway_observability_id = None;
2719
+ self.state.pending_monitoring_refresh_id = None;
2720
+ self.state.pending_monitoring_logs_id = None;
2721
+ self.state.pending_monitoring_drill_id = None;
2722
+ self.state.portal_monitoring.section_refresh_loading = None;
2723
+ self.state.portal_monitoring.logs_fetch_loading = false;
2724
+ self.state.portal_monitoring.metrics_drill = None;
2725
+ self.state.portal_monitoring.metrics_drill_lines.clear();
2726
+ self.state.portal_monitoring.metrics_drill_loading = false;
2167
2727
  self.state.navigation.navigate_to_base(Screen::Main);
2168
2728
  self.add_log("[NAV] Portal Monitoring closed".to_string());
2169
2729
  self.request_immediate_render("portal_monitoring_close");
2170
2730
  return Ok(false);
2171
2731
  }
2732
+ if key.code == KeyCode::Char('?') {
2733
+ self.state.portal_monitoring.help_visible = true;
2734
+ self.request_immediate_render("portal_help_open");
2735
+ return Ok(false);
2736
+ }
2737
+ // R is context-sensitive: Overview → full `gateway.observability` snapshot; any other
2738
+ // section → `monitoring.refresh` for that section only (cheaper; matches expanded detail).
2172
2739
  if key.code == KeyCode::Char('r') || key.code == KeyCode::Char('R') {
2173
2740
  if let Some(ws) = ws_client {
2741
+ let sections = MonitoringSection::all();
2742
+ let idx = self
2743
+ .state
2744
+ .advanced_monitoring
2745
+ .monitoring_state
2746
+ .selected_index;
2747
+ let current = sections.get(idx).copied();
2174
2748
  self.state.portal_observability_last_poll = None;
2175
2749
  self.state.pending_gateway_observability_id = None;
2750
+ self.state.pending_monitoring_refresh_id = None;
2751
+ self.state.pending_monitoring_logs_id = None;
2752
+ self.state.pending_monitoring_drill_id = None;
2753
+ self.state.portal_monitoring.loading = false;
2754
+ self.state.portal_monitoring.section_refresh_loading = None;
2755
+ self.state.portal_monitoring.logs_fetch_loading = false;
2756
+ self.state.portal_monitoring.metrics_drill = None;
2757
+ self.state.portal_monitoring.metrics_drill_lines.clear();
2758
+ self.state.portal_monitoring.metrics_drill_loading = false;
2176
2759
  self.state.portal_monitoring.last_refresh = Some(std::time::Instant::now());
2177
- self.begin_portal_observability_request(ws);
2760
+ match current {
2761
+ Some(MonitoringSection::Overview) | None => {
2762
+ self.begin_portal_observability_request(ws);
2763
+ }
2764
+ Some(sec) => {
2765
+ self.begin_monitoring_refresh_request(ws, sec);
2766
+ }
2767
+ }
2178
2768
  self.request_immediate_render("portal_obs_refresh");
2179
2769
  } else {
2180
2770
  self.state.portal_monitoring.error =
@@ -2183,10 +2773,165 @@ impl App {
2183
2773
  }
2184
2774
  return Ok(false);
2185
2775
  }
2776
+ if key.code == KeyCode::Char('l') || key.code == KeyCode::Char('L') {
2777
+ if let Some(ws) = ws_client {
2778
+ self.begin_monitoring_logs_request(ws);
2779
+ self.state
2780
+ .advanced_monitoring
2781
+ .monitoring_state
2782
+ .expand_section(MonitoringSection::Logs);
2783
+ self.request_immediate_render("portal_monitoring_logs");
2784
+ } else {
2785
+ self.state.portal_monitoring.error =
2786
+ Some("WebSocket to CLI is not connected.".to_string());
2787
+ self.request_immediate_render("portal_obs_no_ws");
2788
+ }
2789
+ return Ok(false);
2790
+ }
2791
+
2792
+ if key.code == KeyCode::Char('h') || key.code == KeyCode::Char('H') {
2793
+ self.state.portal_monitoring.trend_view = !self.state.portal_monitoring.trend_view;
2794
+ let status = if self.state.portal_monitoring.trend_view {
2795
+ "enabled"
2796
+ } else {
2797
+ "disabled"
2798
+ };
2799
+ self.add_log(format!("[PORTAL] Trend/history view {}", status));
2800
+ self.request_immediate_render("portal_trend_toggle");
2801
+ return Ok(false);
2802
+ }
2803
+
2804
+ if key.code == KeyCode::Char('e') || key.code == KeyCode::Char('E') {
2805
+ match self.export_portal_monitoring_snapshot() {
2806
+ Ok(path) => {
2807
+ self.add_log(format!("[PORTAL] Exported monitoring snapshot: {}", path));
2808
+ self.state.portal_monitoring.error = None;
2809
+ }
2810
+ Err(e) => {
2811
+ self.state.portal_monitoring.error = Some(format!("Export failed: {}", e));
2812
+ }
2813
+ }
2814
+ self.request_immediate_render("portal_export");
2815
+ return Ok(false);
2816
+ }
2817
+
2818
+ // Phase 3: extended dependency view (GET /api/monitoring/dependencies/detail)
2819
+ if key.code == KeyCode::Char('t') || key.code == KeyCode::Char('T') {
2820
+ let sections = MonitoringSection::all();
2821
+ let idx = self
2822
+ .state
2823
+ .advanced_monitoring
2824
+ .monitoring_state
2825
+ .selected_index;
2826
+ let current = sections.get(idx).copied();
2827
+ let expanded = current
2828
+ .and_then(|sec| {
2829
+ self.state
2830
+ .advanced_monitoring
2831
+ .monitoring_state
2832
+ .sections
2833
+ .get(&sec)
2834
+ .map(|s| s.expanded)
2835
+ })
2836
+ .unwrap_or(false);
2837
+ if current == Some(MonitoringSection::Dependencies) && expanded {
2838
+ if let Some(ws) = ws_client {
2839
+ self.begin_dependencies_detail_drill(ws);
2840
+ self.request_immediate_render("portal_deps_drill");
2841
+ } else {
2842
+ self.state.portal_monitoring.error =
2843
+ Some("WebSocket to CLI is not connected.".to_string());
2844
+ self.request_immediate_render("portal_obs_no_ws");
2845
+ }
2846
+ return Ok(false);
2847
+ }
2848
+ }
2849
+
2850
+ // Phase 4: diagnostics for the local CLI/TUI bridge + Gateway reachability.
2851
+ if key.code == KeyCode::Char('s') || key.code == KeyCode::Char('S') {
2852
+ let sections = MonitoringSection::all();
2853
+ let idx = self
2854
+ .state
2855
+ .advanced_monitoring
2856
+ .monitoring_state
2857
+ .selected_index;
2858
+ let current = sections.get(idx).copied();
2859
+ let expanded = current
2860
+ .and_then(|sec| {
2861
+ self.state
2862
+ .advanced_monitoring
2863
+ .monitoring_state
2864
+ .sections
2865
+ .get(&sec)
2866
+ .map(|s| s.expanded)
2867
+ })
2868
+ .unwrap_or(false);
2869
+ if current == Some(MonitoringSection::System) && expanded {
2870
+ if let Some(ws) = ws_client {
2871
+ self.begin_system_diagnostics_request(ws);
2872
+ self.request_immediate_render("portal_system_diagnostics");
2873
+ } else {
2874
+ self.state.portal_monitoring.error =
2875
+ Some("WebSocket to CLI is not connected.".to_string());
2876
+ self.request_immediate_render("portal_obs_no_ws");
2877
+ }
2878
+ return Ok(false);
2879
+ }
2880
+ }
2881
+
2882
+ if key.code == KeyCode::Right {
2883
+ let sections = MonitoringSection::all();
2884
+ let idx = self
2885
+ .state
2886
+ .advanced_monitoring
2887
+ .monitoring_state
2888
+ .selected_index;
2889
+ let current = sections.get(idx).copied();
2890
+ let expanded = current
2891
+ .and_then(|sec| {
2892
+ self.state
2893
+ .advanced_monitoring
2894
+ .monitoring_state
2895
+ .sections
2896
+ .get(&sec)
2897
+ .map(|s| s.expanded)
2898
+ })
2899
+ .unwrap_or(false);
2900
+ if current == Some(MonitoringSection::Metrics) && expanded {
2901
+ if let Some(ws) = ws_client {
2902
+ let panel = self
2903
+ .state
2904
+ .portal_monitoring
2905
+ .metrics_drill
2906
+ .map(|p| p.next())
2907
+ .unwrap_or(MetricsDrillPanel::Http);
2908
+ self.begin_monitoring_drill_request(ws, panel);
2909
+ self.request_immediate_render("portal_metrics_drill");
2910
+ } else {
2911
+ self.state.portal_monitoring.error =
2912
+ Some("WebSocket to CLI is not connected.".to_string());
2913
+ self.request_immediate_render("portal_obs_no_ws");
2914
+ }
2915
+ return Ok(false);
2916
+ }
2917
+ }
2918
+
2919
+ if key.code == KeyCode::Left {
2920
+ if self.state.portal_monitoring.metrics_drill.is_some() {
2921
+ self.state.portal_monitoring.metrics_drill = None;
2922
+ self.state.portal_monitoring.metrics_drill_lines.clear();
2923
+ self.state.portal_monitoring.metrics_drill_loading = false;
2924
+ self.request_immediate_render("portal_metrics_drill_exit");
2925
+ return Ok(false);
2926
+ }
2927
+ }
2928
+
2186
2929
  // Toggle auto-refresh with 'A' key
2187
2930
  if key.code == KeyCode::Char('a') || key.code == KeyCode::Char('A') {
2188
- self.state.portal_monitoring.auto_refresh_enabled =
2931
+ self.state.portal_monitoring.auto_refresh_enabled =
2189
2932
  !self.state.portal_monitoring.auto_refresh_enabled;
2933
+ self.state.advanced_monitoring.monitoring_state.live_mode =
2934
+ self.state.portal_monitoring.auto_refresh_enabled;
2190
2935
  let status = if self.state.portal_monitoring.auto_refresh_enabled {
2191
2936
  "enabled"
2192
2937
  } else {
@@ -2196,20 +2941,54 @@ impl App {
2196
2941
  self.request_immediate_render("portal_obs_auto_toggle");
2197
2942
  return Ok(false);
2198
2943
  }
2944
+
2945
+ // Phase 1: Enter / Space — expand or collapse selected section
2946
+ if key.code == KeyCode::Enter || key.code == KeyCode::Char(' ') {
2947
+ let idx = self
2948
+ .state
2949
+ .advanced_monitoring
2950
+ .monitoring_state
2951
+ .selected_index;
2952
+ self.state
2953
+ .advanced_monitoring
2954
+ .monitoring_state
2955
+ .toggle_section_at_index(idx);
2956
+ self.request_immediate_render("portal_monitoring_toggle_section");
2957
+ return Ok(false);
2958
+ }
2959
+
2199
2960
  let inner_h = self.state.portal_monitoring.viewport_lines.max(5);
2200
- let total = self.state.portal_monitoring.content_lines.len();
2201
- let max_scroll = total.saturating_sub(inner_h.min(total.max(1)));
2961
+ let sw = self.state.portal_monitoring.summary_clip_width.max(24);
2962
+ let max_scroll = portal_monitoring_scroll_max(&self.state, sw, inner_h);
2963
+
2202
2964
  match key.code {
2203
- KeyCode::Up | KeyCode::PageUp => {
2965
+ KeyCode::Up => {
2966
+ self.state
2967
+ .advanced_monitoring
2968
+ .monitoring_state
2969
+ .select_previous_wrapped();
2970
+ self.state.portal_monitoring.scroll_offset = 0;
2971
+ self.request_immediate_render("portal_monitoring_select");
2972
+ }
2973
+ KeyCode::Down => {
2974
+ self.state
2975
+ .advanced_monitoring
2976
+ .monitoring_state
2977
+ .select_next_wrapped();
2978
+ self.state.portal_monitoring.scroll_offset = 0;
2979
+ self.request_immediate_render("portal_monitoring_select");
2980
+ }
2981
+ KeyCode::PageUp => {
2982
+ let step = inner_h.saturating_sub(3).max(1);
2204
2983
  self.state.portal_monitoring.scroll_offset = self
2205
2984
  .state
2206
2985
  .portal_monitoring
2207
2986
  .scroll_offset
2208
- .saturating_sub(if key.code == KeyCode::PageUp { inner_h } else { 1 });
2987
+ .saturating_sub(step);
2209
2988
  self.request_immediate_render("portal_obs_scroll");
2210
2989
  }
2211
- KeyCode::Down | KeyCode::PageDown => {
2212
- let step = if key.code == KeyCode::PageDown { inner_h } else { 1 };
2990
+ KeyCode::PageDown => {
2991
+ let step = inner_h.saturating_sub(3).max(1);
2213
2992
  self.state.portal_monitoring.scroll_offset =
2214
2993
  (self.state.portal_monitoring.scroll_offset + step).min(max_scroll);
2215
2994
  self.request_immediate_render("portal_obs_scroll");
@@ -2259,7 +3038,8 @@ impl App {
2259
3038
  self.state.connection_portal.add_log_entry(
2260
3039
  now,
2261
3040
  LogLevel::Info,
2262
- "Type your Gateway URL (https://…), then press Enter to connect.".to_string()
3041
+ "Type your Gateway URL (https://…), then press Enter to connect."
3042
+ .to_string(),
2263
3043
  );
2264
3044
  }
2265
3045
  _ => {}
@@ -2269,7 +3049,7 @@ impl App {
2269
3049
  self.state.connection_portal.add_log_entry(
2270
3050
  now,
2271
3051
  LogLevel::Info,
2272
- "Type your Gateway URL, then press Enter to connect.".to_string()
3052
+ "Type your Gateway URL, then press Enter to connect.".to_string(),
2273
3053
  );
2274
3054
  }
2275
3055
 
@@ -2277,12 +3057,16 @@ impl App {
2277
3057
  .navigation
2278
3058
  .navigate_to_base(Screen::ConnectionPortal);
2279
3059
  }
2280
-
3060
+
2281
3061
  // ============================================================
2282
3062
  // SETUP PORTAL INPUT HANDLING
2283
3063
  // ============================================================
2284
-
2285
- fn handle_setup_portal_input(&mut self, key: KeyEvent, _ws_client: Option<&WebSocketClient>) -> anyhow::Result<bool> {
3064
+
3065
+ fn handle_setup_portal_input(
3066
+ &mut self,
3067
+ key: KeyEvent,
3068
+ _ws_client: Option<&WebSocketClient>,
3069
+ ) -> anyhow::Result<bool> {
2286
3070
  // ESC always closes the portal (even when detecting), so user can always get out
2287
3071
  if key.code == KeyCode::Esc {
2288
3072
  self.state.setup_portal.detecting = false;
@@ -2312,18 +3096,18 @@ impl App {
2312
3096
  self.add_log("[NAV] Opening Connection Portal (F2)...".to_string());
2313
3097
  self.request_immediate_render("open_connection_portal");
2314
3098
  }
2315
-
3099
+
2316
3100
  // Up/Down - Navigate options (immediate render so windowed terminals always redraw)
2317
3101
  KeyCode::Up => {
2318
3102
  self.state.setup_portal.cycle_option(-1);
2319
3103
  self.request_immediate_render("setup_nav_up");
2320
3104
  }
2321
-
3105
+
2322
3106
  KeyCode::Down => {
2323
3107
  self.state.setup_portal.cycle_option(1);
2324
3108
  self.request_immediate_render("setup_nav_down");
2325
3109
  }
2326
-
3110
+
2327
3111
  // Enter - Connection Portal with pre-filled URL and fresh portal state
2328
3112
  KeyCode::Enter => {
2329
3113
  self.state.setup_portal.error = None;
@@ -2335,11 +3119,10 @@ impl App {
2335
3119
  ));
2336
3120
  self.request_immediate_render("setup_enter");
2337
3121
  }
2338
-
3122
+
2339
3123
  _ => {}
2340
3124
  }
2341
-
3125
+
2342
3126
  Ok(false)
2343
3127
  }
2344
3128
  }
2345
-