4runr-os 2.10.39 → 2.10.40

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 (30) 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.rs +569 -8
  27. package/mk3-tui/src/main.rs +248 -0
  28. package/mk3-tui/src/monitoring/mod.rs +428 -0
  29. package/mk3-tui/src/ui/portal_monitoring.rs +1018 -146
  30. 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`).
@@ -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,
@@ -183,6 +214,50 @@ impl ConnectionPortalState {
183
214
  }
184
215
 
185
216
  /// Gateway traffic snapshot (Prometheus `/metrics` via CLI WebSocket handler).
217
+ #[derive(Debug, Clone, Serialize)]
218
+ pub struct MetricHistoryEntry {
219
+ pub timestamp: String,
220
+ pub http_requests: u64,
221
+ pub http_errors: u64,
222
+ pub queue_waiting: u64,
223
+ pub queue_active: u64,
224
+ pub queue_failed: u64,
225
+ pub runs_active: u64,
226
+ pub sse_active: u64,
227
+ }
228
+
229
+ /// Phase 5 contract with `packages/os-cli/src/gateway-observability.ts` `totals`.
230
+ /// If CLI keys change, update this mapping and `metric_history_totals_contract_keys`.
231
+ pub fn metric_history_entry_from_totals(
232
+ timestamp: String,
233
+ totals: &serde_json::Map<String, serde_json::Value>,
234
+ ) -> MetricHistoryEntry {
235
+ fn read_u64(totals: &serde_json::Map<String, serde_json::Value>, key: &str) -> u64 {
236
+ totals
237
+ .get(key)
238
+ .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f.max(0.0) as u64)))
239
+ .unwrap_or(0)
240
+ }
241
+
242
+ MetricHistoryEntry {
243
+ timestamp,
244
+ http_requests: read_u64(totals, "httpRequests"),
245
+ http_errors: read_u64(totals, "httpRequestErrors"),
246
+ queue_waiting: read_u64(totals, "queueJobsWaiting"),
247
+ queue_active: read_u64(totals, "queueJobsActive"),
248
+ queue_failed: read_u64(totals, "queueJobsFailed"),
249
+ runs_active: read_u64(totals, "runsActive"),
250
+ sse_active: read_u64(totals, "sseActive"),
251
+ }
252
+ }
253
+
254
+ #[derive(Debug, Clone, Serialize)]
255
+ pub struct DependencyAlertEntry {
256
+ pub timestamp: String,
257
+ pub previous: String,
258
+ pub current: String,
259
+ }
260
+
186
261
  #[derive(Debug, Clone)]
187
262
  pub struct PortalMonitoringState {
188
263
  pub loading: bool,
@@ -190,6 +265,8 @@ pub struct PortalMonitoringState {
190
265
  pub last_updated: Option<String>,
191
266
  pub content_lines: Vec<String>,
192
267
  pub scroll_offset: usize,
268
+ /// Width-based clip for section summaries; updated each render (Phase 1).
269
+ pub summary_clip_width: usize,
193
270
  /// Body inner height from last render (for scroll bounds).
194
271
  pub viewport_lines: usize,
195
272
  /// Auto-refresh timer
@@ -200,6 +277,24 @@ pub struct PortalMonitoringState {
200
277
  pub last_http_for_delta: Option<u64>,
201
278
  /// Wall clock at last successful observability poll (for rate estimate).
202
279
  pub last_instant_for_delta: Option<Instant>,
280
+ /// Phase 2: per-section lines from `monitoring.refresh` / `monitoring.logs` (merged over snapshot).
281
+ pub section_overrides: HashMap<MonitoringSection, Vec<String>>,
282
+ /// Which section is waiting on `monitoring.refresh`.
283
+ pub section_refresh_loading: Option<MonitoringSection>,
284
+ pub logs_fetch_loading: bool,
285
+ /// Phase 3: drill-down inside Metrics (`→` / `←`); which prom-client slice is shown.
286
+ pub metrics_drill: Option<MetricsDrillPanel>,
287
+ pub metrics_drill_lines: Vec<String>,
288
+ pub metrics_drill_loading: bool,
289
+ /// Last N HTTP request totals from full snapshots (for ASCII sparkline).
290
+ pub http_total_sparkline: VecDeque<u64>,
291
+ /// Phase 5: bounded sample history (720 samples; wall-clock span depends on refresh cadence).
292
+ pub metric_history: VecDeque<MetricHistoryEntry>,
293
+ pub dependency_alerts: VecDeque<DependencyAlertEntry>,
294
+ pub last_dependency_status: Option<String>,
295
+ pub trend_view: bool,
296
+ pub last_export_path: Option<String>,
297
+ pub help_visible: bool,
203
298
  }
204
299
 
205
300
  impl Default for PortalMonitoringState {
@@ -210,12 +305,54 @@ impl Default for PortalMonitoringState {
210
305
  last_updated: None,
211
306
  content_lines: Vec::new(),
212
307
  scroll_offset: 0,
308
+ summary_clip_width: 80,
213
309
  viewport_lines: 18,
214
310
  last_refresh: None,
215
311
  auto_refresh_interval: Duration::from_secs(5),
216
312
  auto_refresh_enabled: true,
217
313
  last_http_for_delta: None,
218
314
  last_instant_for_delta: None,
315
+ section_overrides: HashMap::new(),
316
+ section_refresh_loading: None,
317
+ logs_fetch_loading: false,
318
+ metrics_drill: None,
319
+ metrics_drill_lines: Vec::new(),
320
+ metrics_drill_loading: false,
321
+ http_total_sparkline: VecDeque::new(),
322
+ metric_history: VecDeque::new(),
323
+ dependency_alerts: VecDeque::new(),
324
+ last_dependency_status: None,
325
+ trend_view: false,
326
+ last_export_path: None,
327
+ help_visible: false,
328
+ }
329
+ }
330
+ }
331
+
332
+ /// Section-based monitoring UI state (Phase 1).
333
+ #[derive(Debug, Clone)]
334
+ pub struct AdvancedMonitoringState {
335
+ pub monitoring_state: MonitoringState,
336
+ /// Optional mirror of the connected Gateway URL at construction time. **Live URL for UI is always
337
+ /// `AppState.gateway_url`** (and `MonitoringState::gateway_url` is synced from there in Portal Monitoring render).
338
+ #[allow(dead_code)]
339
+ pub gateway_url: Option<String>,
340
+ }
341
+
342
+ impl Default for AdvancedMonitoringState {
343
+ fn default() -> Self {
344
+ Self {
345
+ monitoring_state: MonitoringState::default(),
346
+ gateway_url: None,
347
+ }
348
+ }
349
+ }
350
+
351
+ impl AdvancedMonitoringState {
352
+ pub fn new(gateway_url: Option<String>) -> Self {
353
+ Self {
354
+ monitoring_state: MonitoringState::new(gateway_url.clone()),
355
+ gateway_url,
219
356
  }
220
357
  }
221
358
  }
@@ -296,6 +433,9 @@ pub struct AppState {
296
433
  /// Last successful observability pull (for auto-refresh interval).
297
434
  pub portal_observability_last_poll: Option<Instant>,
298
435
 
436
+ /// Section selection / expand state for Portal Monitoring (Phase 1).
437
+ pub advanced_monitoring: AdvancedMonitoringState,
438
+
299
439
  // Command tracking for response handling (Step 5.1)
300
440
  pub pending_agent_create_id: Option<String>,
301
441
  pub pending_agent_list_id: Option<String>,
@@ -303,6 +443,9 @@ pub struct AppState {
303
443
  pub pending_gateway_connect_id: Option<String>,
304
444
  pub pending_gateway_health_id: Option<String>, // Phase 2.1: Track health checks
305
445
  pub pending_gateway_observability_id: Option<String>,
446
+ pub pending_monitoring_refresh_id: Option<String>,
447
+ pub pending_monitoring_logs_id: Option<String>,
448
+ pub pending_monitoring_drill_id: Option<String>,
306
449
  pub pending_setup_detect_id: Option<String>, // Track setup.detect command
307
450
 
308
451
  // Step 6: Gateway runs
@@ -380,12 +523,16 @@ impl Default for AppState {
380
523
  setup_portal: SetupPortalState::default(),
381
524
  portal_monitoring: PortalMonitoringState::default(),
382
525
  portal_observability_last_poll: None,
526
+ advanced_monitoring: AdvancedMonitoringState::default(),
383
527
  pending_agent_create_id: None,
384
528
  pending_agent_list_id: None,
385
529
  pending_agent_delete_id: None,
386
530
  pending_gateway_connect_id: None,
387
531
  pending_gateway_health_id: None, // Phase 2.1: Initialize health check tracking
388
532
  pending_gateway_observability_id: None,
533
+ pending_monitoring_refresh_id: None,
534
+ pending_monitoring_logs_id: None,
535
+ pending_monitoring_drill_id: None,
389
536
  pending_setup_detect_id: None,
390
537
  pending_run_list_id: None,
391
538
  pending_run_get_id: None,
@@ -557,10 +704,21 @@ impl App {
557
704
  }
558
705
 
559
706
  /// Ask CLI to pull Gateway `GET /metrics` and return a condensed snapshot for Portal Monitoring.
707
+ ///
708
+ /// **Intentional command split:** the TUI uses this `gateway.observability` path for the
709
+ /// **Overview** row (full health + Prometheus snapshot in one response). Per-section refresh
710
+ /// uses `begin_monitoring_refresh_request` → `monitoring.refresh` instead. The CLI also maps
711
+ /// `monitoring.refresh` with `section: "overview"` to the same handler for non-TUI callers.
560
712
  pub fn begin_portal_observability_request(&mut self, ws: &WebSocketClient) {
561
713
  if self.state.pending_gateway_observability_id.is_some() {
562
714
  return;
563
715
  }
716
+ if self.state.pending_monitoring_refresh_id.is_some()
717
+ || self.state.pending_monitoring_logs_id.is_some()
718
+ || self.state.pending_monitoring_drill_id.is_some()
719
+ {
720
+ return;
721
+ }
564
722
  self.state.portal_monitoring.loading = true;
565
723
  self.state.portal_monitoring.error = None;
566
724
  let nonce = std::time::SystemTime::now()
@@ -580,6 +738,179 @@ impl App {
580
738
  }
581
739
  }
582
740
 
741
+ /// Phase 2: refresh one Portal Monitoring section via CLI (`monitoring.refresh` → Gateway APIs).
742
+ pub fn begin_monitoring_refresh_request(&mut self, ws: &WebSocketClient, section: MonitoringSection) {
743
+ if self.state.pending_gateway_observability_id.is_some()
744
+ || self.state.pending_monitoring_refresh_id.is_some()
745
+ || self.state.pending_monitoring_logs_id.is_some()
746
+ || self.state.pending_monitoring_drill_id.is_some()
747
+ {
748
+ return;
749
+ }
750
+ let nonce = std::time::SystemTime::now()
751
+ .duration_since(std::time::UNIX_EPOCH)
752
+ .map(|d| d.as_millis())
753
+ .unwrap_or(0);
754
+ let slug = section.cli_slug();
755
+ let data = serde_json::json!({ "section": slug, "nonce": nonce });
756
+ match ws.send_command("monitoring.refresh", Some(data)) {
757
+ Ok(id) => {
758
+ self.state.pending_monitoring_refresh_id = Some(id);
759
+ self.state.portal_monitoring.section_refresh_loading = Some(section);
760
+ self.state.portal_monitoring.error = None;
761
+ }
762
+ Err(e) => {
763
+ self.state.portal_monitoring.section_refresh_loading = None;
764
+ self.state.portal_monitoring.error = Some(format!("{}", e));
765
+ self.request_immediate_render("monitoring_refresh_send_err");
766
+ }
767
+ }
768
+ }
769
+
770
+ /// Phase 2: fetch Gateway log buffer (`monitoring.logs`).
771
+ pub fn begin_monitoring_logs_request(&mut self, ws: &WebSocketClient) {
772
+ if self.state.pending_gateway_observability_id.is_some()
773
+ || self.state.pending_monitoring_refresh_id.is_some()
774
+ || self.state.pending_monitoring_logs_id.is_some()
775
+ || self.state.pending_monitoring_drill_id.is_some()
776
+ {
777
+ return;
778
+ }
779
+ let nonce = std::time::SystemTime::now()
780
+ .duration_since(std::time::UNIX_EPOCH)
781
+ .map(|d| d.as_millis())
782
+ .unwrap_or(0);
783
+ let data = serde_json::json!({ "limit": 100u32, "nonce": nonce });
784
+ match ws.send_command("monitoring.logs", Some(data)) {
785
+ Ok(id) => {
786
+ self.state.pending_monitoring_logs_id = Some(id);
787
+ self.state.portal_monitoring.logs_fetch_loading = true;
788
+ self.state.portal_monitoring.error = None;
789
+ }
790
+ Err(e) => {
791
+ self.state.portal_monitoring.logs_fetch_loading = false;
792
+ self.state.portal_monitoring.error = Some(format!("{}", e));
793
+ self.request_immediate_render("monitoring_logs_send_err");
794
+ }
795
+ }
796
+ }
797
+
798
+ /// Phase 3: Gateway drill-down (`monitoring.drill`) for Metrics sub-panels (HTTP / Runs / Queue / SSE).
799
+ pub fn begin_monitoring_drill_request(&mut self, ws: &WebSocketClient, panel: MetricsDrillPanel) {
800
+ if self.state.pending_gateway_observability_id.is_some()
801
+ || self.state.pending_monitoring_refresh_id.is_some()
802
+ || self.state.pending_monitoring_logs_id.is_some()
803
+ || self.state.pending_monitoring_drill_id.is_some()
804
+ {
805
+ return;
806
+ }
807
+ let nonce = std::time::SystemTime::now()
808
+ .duration_since(std::time::UNIX_EPOCH)
809
+ .map(|d| d.as_millis())
810
+ .unwrap_or(0);
811
+ let target = panel.cli_target();
812
+ let data = serde_json::json!({ "target": target, "nonce": nonce });
813
+ match ws.send_command("monitoring.drill", Some(data)) {
814
+ Ok(id) => {
815
+ self.state.pending_monitoring_drill_id = Some(id);
816
+ self.state.portal_monitoring.metrics_drill = Some(panel);
817
+ self.state.portal_monitoring.metrics_drill_loading = true;
818
+ self.state.portal_monitoring.metrics_drill_lines.clear();
819
+ self.state.portal_monitoring.error = None;
820
+ }
821
+ Err(e) => {
822
+ self.state.portal_monitoring.metrics_drill_loading = false;
823
+ self.state.portal_monitoring.error = Some(format!("{}", e));
824
+ self.request_immediate_render("monitoring_drill_send_err");
825
+ }
826
+ }
827
+ }
828
+
829
+ /// Phase 3: dependency pools + queue counts (`dependencies/detail`).
830
+ pub fn begin_dependencies_detail_drill(&mut self, ws: &WebSocketClient) {
831
+ if self.state.pending_gateway_observability_id.is_some()
832
+ || self.state.pending_monitoring_refresh_id.is_some()
833
+ || self.state.pending_monitoring_logs_id.is_some()
834
+ || self.state.pending_monitoring_drill_id.is_some()
835
+ {
836
+ return;
837
+ }
838
+ let nonce = std::time::SystemTime::now()
839
+ .duration_since(std::time::UNIX_EPOCH)
840
+ .map(|d| d.as_millis())
841
+ .unwrap_or(0);
842
+ let data = serde_json::json!({ "target": "dependencies_detail", "nonce": nonce });
843
+ match ws.send_command("monitoring.drill", Some(data)) {
844
+ Ok(id) => {
845
+ self.state.pending_monitoring_drill_id = Some(id);
846
+ self.state.portal_monitoring.section_refresh_loading = Some(MonitoringSection::Dependencies);
847
+ self.state.portal_monitoring.error = None;
848
+ }
849
+ Err(e) => {
850
+ self.state.portal_monitoring.section_refresh_loading = None;
851
+ self.state.portal_monitoring.error = Some(format!("{}", e));
852
+ self.request_immediate_render("deps_drill_send_err");
853
+ }
854
+ }
855
+ }
856
+
857
+ /// Phase 4: run local CLI/host diagnostics for the System section.
858
+ pub fn begin_system_diagnostics_request(&mut self, ws: &WebSocketClient) {
859
+ if self.state.pending_gateway_observability_id.is_some()
860
+ || self.state.pending_monitoring_refresh_id.is_some()
861
+ || self.state.pending_monitoring_logs_id.is_some()
862
+ || self.state.pending_monitoring_drill_id.is_some()
863
+ {
864
+ return;
865
+ }
866
+ let nonce = std::time::SystemTime::now()
867
+ .duration_since(std::time::UNIX_EPOCH)
868
+ .map(|d| d.as_millis())
869
+ .unwrap_or(0);
870
+ let data = serde_json::json!({ "target": "system_diagnostics", "nonce": nonce });
871
+ match ws.send_command("monitoring.drill", Some(data)) {
872
+ Ok(id) => {
873
+ self.state.pending_monitoring_drill_id = Some(id);
874
+ self.state.portal_monitoring.section_refresh_loading = Some(MonitoringSection::System);
875
+ self.state.portal_monitoring.error = None;
876
+ }
877
+ Err(e) => {
878
+ self.state.portal_monitoring.section_refresh_loading = None;
879
+ self.state.portal_monitoring.error = Some(format!("{}", e));
880
+ self.request_immediate_render("system_diagnostics_send_err");
881
+ }
882
+ }
883
+ }
884
+
885
+ pub fn export_portal_monitoring_snapshot(&mut self) -> anyhow::Result<String> {
886
+ let mut overrides = serde_json::Map::new();
887
+ for (section, lines) in &self.state.portal_monitoring.section_overrides {
888
+ overrides.insert(section.cli_slug().to_string(), serde_json::json!(lines));
889
+ }
890
+ let payload = serde_json::json!({
891
+ "exportedAt": wall_clock_hms(),
892
+ "gatewayUrl": self.state.gateway_url.clone(),
893
+ "lastUpdated": self.state.portal_monitoring.last_updated.clone(),
894
+ "contentLines": self.state.portal_monitoring.content_lines.clone(),
895
+ "sectionOverrides": overrides,
896
+ "metricHistory": self.state.portal_monitoring.metric_history.clone(),
897
+ "dependencyAlerts": self.state.portal_monitoring.dependency_alerts.clone(),
898
+ "trendView": self.state.portal_monitoring.trend_view,
899
+ });
900
+ let unix = std::time::SystemTime::now()
901
+ .duration_since(std::time::UNIX_EPOCH)
902
+ .map(|d| d.as_secs())
903
+ .unwrap_or(0);
904
+ let mut path = std::env::current_dir()
905
+ .unwrap_or_else(|_| std::env::temp_dir());
906
+ path.push(format!("4runr-monitoring-export-{unix}.json"));
907
+ let body = serde_json::to_string_pretty(&payload)?;
908
+ std::fs::write(&path, body)?;
909
+ let display = path.display().to_string();
910
+ self.state.portal_monitoring.last_export_path = Some(display.clone());
911
+ Ok(display)
912
+ }
913
+
583
914
  /// CLI ↔ TUI WebSocket lost: drop Gateway link UI and leave Portal Monitoring / Connection Portal so the session does not appear still "connected".
584
915
  pub fn on_cli_backend_disconnect(&mut self) {
585
916
  use crate::screens::Screen;
@@ -591,8 +922,12 @@ impl App {
591
922
  self.state.pending_gateway_connect_id = None;
592
923
  self.state.pending_gateway_health_id = None;
593
924
  self.state.pending_gateway_observability_id = None;
925
+ self.state.pending_monitoring_refresh_id = None;
926
+ self.state.pending_monitoring_logs_id = None;
927
+ self.state.pending_monitoring_drill_id = None;
594
928
  self.state.portal_observability_last_poll = None;
595
929
  self.state.portal_monitoring = PortalMonitoringState::default();
930
+ self.state.advanced_monitoring = AdvancedMonitoringState::default();
596
931
  self.state.connection_portal.finish_connecting();
597
932
  self.state.connection_portal.connection_success = false;
598
933
  self.state.connection_portal.connecting = false;
@@ -951,6 +1286,9 @@ impl App {
951
1286
  self.state
952
1287
  .navigation
953
1288
  .navigate_to_base(Screen::PortalMonitoring);
1289
+ self.state.advanced_monitoring =
1290
+ AdvancedMonitoringState::new(self.state.gateway_url.clone());
1291
+ self.state.portal_monitoring.scroll_offset = 0;
954
1292
  self.state.logs.push_back("[NAV] Opening Portal Monitoring...".into());
955
1293
  if let Some(ws) = ws_client {
956
1294
  self.begin_portal_observability_request(ws);
@@ -1999,6 +2337,9 @@ impl App {
1999
2337
  self.state
2000
2338
  .navigation
2001
2339
  .navigate_to_base(Screen::PortalMonitoring);
2340
+ self.state.advanced_monitoring =
2341
+ AdvancedMonitoringState::new(self.state.gateway_url.clone());
2342
+ self.state.portal_monitoring.scroll_offset = 0;
2002
2343
  self.add_log("[NAV] Opening Portal Monitoring".to_string());
2003
2344
  if let Some(ws) = ws_client {
2004
2345
  self.begin_portal_observability_request(ws);
@@ -2020,7 +2361,11 @@ impl App {
2020
2361
  self.state.pending_gateway_connect_id = None;
2021
2362
  self.state.pending_gateway_health_id = None;
2022
2363
  self.state.pending_gateway_observability_id = None;
2364
+ self.state.pending_monitoring_refresh_id = None;
2365
+ self.state.pending_monitoring_logs_id = None;
2366
+ self.state.pending_monitoring_drill_id = None;
2023
2367
  self.state.portal_monitoring = PortalMonitoringState::default();
2368
+ self.state.advanced_monitoring = AdvancedMonitoringState::default();
2024
2369
  self.state.portal_observability_last_poll = None;
2025
2370
  self.state.navigation.navigate_to_base(Screen::Main);
2026
2371
  self.add_log("[MODE] Continuing in LOCAL mode (no Gateway connection)".to_string());
@@ -2152,6 +2497,7 @@ impl App {
2152
2497
  key: KeyEvent,
2153
2498
  ws_client: Option<&WebSocketClient>,
2154
2499
  ) -> anyhow::Result<bool> {
2500
+ use crate::ui::portal_monitoring::{portal_monitoring_scroll_max};
2155
2501
  use crossterm::event::KeyModifiers;
2156
2502
  if key.modifiers.contains(KeyModifiers::CONTROL) {
2157
2503
  match key.code {
@@ -2162,19 +2508,59 @@ impl App {
2162
2508
  if key.code == KeyCode::F(10) {
2163
2509
  return Ok(true);
2164
2510
  }
2511
+ if self.state.portal_monitoring.help_visible {
2512
+ if key.code == KeyCode::Esc || key.code == KeyCode::Char('?') {
2513
+ self.state.portal_monitoring.help_visible = false;
2514
+ self.request_immediate_render("portal_help_close");
2515
+ }
2516
+ return Ok(false);
2517
+ }
2165
2518
  if key.code == KeyCode::Esc {
2166
2519
  self.state.pending_gateway_observability_id = None;
2520
+ self.state.pending_monitoring_refresh_id = None;
2521
+ self.state.pending_monitoring_logs_id = None;
2522
+ self.state.pending_monitoring_drill_id = None;
2523
+ self.state.portal_monitoring.section_refresh_loading = None;
2524
+ self.state.portal_monitoring.logs_fetch_loading = false;
2525
+ self.state.portal_monitoring.metrics_drill = None;
2526
+ self.state.portal_monitoring.metrics_drill_lines.clear();
2527
+ self.state.portal_monitoring.metrics_drill_loading = false;
2167
2528
  self.state.navigation.navigate_to_base(Screen::Main);
2168
2529
  self.add_log("[NAV] Portal Monitoring closed".to_string());
2169
2530
  self.request_immediate_render("portal_monitoring_close");
2170
2531
  return Ok(false);
2171
2532
  }
2533
+ if key.code == KeyCode::Char('?') {
2534
+ self.state.portal_monitoring.help_visible = true;
2535
+ self.request_immediate_render("portal_help_open");
2536
+ return Ok(false);
2537
+ }
2538
+ // R is context-sensitive: Overview → full `gateway.observability` snapshot; any other
2539
+ // section → `monitoring.refresh` for that section only (cheaper; matches expanded detail).
2172
2540
  if key.code == KeyCode::Char('r') || key.code == KeyCode::Char('R') {
2173
2541
  if let Some(ws) = ws_client {
2542
+ let sections = MonitoringSection::all();
2543
+ let idx = self.state.advanced_monitoring.monitoring_state.selected_index;
2544
+ let current = sections.get(idx).copied();
2174
2545
  self.state.portal_observability_last_poll = None;
2175
2546
  self.state.pending_gateway_observability_id = None;
2547
+ self.state.pending_monitoring_refresh_id = None;
2548
+ self.state.pending_monitoring_logs_id = None;
2549
+ self.state.pending_monitoring_drill_id = None;
2550
+ self.state.portal_monitoring.section_refresh_loading = None;
2551
+ self.state.portal_monitoring.logs_fetch_loading = false;
2552
+ self.state.portal_monitoring.metrics_drill = None;
2553
+ self.state.portal_monitoring.metrics_drill_lines.clear();
2554
+ self.state.portal_monitoring.metrics_drill_loading = false;
2176
2555
  self.state.portal_monitoring.last_refresh = Some(std::time::Instant::now());
2177
- self.begin_portal_observability_request(ws);
2556
+ match current {
2557
+ Some(MonitoringSection::Overview) | None => {
2558
+ self.begin_portal_observability_request(ws);
2559
+ }
2560
+ Some(sec) => {
2561
+ self.begin_monitoring_refresh_request(ws, sec);
2562
+ }
2563
+ }
2178
2564
  self.request_immediate_render("portal_obs_refresh");
2179
2565
  } else {
2180
2566
  self.state.portal_monitoring.error =
@@ -2183,10 +2569,155 @@ impl App {
2183
2569
  }
2184
2570
  return Ok(false);
2185
2571
  }
2572
+ if key.code == KeyCode::Char('l') || key.code == KeyCode::Char('L') {
2573
+ if let Some(ws) = ws_client {
2574
+ self.begin_monitoring_logs_request(ws);
2575
+ self.state
2576
+ .advanced_monitoring
2577
+ .monitoring_state
2578
+ .expand_section(MonitoringSection::Logs);
2579
+ self.request_immediate_render("portal_monitoring_logs");
2580
+ } else {
2581
+ self.state.portal_monitoring.error =
2582
+ Some("WebSocket to CLI is not connected.".to_string());
2583
+ self.request_immediate_render("portal_obs_no_ws");
2584
+ }
2585
+ return Ok(false);
2586
+ }
2587
+
2588
+ if key.code == KeyCode::Char('h') || key.code == KeyCode::Char('H') {
2589
+ self.state.portal_monitoring.trend_view = !self.state.portal_monitoring.trend_view;
2590
+ let status = if self.state.portal_monitoring.trend_view {
2591
+ "enabled"
2592
+ } else {
2593
+ "disabled"
2594
+ };
2595
+ self.add_log(format!("[PORTAL] Trend/history view {}", status));
2596
+ self.request_immediate_render("portal_trend_toggle");
2597
+ return Ok(false);
2598
+ }
2599
+
2600
+ if key.code == KeyCode::Char('e') || key.code == KeyCode::Char('E') {
2601
+ match self.export_portal_monitoring_snapshot() {
2602
+ Ok(path) => {
2603
+ self.add_log(format!("[PORTAL] Exported monitoring snapshot: {}", path));
2604
+ self.state.portal_monitoring.error = None;
2605
+ }
2606
+ Err(e) => {
2607
+ self.state.portal_monitoring.error = Some(format!("Export failed: {}", e));
2608
+ }
2609
+ }
2610
+ self.request_immediate_render("portal_export");
2611
+ return Ok(false);
2612
+ }
2613
+
2614
+ // Phase 3: extended dependency view (GET /api/monitoring/dependencies/detail)
2615
+ if key.code == KeyCode::Char('t') || key.code == KeyCode::Char('T') {
2616
+ let sections = MonitoringSection::all();
2617
+ let idx = self.state.advanced_monitoring.monitoring_state.selected_index;
2618
+ let current = sections.get(idx).copied();
2619
+ let expanded = current
2620
+ .and_then(|sec| {
2621
+ self.state
2622
+ .advanced_monitoring
2623
+ .monitoring_state
2624
+ .sections
2625
+ .get(&sec)
2626
+ .map(|s| s.expanded)
2627
+ })
2628
+ .unwrap_or(false);
2629
+ if current == Some(MonitoringSection::Dependencies) && expanded {
2630
+ if let Some(ws) = ws_client {
2631
+ self.begin_dependencies_detail_drill(ws);
2632
+ self.request_immediate_render("portal_deps_drill");
2633
+ } else {
2634
+ self.state.portal_monitoring.error =
2635
+ Some("WebSocket to CLI is not connected.".to_string());
2636
+ self.request_immediate_render("portal_obs_no_ws");
2637
+ }
2638
+ return Ok(false);
2639
+ }
2640
+ }
2641
+
2642
+ // Phase 4: diagnostics for the local CLI/TUI bridge + Gateway reachability.
2643
+ if key.code == KeyCode::Char('s') || key.code == KeyCode::Char('S') {
2644
+ let sections = MonitoringSection::all();
2645
+ let idx = self.state.advanced_monitoring.monitoring_state.selected_index;
2646
+ let current = sections.get(idx).copied();
2647
+ let expanded = current
2648
+ .and_then(|sec| {
2649
+ self.state
2650
+ .advanced_monitoring
2651
+ .monitoring_state
2652
+ .sections
2653
+ .get(&sec)
2654
+ .map(|s| s.expanded)
2655
+ })
2656
+ .unwrap_or(false);
2657
+ if current == Some(MonitoringSection::System) && expanded {
2658
+ if let Some(ws) = ws_client {
2659
+ self.begin_system_diagnostics_request(ws);
2660
+ self.request_immediate_render("portal_system_diagnostics");
2661
+ } else {
2662
+ self.state.portal_monitoring.error =
2663
+ Some("WebSocket to CLI is not connected.".to_string());
2664
+ self.request_immediate_render("portal_obs_no_ws");
2665
+ }
2666
+ return Ok(false);
2667
+ }
2668
+ }
2669
+
2670
+ if key.code == KeyCode::Right {
2671
+ let sections = MonitoringSection::all();
2672
+ let idx = self.state.advanced_monitoring.monitoring_state.selected_index;
2673
+ let current = sections.get(idx).copied();
2674
+ let expanded = current
2675
+ .and_then(|sec| {
2676
+ self.state
2677
+ .advanced_monitoring
2678
+ .monitoring_state
2679
+ .sections
2680
+ .get(&sec)
2681
+ .map(|s| s.expanded)
2682
+ })
2683
+ .unwrap_or(false);
2684
+ if current == Some(MonitoringSection::Metrics) && expanded {
2685
+ if let Some(ws) = ws_client {
2686
+ let panel = self
2687
+ .state
2688
+ .portal_monitoring
2689
+ .metrics_drill
2690
+ .map(|p| p.next())
2691
+ .unwrap_or(MetricsDrillPanel::Http);
2692
+ self.begin_monitoring_drill_request(ws, panel);
2693
+ self.request_immediate_render("portal_metrics_drill");
2694
+ } else {
2695
+ self.state.portal_monitoring.error =
2696
+ Some("WebSocket to CLI is not connected.".to_string());
2697
+ self.request_immediate_render("portal_obs_no_ws");
2698
+ }
2699
+ return Ok(false);
2700
+ }
2701
+ }
2702
+
2703
+ if key.code == KeyCode::Left {
2704
+ if self.state.portal_monitoring.metrics_drill.is_some() {
2705
+ self.state.portal_monitoring.metrics_drill = None;
2706
+ self.state.portal_monitoring.metrics_drill_lines.clear();
2707
+ self.state.portal_monitoring.metrics_drill_loading = false;
2708
+ self.request_immediate_render("portal_metrics_drill_exit");
2709
+ return Ok(false);
2710
+ }
2711
+ }
2712
+
2186
2713
  // Toggle auto-refresh with 'A' key
2187
2714
  if key.code == KeyCode::Char('a') || key.code == KeyCode::Char('A') {
2188
2715
  self.state.portal_monitoring.auto_refresh_enabled =
2189
2716
  !self.state.portal_monitoring.auto_refresh_enabled;
2717
+ self.state
2718
+ .advanced_monitoring
2719
+ .monitoring_state
2720
+ .live_mode = self.state.portal_monitoring.auto_refresh_enabled;
2190
2721
  let status = if self.state.portal_monitoring.auto_refresh_enabled {
2191
2722
  "enabled"
2192
2723
  } else {
@@ -2196,20 +2727,50 @@ impl App {
2196
2727
  self.request_immediate_render("portal_obs_auto_toggle");
2197
2728
  return Ok(false);
2198
2729
  }
2730
+
2731
+ // Phase 1: Enter / Space — expand or collapse selected section
2732
+ if key.code == KeyCode::Enter || key.code == KeyCode::Char(' ') {
2733
+ let idx = self.state.advanced_monitoring.monitoring_state.selected_index;
2734
+ self.state
2735
+ .advanced_monitoring
2736
+ .monitoring_state
2737
+ .toggle_section_at_index(idx);
2738
+ self.request_immediate_render("portal_monitoring_toggle_section");
2739
+ return Ok(false);
2740
+ }
2741
+
2199
2742
  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)));
2743
+ let sw = self.state.portal_monitoring.summary_clip_width.max(24);
2744
+ let max_scroll = portal_monitoring_scroll_max(&self.state, sw, inner_h);
2745
+
2202
2746
  match key.code {
2203
- KeyCode::Up | KeyCode::PageUp => {
2747
+ KeyCode::Up => {
2748
+ self.state
2749
+ .advanced_monitoring
2750
+ .monitoring_state
2751
+ .select_previous_wrapped();
2752
+ self.state.portal_monitoring.scroll_offset = 0;
2753
+ self.request_immediate_render("portal_monitoring_select");
2754
+ }
2755
+ KeyCode::Down => {
2756
+ self.state
2757
+ .advanced_monitoring
2758
+ .monitoring_state
2759
+ .select_next_wrapped();
2760
+ self.state.portal_monitoring.scroll_offset = 0;
2761
+ self.request_immediate_render("portal_monitoring_select");
2762
+ }
2763
+ KeyCode::PageUp => {
2764
+ let step = inner_h.saturating_sub(3).max(1);
2204
2765
  self.state.portal_monitoring.scroll_offset = self
2205
2766
  .state
2206
2767
  .portal_monitoring
2207
2768
  .scroll_offset
2208
- .saturating_sub(if key.code == KeyCode::PageUp { inner_h } else { 1 });
2769
+ .saturating_sub(step);
2209
2770
  self.request_immediate_render("portal_obs_scroll");
2210
2771
  }
2211
- KeyCode::Down | KeyCode::PageDown => {
2212
- let step = if key.code == KeyCode::PageDown { inner_h } else { 1 };
2772
+ KeyCode::PageDown => {
2773
+ let step = inner_h.saturating_sub(3).max(1);
2213
2774
  self.state.portal_monitoring.scroll_offset =
2214
2775
  (self.state.portal_monitoring.scroll_offset + step).min(max_scroll);
2215
2776
  self.request_immediate_render("portal_obs_scroll");