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.
- package/apps/gateway/dist/apps/gateway/src/index.js +14 -4
- package/apps/gateway/dist/apps/gateway/src/index.js.map +1 -1
- package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts +18 -0
- package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts.map +1 -0
- package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js +117 -0
- package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js.map +1 -0
- package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts +2 -0
- package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts.map +1 -0
- package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js +54 -0
- package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js.map +1 -0
- package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts +15 -0
- package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts.map +1 -0
- package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js +164 -0
- package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js.map +1 -0
- package/apps/gateway/package-lock.json +204 -353
- package/apps/gateway/src/index.ts +27 -8
- package/apps/gateway/src/metrics/monitoring-detail.ts +162 -0
- package/apps/gateway/src/middleware/log-capture.ts +70 -0
- package/apps/gateway/src/routes/monitoring.ts +298 -0
- package/dist/gateway-client.d.ts +2 -0
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +22 -0
- package/dist/gateway-client.js.map +1 -1
- package/dist/tui-handlers.js +498 -0
- package/dist/tui-handlers.js.map +1 -1
- package/mk3-tui/src/app.rs +569 -8
- package/mk3-tui/src/main.rs +248 -0
- package/mk3-tui/src/monitoring/mod.rs +428 -0
- package/mk3-tui/src/ui/portal_monitoring.rs +1018 -146
- package/package.json +2 -2
package/mk3-tui/src/app.rs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2201
|
-
let max_scroll =
|
|
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
|
|
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(
|
|
2769
|
+
.saturating_sub(step);
|
|
2209
2770
|
self.request_immediate_render("portal_obs_scroll");
|
|
2210
2771
|
}
|
|
2211
|
-
KeyCode::
|
|
2212
|
-
let step =
|
|
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");
|