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
@@ -3,7 +3,8 @@ use crossterm::cursor;
3
3
  use crossterm::event::{self, Event, KeyCode, KeyEventKind, MouseEventKind};
4
4
  use crossterm::execute;
5
5
  use crossterm::terminal::{
6
- disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
6
+ disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
7
+ LeaveAlternateScreen, SetTitle,
7
8
  };
8
9
  use ratatui::prelude::*;
9
10
  use std::time::{Duration, Instant};
@@ -11,6 +12,7 @@ use std::time::{Duration, Instant};
11
12
  mod app;
12
13
  mod debug_log;
13
14
  mod io;
15
+ mod monitoring;
14
16
  mod screens;
15
17
  mod storage;
16
18
  mod ui;
@@ -23,6 +25,22 @@ use websocket::{WebSocketClient, WsClientMessage};
23
25
  /// Default local WebSocket port for TUI ↔ Node (keep in sync with `DEFAULT_TUI_WS_PORT` in `packages/os-cli/src/tui-defaults.ts`).
24
26
  const DEFAULT_TUI_WS_PORT: u16 = 8081;
25
27
 
28
+ fn sanitize_monitoring_line(raw: &str) -> String {
29
+ let mut line: String = raw
30
+ .chars()
31
+ .map(|ch| match ch {
32
+ '\r' | '\n' => ' ',
33
+ ch if ch.is_control() && ch != '\t' => ' ',
34
+ ch => ch,
35
+ })
36
+ .collect();
37
+ if line.chars().count() > 500 {
38
+ line = line.chars().take(500).collect();
39
+ line.push('…');
40
+ }
41
+ line
42
+ }
43
+
26
44
  fn main() -> Result<()> {
27
45
  // Window / tab title (otherwise hosts often show the executable name, e.g. mk3-tui)
28
46
  let window_title = match std::env::var("4RUNR_CLI_VERSION") {
@@ -33,36 +51,36 @@ fn main() -> Result<()> {
33
51
  // Setup terminal (boot screen is now part of TUI)
34
52
  enable_raw_mode()?;
35
53
  let mut stdout = std::io::stdout();
36
-
54
+
37
55
  // Enter alternate screen, hide cursor, clear screen, move to top-left
38
56
  execute!(
39
- stdout,
57
+ stdout,
40
58
  EnterAlternateScreen,
41
59
  SetTitle(window_title),
42
60
  cursor::Hide,
43
61
  Clear(ClearType::All),
44
62
  cursor::MoveTo(0, 0)
45
63
  )?;
46
-
64
+
47
65
  let backend = CrosstermBackend::new(stdout);
48
66
  let mut terminal = Terminal::new(backend)?;
49
-
67
+
50
68
  // Clear the terminal buffer to ensure no artifacts
51
69
  terminal.clear()?;
52
70
 
53
71
  // Create app
54
72
  let mut app = App::new();
55
73
  let mut io_handler = IoHandler::new()?;
56
-
74
+
57
75
  // Create runtime for async WebSocket client
58
76
  let rt = tokio::runtime::Runtime::new()?;
59
-
77
+
60
78
  // Get TUI port from environment variable or default (see DEFAULT_TUI_WS_PORT)
61
79
  let tui_port = std::env::var("TUI_PORT")
62
80
  .ok()
63
81
  .and_then(|p| p.parse::<u16>().ok())
64
82
  .unwrap_or(DEFAULT_TUI_WS_PORT);
65
-
83
+
66
84
  // Try to connect to WebSocket server
67
85
  let ws_url = format!("ws://localhost:{}", tui_port);
68
86
  let ws_client = rt.block_on(async {
@@ -78,17 +96,17 @@ fn main() -> Result<()> {
78
96
  }
79
97
  }
80
98
  });
81
-
99
+
82
100
  // Track real time for uptime (not from tick counter)
83
101
  let start_time = Instant::now();
84
102
  let mut last_tick = Instant::now();
85
103
  let mut portal_connect_ui_tick = Instant::now();
86
-
104
+
87
105
  // Track previous screen to detect portal navigation
88
106
  let mut previous_screen: Option<crate::screens::Screen> = None;
89
107
  // Last terminal size while Connection or Setup portal is visible (windowed hosts: sync buffer + clear on dimension drift).
90
108
  let mut standalone_portal_last_terminal_dims: Option<(u16, u16)> = None;
91
-
109
+
92
110
  // Force initial render
93
111
  app.request_render("initial");
94
112
 
@@ -98,7 +116,7 @@ fn main() -> Result<()> {
98
116
  // ┌─────────────────────────────────────────────────────────┐
99
117
  // │ STEP 1: UPDATE ANIMATIONS (runs EVERY loop iteration) │
100
118
  // └─────────────────────────────────────────────────────────┘
101
-
119
+
102
120
  // Check if portal is active - if so, skip base screen state updates
103
121
  // This prevents base screen from affecting portal performance
104
122
  use crate::screens::Screen;
@@ -107,7 +125,7 @@ fn main() -> Result<()> {
107
125
  current_screen,
108
126
  Screen::ConnectionPortal | Screen::SetupPortal | Screen::PortalMonitoring
109
127
  );
110
-
128
+
111
129
  // DEBUG: Log portal detection
112
130
  #[cfg(debug_assertions)]
113
131
  {
@@ -115,47 +133,54 @@ fn main() -> Result<()> {
115
133
  unsafe {
116
134
  LOG_COUNT += 1;
117
135
  if LOG_COUNT % 100 == 0 {
118
- eprintln!("[MAIN] Current screen: {:?}, Is portal active: {}", current_screen, is_portal_active);
119
- eprintln!("[MAIN] Base screen: {:?}, Overlay stack: {:?}",
120
- app.state.navigation.base_screen,
121
- app.state.navigation.overlay_stack);
136
+ eprintln!(
137
+ "[MAIN] Current screen: {:?}, Is portal active: {}",
138
+ current_screen, is_portal_active
139
+ );
140
+ eprintln!(
141
+ "[MAIN] Base screen: {:?}, Overlay stack: {:?}",
142
+ app.state.navigation.base_screen, app.state.navigation.overlay_stack
143
+ );
122
144
  }
123
145
  }
124
146
  }
125
-
147
+
126
148
  // Only update base screen state when NOT in a portal
127
149
  // Portals are standalone screens and don't need base screen updates
128
150
  if !is_portal_active {
129
151
  // Uptime - always update from real clock (OUTSIDE poll block)
130
152
  app.state.uptime_secs = start_time.elapsed().as_secs();
131
-
153
+
132
154
  // Spinner - update on timer (every 150ms) - OUTSIDE poll block
133
155
  if last_tick.elapsed() >= Duration::from_millis(150) {
134
- app.tick(); // Advances spinner_frame
156
+ app.tick(); // Advances spinner_frame
135
157
  last_tick = Instant::now();
136
158
  app.request_render("animation_tick"); // Request render for animation update
137
159
  }
138
160
  }
139
161
 
140
162
  // Connection Portal: redraw while waiting on gateway.connect so the elapsed timer advances
141
- if matches!(current_screen, Screen::ConnectionPortal) && app.state.connection_portal.connecting {
163
+ if matches!(current_screen, Screen::ConnectionPortal)
164
+ && app.state.connection_portal.connecting
165
+ {
142
166
  if portal_connect_ui_tick.elapsed() >= Duration::from_millis(500) {
143
167
  portal_connect_ui_tick = Instant::now();
144
168
  app.request_immediate_render("portal_connecting_tick");
145
169
  }
146
170
  }
147
-
171
+
148
172
  // Portal Monitoring: auto-refresh every 5 seconds when enabled
149
173
  if matches!(current_screen, Screen::PortalMonitoring) {
150
174
  let should_auto_refresh = {
151
175
  let pm = &app.state.portal_monitoring;
152
176
  pm.auto_refresh_enabled
153
177
  && !pm.loading
154
- && pm.last_refresh
178
+ && pm
179
+ .last_refresh
155
180
  .map(|lr| lr.elapsed() >= pm.auto_refresh_interval)
156
181
  .unwrap_or(true)
157
182
  };
158
-
183
+
159
184
  if should_auto_refresh {
160
185
  if let Some(ws) = &ws_client {
161
186
  app.state.portal_monitoring.last_refresh = Some(Instant::now());
@@ -163,7 +188,7 @@ fn main() -> Result<()> {
163
188
  }
164
189
  }
165
190
  }
166
-
191
+
167
192
  // ┌─────────────────────────────────────────────────────────┐
168
193
  // │ STEP 2: CHECK WEBSOCKET MESSAGES │
169
194
  // └─────────────────────────────────────────────────────────┘
@@ -172,17 +197,26 @@ fn main() -> Result<()> {
172
197
  match msg {
173
198
  WsClientMessage::Connected => {
174
199
  app.add_log("WebSocket connected".to_string());
175
-
200
+
176
201
  // Step 4.9: Send startup commands to load real data
177
202
  if let Ok(id) = client.send_command("system.status", None) {
178
- app.add_log(format!("Sent system.status (id: {})", &id[id.len().saturating_sub(8)..]));
203
+ app.add_log(format!(
204
+ "Sent system.status (id: {})",
205
+ &id[id.len().saturating_sub(8)..]
206
+ ));
179
207
  }
180
208
  if let Ok(id) = client.send_command("agent.list", None) {
181
- app.add_log(format!("Sent agent.list (id: {})", &id[id.len().saturating_sub(8)..]));
209
+ app.add_log(format!(
210
+ "Sent agent.list (id: {})",
211
+ &id[id.len().saturating_sub(8)..]
212
+ ));
182
213
  }
183
214
  }
184
215
  WsClientMessage::Disconnected => {
185
- app.add_log("[WEBSOCKET] Disconnected from CLI backend — Gateway link cleared".to_string());
216
+ app.add_log(
217
+ "[WEBSOCKET] Disconnected from CLI backend — Gateway link cleared"
218
+ .to_string(),
219
+ );
186
220
  app.on_cli_backend_disconnect();
187
221
  }
188
222
  WsClientMessage::ConnectionLost => {
@@ -199,7 +233,7 @@ fn main() -> Result<()> {
199
233
  if resp.payload.success {
200
234
  // Format response nicely and update state based on command
201
235
  let short_id = &resp.id[resp.id.len().saturating_sub(8)..];
202
-
236
+
203
237
  // Try to extract and apply data to state
204
238
  if let Some(data) = &resp.payload.data {
205
239
  if let Some(obj) = data.as_object() {
@@ -217,7 +251,9 @@ fn main() -> Result<()> {
217
251
  "✓ [{}] Mode: {}, Posture: {}",
218
252
  short_id,
219
253
  mode.as_str().unwrap_or("?"),
220
- obj.get("posture").and_then(|p| p.as_str()).unwrap_or("?")
254
+ obj.get("posture")
255
+ .and_then(|p| p.as_str())
256
+ .unwrap_or("?")
221
257
  ));
222
258
  }
223
259
  // Handle agent.list response
@@ -225,63 +261,100 @@ fn main() -> Result<()> {
225
261
  if let Some(agents_array) = agents_data.as_array() {
226
262
  // Parse agents into AgentInfo structs
227
263
  use crate::app::AgentInfo;
228
- let agents: Vec<AgentInfo> = agents_array.iter()
264
+ let agents: Vec<AgentInfo> = agents_array
265
+ .iter()
229
266
  .filter_map(|agent| {
230
267
  let obj = agent.as_object()?;
231
268
  Some(AgentInfo {
232
- name: obj.get("name")?.as_str()?.to_string(),
233
- description: obj.get("description").and_then(|d| d.as_str()).map(|s| s.to_string()),
234
- model: obj.get("model").and_then(|m| m.as_str()).unwrap_or("unknown").to_string(),
235
- provider: obj.get("provider").and_then(|p| p.as_str()).unwrap_or("unknown").to_string(),
236
- system_prompt: obj.get("systemPrompt").and_then(|sp| sp.as_str()).map(|s| s.to_string()),
237
- temperature: obj.get("temperature").and_then(|t| t.as_f64()).map(|f| f as f32),
238
- max_tokens: obj.get("maxTokens").and_then(|mt| mt.as_u64()).map(|u| u as u32),
239
- tools: obj.get("tools")
269
+ name: obj
270
+ .get("name")?
271
+ .as_str()?
272
+ .to_string(),
273
+ description: obj
274
+ .get("description")
275
+ .and_then(|d| d.as_str())
276
+ .map(|s| s.to_string()),
277
+ model: obj
278
+ .get("model")
279
+ .and_then(|m| m.as_str())
280
+ .unwrap_or("unknown")
281
+ .to_string(),
282
+ provider: obj
283
+ .get("provider")
284
+ .and_then(|p| p.as_str())
285
+ .unwrap_or("unknown")
286
+ .to_string(),
287
+ system_prompt: obj
288
+ .get("systemPrompt")
289
+ .and_then(|sp| sp.as_str())
290
+ .map(|s| s.to_string()),
291
+ temperature: obj
292
+ .get("temperature")
293
+ .and_then(|t| t.as_f64())
294
+ .map(|f| f as f32),
295
+ max_tokens: obj
296
+ .get("maxTokens")
297
+ .and_then(|mt| mt.as_u64())
298
+ .map(|u| u as u32),
299
+ tools: obj
300
+ .get("tools")
240
301
  .and_then(|t| t.as_array())
241
- .map(|arr| arr.iter()
242
- .filter_map(|v| v.as_str().map(|s| s.to_string()))
243
- .collect())
302
+ .map(|arr| {
303
+ arr.iter()
304
+ .filter_map(|v| {
305
+ v.as_str()
306
+ .map(|s| s.to_string())
307
+ })
308
+ .collect()
309
+ })
244
310
  .unwrap_or_default(),
245
311
  })
246
312
  })
247
313
  .collect();
248
-
314
+
249
315
  // Update capabilities with agent names
250
- app.state.capabilities = agents.iter()
251
- .map(|a| a.name.clone())
252
- .collect();
253
-
316
+ app.state.capabilities =
317
+ agents.iter().map(|a| a.name.clone()).collect();
318
+
254
319
  // Update cache with agent data
255
320
  if let Some(cache) = &mut app.state.cache {
256
321
  use crate::storage::cache::AgentData;
257
322
  use std::time::{SystemTime, UNIX_EPOCH};
258
-
259
- let cache_agents: Vec<AgentData> = agents.iter()
323
+
324
+ let cache_agents: Vec<AgentData> = agents
325
+ .iter()
260
326
  .map(|agent| AgentData {
261
327
  name: agent.name.clone(),
262
328
  description: agent.description.clone(),
263
329
  model: agent.model.clone(),
264
330
  provider: agent.provider.clone(),
265
- created_at: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(),
331
+ created_at: SystemTime::now()
332
+ .duration_since(UNIX_EPOCH)
333
+ .unwrap()
334
+ .as_secs(),
266
335
  })
267
336
  .collect();
268
-
337
+
269
338
  let _ = cache.update_agents(cache_agents);
270
339
  }
271
-
340
+
272
341
  // Update agent list state
273
342
  app.state.agent_list.agents = agents;
274
343
  app.state.agent_list.selected_index = 0;
275
344
  app.state.agent_list.detail_view = None;
276
-
345
+
277
346
  // Clear pending agent.list ID if this response matches (Step 5.2)
278
- if Some(&resp.id) == app.state.pending_agent_list_id.as_ref() {
347
+ if Some(&resp.id)
348
+ == app.state.pending_agent_list_id.as_ref()
349
+ {
279
350
  app.state.pending_agent_list_id = None;
280
351
  }
281
-
352
+
282
353
  // Only open AgentList overlay if we're on Main screen (not during boot)
283
354
  use crate::screens::Screen;
284
- if app.state.navigation.current_screen() == &Screen::Main {
355
+ if app.state.navigation.current_screen()
356
+ == &Screen::Main
357
+ {
285
358
  app.push_overlay(Screen::AgentList);
286
359
  app.request_render("agent_list_opened");
287
360
  }
@@ -290,51 +363,76 @@ fn main() -> Result<()> {
290
363
  // Handle agent.create response (Step 5.1)
291
364
  else if let Some(agent) = obj.get("agent") {
292
365
  // Check if this is a response to our agent.create command
293
- if Some(&resp.id) == app.state.pending_agent_create_id.as_ref() {
366
+ if Some(&resp.id)
367
+ == app.state.pending_agent_create_id.as_ref()
368
+ {
294
369
  app.state.pending_agent_create_id = None;
295
-
370
+
296
371
  // Extract agent name for success message
297
- let agent_name = agent.as_object()
372
+ let agent_name = agent
373
+ .as_object()
298
374
  .and_then(|a| a.get("name"))
299
375
  .and_then(|n| n.as_str())
300
376
  .unwrap_or("unknown");
301
-
302
- app.add_log(format!("✓ [{}] Agent '{}' created successfully", short_id, agent_name));
303
-
377
+
378
+ app.add_log(format!(
379
+ "✓ [{}] Agent '{}' created successfully",
380
+ short_id, agent_name
381
+ ));
382
+
304
383
  // Refresh agent list (Step 5.3)
305
384
  if let Some(ref ws) = ws_client {
306
- if let Ok(list_id) = ws.send_command("agent.list", None) {
385
+ if let Ok(list_id) =
386
+ ws.send_command("agent.list", None)
387
+ {
307
388
  app.state.pending_agent_list_id = Some(list_id);
308
389
  }
309
390
  }
310
391
  } else {
311
- app.add_log(format!("✓ [{}] Agent response received", short_id));
392
+ app.add_log(format!(
393
+ "✓ [{}] Agent response received",
394
+ short_id
395
+ ));
312
396
  }
313
397
  }
314
398
  // Handle agent.delete response (Step 5.5)
315
399
  else if let Some(deleted) = obj.get("deleted") {
316
400
  if deleted.as_bool().unwrap_or(false) {
317
- if Some(&resp.id) == app.state.pending_agent_delete_id.as_ref() {
401
+ if Some(&resp.id)
402
+ == app.state.pending_agent_delete_id.as_ref()
403
+ {
318
404
  app.state.pending_agent_delete_id = None;
319
- app.add_log(format!("✓ [{}] Agent deleted successfully", short_id));
320
-
405
+ app.add_log(format!(
406
+ "✓ [{}] Agent deleted successfully",
407
+ short_id
408
+ ));
409
+
321
410
  // Refresh agent list (Step 5.5)
322
411
  if let Some(ref ws) = ws_client {
323
- if let Ok(list_id) = ws.send_command("agent.list", None) {
324
- app.state.pending_agent_list_id = Some(list_id);
412
+ if let Ok(list_id) =
413
+ ws.send_command("agent.list", None)
414
+ {
415
+ app.state.pending_agent_list_id =
416
+ Some(list_id);
325
417
  }
326
418
  }
327
419
  }
328
420
  }
329
421
  }
330
422
  // Step 6: run.list (match pending id first — avoids stealing the chain on unrelated `runs` keys)
331
- else if Some(&resp.id) == app.state.pending_run_list_id.as_ref() {
423
+ else if Some(&resp.id)
424
+ == app.state.pending_run_list_id.as_ref()
425
+ {
332
426
  app.state.pending_run_list_id = None;
333
- if let Some(runs_arr) = obj.get("runs").and_then(|r| r.as_array()) {
427
+ if let Some(runs_arr) =
428
+ obj.get("runs").and_then(|r| r.as_array())
429
+ {
334
430
  use crate::ui::run_manager::run_info_from_gateway_value;
335
431
  let mut parsed = Vec::new();
336
432
  for item in runs_arr {
337
- if let Some(info) = run_info_from_gateway_value(item) {
433
+ if let Some(info) =
434
+ run_info_from_gateway_value(item)
435
+ {
338
436
  parsed.push(info);
339
437
  }
340
438
  }
@@ -353,18 +451,23 @@ fn main() -> Result<()> {
353
451
  }
354
452
  }
355
453
  // Step 6: run.get (detail) — pending id first so `run` never blocks other handlers
356
- else if Some(&resp.id) == app.state.pending_run_get_id.as_ref() {
454
+ else if Some(&resp.id)
455
+ == app.state.pending_run_get_id.as_ref()
456
+ {
357
457
  app.state.pending_run_get_id = None;
358
458
  if let Some(run_val) = obj.get("run") {
359
459
  use crate::ui::run_manager::run_info_from_gateway_value;
360
- if let Some(info) = run_info_from_gateway_value(run_val) {
460
+ if let Some(info) = run_info_from_gateway_value(run_val)
461
+ {
361
462
  app.state.run_manager.merge_run_detail(info);
362
463
  }
363
464
  }
364
465
  app.request_render("run_detail_loaded");
365
466
  }
366
467
  // Step 6: run.cancel
367
- else if Some(&resp.id) == app.state.pending_run_cancel_id.as_ref() {
468
+ else if Some(&resp.id)
469
+ == app.state.pending_run_cancel_id.as_ref()
470
+ {
368
471
  app.state.pending_run_cancel_id = None;
369
472
  if let (Some(rid), Some(st)) = (
370
473
  obj.get("runId").and_then(|v| v.as_str()),
@@ -375,18 +478,24 @@ fn main() -> Result<()> {
375
478
  short_id, rid, st
376
479
  ));
377
480
  } else {
378
- app.add_log(format!("✓ [{}] Run cancel acknowledged", short_id));
481
+ app.add_log(format!(
482
+ "✓ [{}] Run cancel acknowledged",
483
+ short_id
484
+ ));
379
485
  }
380
486
  if let Some(ref ws) = ws_client {
381
487
  let data = serde_json::json!({ "limit": 50 });
382
- if let Ok(lid) = ws.send_command("run.list", Some(data)) {
488
+ if let Ok(lid) = ws.send_command("run.list", Some(data))
489
+ {
383
490
  app.state.pending_run_list_id = Some(lid);
384
491
  }
385
492
  }
386
493
  app.request_render("run_cancelled");
387
494
  }
388
495
  // Step 6: run.quick
389
- else if Some(&resp.id) == app.state.pending_run_quick_id.as_ref() {
496
+ else if Some(&resp.id)
497
+ == app.state.pending_run_quick_id.as_ref()
498
+ {
390
499
  app.state.pending_run_quick_id = None;
391
500
  if let (Some(rid), Some(st)) = (
392
501
  obj.get("runId").and_then(|v| v.as_str()),
@@ -401,42 +510,197 @@ fn main() -> Result<()> {
401
510
  }
402
511
  if let Some(ref ws) = ws_client {
403
512
  let data = serde_json::json!({ "limit": 50 });
404
- if let Ok(lid) = ws.send_command("run.list", Some(data)) {
513
+ if let Ok(lid) = ws.send_command("run.list", Some(data))
514
+ {
405
515
  app.state.pending_run_list_id = Some(lid);
406
516
  }
407
517
  }
408
518
  app.request_render("run_quick_ok");
409
519
  }
520
+ // Portal Monitoring Phase 2: monitoring.refresh (section slice)
521
+ else if Some(&resp.id)
522
+ == app.state.pending_monitoring_refresh_id.as_ref()
523
+ {
524
+ app.state.pending_monitoring_refresh_id = None;
525
+ app.state.portal_monitoring.section_refresh_loading = None;
526
+ if obj.get("monitoringRefresh").and_then(|v| v.as_bool())
527
+ == Some(true)
528
+ {
529
+ let section_str = obj
530
+ .get("section")
531
+ .and_then(|v| v.as_str())
532
+ .unwrap_or("");
533
+ use crate::monitoring::MonitoringSection;
534
+ if let Some(sec) =
535
+ MonitoringSection::from_cli_slug(section_str)
536
+ {
537
+ let lines: Vec<String> = obj
538
+ .get("lines")
539
+ .and_then(|a| a.as_array())
540
+ .map(|arr| {
541
+ arr.iter()
542
+ .filter_map(|v| {
543
+ v.as_str()
544
+ .map(sanitize_monitoring_line)
545
+ })
546
+ .collect()
547
+ })
548
+ .unwrap_or_default();
549
+ app.state
550
+ .portal_monitoring
551
+ .section_overrides
552
+ .insert(sec, lines);
553
+ }
554
+ }
555
+ app.request_render("monitoring_refresh_ok");
556
+ }
557
+ // Portal Monitoring Phase 2: monitoring.logs
558
+ else if Some(&resp.id)
559
+ == app.state.pending_monitoring_logs_id.as_ref()
560
+ {
561
+ app.state.pending_monitoring_logs_id = None;
562
+ app.state.portal_monitoring.logs_fetch_loading = false;
563
+ if obj.get("monitoringLogs").and_then(|v| v.as_bool())
564
+ == Some(true)
565
+ {
566
+ let lines: Vec<String> = obj
567
+ .get("logs")
568
+ .and_then(|a| a.as_array())
569
+ .map(|arr| {
570
+ arr.iter()
571
+ .filter_map(|entry| {
572
+ let e = entry.as_object()?;
573
+ let ts = e
574
+ .get("timestamp")
575
+ .and_then(|v| v.as_str())
576
+ .unwrap_or("");
577
+ let lvl = e
578
+ .get("level")
579
+ .and_then(|v| v.as_str())
580
+ .unwrap_or("");
581
+ let msg = e
582
+ .get("message")
583
+ .and_then(|v| v.as_str())
584
+ .unwrap_or("");
585
+ if ts.is_empty()
586
+ && lvl.is_empty()
587
+ && msg.is_empty()
588
+ {
589
+ return None;
590
+ }
591
+ Some(sanitize_monitoring_line(
592
+ &format!(
593
+ "{} [{}] {}",
594
+ ts, lvl, msg
595
+ ),
596
+ ))
597
+ })
598
+ .collect()
599
+ })
600
+ .unwrap_or_default();
601
+ use crate::monitoring::MonitoringSection;
602
+ app.state
603
+ .portal_monitoring
604
+ .section_overrides
605
+ .insert(MonitoringSection::Logs, lines);
606
+ }
607
+ app.request_render("monitoring_logs_ok");
608
+ }
609
+ // Portal Monitoring Phase 3: monitoring.drill (metrics sub-panels / dependency detail)
610
+ else if Some(&resp.id)
611
+ == app.state.pending_monitoring_drill_id.as_ref()
612
+ {
613
+ app.state.pending_monitoring_drill_id = None;
614
+ app.state.portal_monitoring.metrics_drill_loading = false;
615
+ app.state.portal_monitoring.section_refresh_loading = None;
616
+ if obj.get("monitoringDrill").and_then(|v| v.as_bool())
617
+ == Some(true)
618
+ {
619
+ let target = obj
620
+ .get("target")
621
+ .and_then(|v| v.as_str())
622
+ .unwrap_or("");
623
+ let lines: Vec<String> = obj
624
+ .get("lines")
625
+ .and_then(|a| a.as_array())
626
+ .map(|arr| {
627
+ arr.iter()
628
+ .filter_map(|v| {
629
+ v.as_str().map(sanitize_monitoring_line)
630
+ })
631
+ .collect()
632
+ })
633
+ .unwrap_or_default();
634
+ use crate::monitoring::{
635
+ MetricsDrillPanel, MonitoringSection,
636
+ };
637
+ if target == "dependencies_detail" {
638
+ app.state
639
+ .portal_monitoring
640
+ .section_overrides
641
+ .insert(MonitoringSection::Dependencies, lines);
642
+ } else if target == "system_diagnostics" {
643
+ app.state
644
+ .portal_monitoring
645
+ .section_overrides
646
+ .insert(MonitoringSection::System, lines);
647
+ } else if let Some(panel) =
648
+ MetricsDrillPanel::from_cli_target(target)
649
+ {
650
+ app.state.portal_monitoring.metrics_drill =
651
+ Some(panel);
652
+ app.state.portal_monitoring.metrics_drill_lines =
653
+ lines;
654
+ }
655
+ }
656
+ app.request_render("monitoring_drill_ok");
657
+ }
410
658
  // Portal Monitoring: gateway.observability (/metrics snapshot)
411
- else if Some(&resp.id) == app.state.pending_gateway_observability_id.as_ref() {
659
+ else if Some(&resp.id)
660
+ == app.state.pending_gateway_observability_id.as_ref()
661
+ {
412
662
  app.state.pending_gateway_observability_id = None;
413
663
  app.state.portal_monitoring.loading = false;
414
- app.state.portal_observability_last_poll = Some(Instant::now());
415
- if obj.get("observability").and_then(|v| v.as_bool()) == Some(true) {
664
+ app.state.portal_observability_last_poll =
665
+ Some(Instant::now());
666
+ if obj.get("observability").and_then(|v| v.as_bool())
667
+ == Some(true)
668
+ {
416
669
  let snapshot = obj
417
670
  .get("snapshotAt")
418
671
  .and_then(|v| v.as_str())
419
672
  .unwrap_or("")
420
673
  .to_string();
674
+ // Snapshot markers — keep substring order compatible with
675
+ // `portal_monitoring::SNAPSHOT_MARKER_*` (CLI healthLines + layout below).
421
676
  let mut lines: Vec<String> = vec![
422
677
  "Portal snapshot (health + metrics on each refresh / auto poll)."
423
678
  .to_string(),
424
679
  format!("Fetched: {}", if snapshot.is_empty() { "—" } else { snapshot.as_str() }),
425
680
  String::new(),
426
681
  ];
427
- if let Some(tot) = obj.get("totals").and_then(|v| v.get("httpRequests")) {
682
+ if let Some(tot) = obj
683
+ .get("totals")
684
+ .and_then(|v| v.get("httpRequests"))
685
+ {
428
686
  let current = tot
429
687
  .as_u64()
430
688
  .or_else(|| tot.as_f64().map(|f| f as u64));
431
689
  if let Some(c) = current {
432
690
  if let (Some(prev), Some(last_t)) = (
433
- app.state.portal_monitoring.last_http_for_delta,
434
- app.state.portal_monitoring.last_instant_for_delta,
691
+ app.state
692
+ .portal_monitoring
693
+ .last_http_for_delta,
694
+ app.state
695
+ .portal_monitoring
696
+ .last_instant_for_delta,
435
697
  ) {
436
698
  let d = c.saturating_sub(prev);
437
- let el = last_t.elapsed().as_secs_f32().max(0.1);
699
+ let el =
700
+ last_t.elapsed().as_secs_f32().max(0.1);
438
701
  let per_min = (d as f32 / el) * 60.0;
439
- let pulse = if d > 0 { " *" } else { " --" };
702
+ let pulse =
703
+ if d > 0 { " *" } else { " --" };
440
704
  lines.push(format!(
441
705
  "LIVE: +{} HTTP since last screen refresh (~{:.0}/min){}",
442
706
  d, per_min, pulse
@@ -444,13 +708,138 @@ fn main() -> Result<()> {
444
708
  } else {
445
709
  lines.push("LIVE: first sample — next refresh shows HTTP request rate (poll interval).".to_string());
446
710
  }
447
- app.state.portal_monitoring.last_http_for_delta = Some(c);
448
- app.state.portal_monitoring.last_instant_for_delta =
711
+ app.state
712
+ .portal_monitoring
713
+ .last_http_for_delta = Some(c);
714
+ app.state
715
+ .portal_monitoring
716
+ .last_instant_for_delta =
449
717
  Some(std::time::Instant::now());
718
+ app.state
719
+ .portal_monitoring
720
+ .http_total_sparkline
721
+ .push_back(c);
722
+ while app
723
+ .state
724
+ .portal_monitoring
725
+ .http_total_sparkline
726
+ .len()
727
+ > 48
728
+ {
729
+ app.state
730
+ .portal_monitoring
731
+ .http_total_sparkline
732
+ .pop_front();
733
+ }
450
734
  }
451
735
  }
736
+ if let Some(totals) =
737
+ obj.get("totals").and_then(|v| v.as_object())
738
+ {
739
+ app.state
740
+ .portal_monitoring
741
+ .metric_history
742
+ .push_back(
743
+ crate::app::metric_history_entry_from_totals(
744
+ if snapshot.is_empty() {
745
+ "—".to_string()
746
+ } else {
747
+ snapshot.clone()
748
+ },
749
+ totals,
750
+ ),
751
+ );
752
+ while app
753
+ .state
754
+ .portal_monitoring
755
+ .metric_history
756
+ .len()
757
+ > 720
758
+ {
759
+ app.state
760
+ .portal_monitoring
761
+ .metric_history
762
+ .pop_front();
763
+ }
764
+ }
765
+ // Phase 5 v1: dependency alerts are text-derived from
766
+ // observability health lines, not a structured readiness enum.
767
+ let dependency_status = {
768
+ let mut status = if obj
769
+ .get("healthOk")
770
+ .and_then(|v| v.as_bool())
771
+ == Some(false)
772
+ {
773
+ "unhealthy"
774
+ } else {
775
+ "healthy"
776
+ };
777
+ if let Some(arr) = obj
778
+ .get("healthLines")
779
+ .and_then(|a| a.as_array())
780
+ {
781
+ for v in arr {
782
+ if let Some(s) = v.as_str() {
783
+ let l = s.to_lowercase();
784
+ if l.contains("✗")
785
+ || l.contains(": down")
786
+ || l.contains("not ready")
787
+ {
788
+ status = "unhealthy";
789
+ break;
790
+ }
791
+ if l.contains("⚠")
792
+ || l.contains("degraded")
793
+ || l.contains("warning")
794
+ {
795
+ status = "degraded";
796
+ }
797
+ }
798
+ }
799
+ }
800
+ status.to_string()
801
+ };
802
+ if let Some(prev) = app
803
+ .state
804
+ .portal_monitoring
805
+ .last_dependency_status
806
+ .clone()
807
+ {
808
+ if prev != dependency_status {
809
+ app.state
810
+ .portal_monitoring
811
+ .dependency_alerts
812
+ .push_back(
813
+ crate::app::DependencyAlertEntry {
814
+ timestamp: if snapshot.is_empty() {
815
+ "—".to_string()
816
+ } else {
817
+ snapshot.clone()
818
+ },
819
+ previous: prev,
820
+ current: dependency_status.clone(),
821
+ },
822
+ );
823
+ while app
824
+ .state
825
+ .portal_monitoring
826
+ .dependency_alerts
827
+ .len()
828
+ > 10
829
+ {
830
+ app.state
831
+ .portal_monitoring
832
+ .dependency_alerts
833
+ .pop_front();
834
+ }
835
+ }
836
+ }
837
+ app.state.portal_monitoring.last_dependency_status =
838
+ Some(dependency_status);
452
839
  lines.push(String::new());
453
- if let Some(arr) = obj.get("healthLines").and_then(|a| a.as_array()) {
840
+ if let Some(arr) =
841
+ obj.get("healthLines").and_then(|a| a.as_array())
842
+ {
454
843
  for v in arr {
455
844
  if let Some(s) = v.as_str() {
456
845
  lines.push(s.to_string());
@@ -463,7 +852,9 @@ fn main() -> Result<()> {
463
852
  .to_string(),
464
853
  );
465
854
  lines.push(String::new());
466
- if let Some(arr) = obj.get("statsLines").and_then(|a| a.as_array()) {
855
+ if let Some(arr) =
856
+ obj.get("statsLines").and_then(|a| a.as_array())
857
+ {
467
858
  for v in arr {
468
859
  if let Some(s) = v.as_str() {
469
860
  lines.push(s.to_string());
@@ -471,8 +862,13 @@ fn main() -> Result<()> {
471
862
  }
472
863
  }
473
864
  lines.push(String::new());
474
- lines.push("Top HTTP routes (from http_requests_total labels)".to_string());
475
- if let Some(arr) = obj.get("routeLines").and_then(|a| a.as_array()) {
865
+ lines.push(
866
+ "Top HTTP routes (from http_requests_total labels)"
867
+ .to_string(),
868
+ );
869
+ if let Some(arr) =
870
+ obj.get("routeLines").and_then(|a| a.as_array())
871
+ {
476
872
  if !arr.is_empty() {
477
873
  lines.push(
478
874
  " (method route) count".to_string(),
@@ -490,11 +886,21 @@ fn main() -> Result<()> {
490
886
  }
491
887
  } else {
492
888
  lines.push(
493
- " (no route breakdown in response.)".to_string(),
889
+ " (no route breakdown in response.)"
890
+ .to_string(),
494
891
  );
495
892
  }
496
- app.state.portal_monitoring.last_updated = Some(snapshot);
893
+ app.state.portal_monitoring.last_updated =
894
+ Some(snapshot);
497
895
  app.state.portal_monitoring.content_lines = lines;
896
+ app.state.portal_monitoring.section_overrides.clear();
897
+ app.state.portal_monitoring.section_refresh_loading =
898
+ None;
899
+ app.state.portal_monitoring.logs_fetch_loading = false;
900
+ app.state.portal_monitoring.metrics_drill = None;
901
+ app.state.portal_monitoring.metrics_drill_lines.clear();
902
+ app.state.portal_monitoring.metrics_drill_loading =
903
+ false;
498
904
  app.state.portal_monitoring.error = None;
499
905
  app.state.portal_monitoring.scroll_offset = 0;
500
906
  } else {
@@ -507,41 +913,55 @@ fn main() -> Result<()> {
507
913
  // Handle gateway.connect response (Step 7.5)
508
914
  else if let Some(connected) = obj.get("connected") {
509
915
  if connected.as_bool().unwrap_or(false) {
510
- if Some(&resp.id) == app.state.pending_gateway_connect_id.as_ref() {
916
+ if Some(&resp.id)
917
+ == app.state.pending_gateway_connect_id.as_ref()
918
+ {
511
919
  app.state.pending_gateway_connect_id = None;
512
-
920
+
513
921
  use crate::app::OperationMode;
514
922
  app.state.operation_mode = OperationMode::Connected;
515
-
516
- let url = obj.get("url")
923
+
924
+ let url = obj
925
+ .get("url")
517
926
  .and_then(|v| v.as_str())
518
927
  .unwrap_or("unknown");
519
-
928
+
520
929
  app.state.gateway_url = Some(url.to_string());
521
- app.state.connection_portal.last_successful_url = Some(url.to_string());
930
+ app.state.connection_portal.last_successful_url =
931
+ Some(url.to_string());
522
932
  app.state.connection_portal.finish_connecting();
523
- app.state.connection_portal.connection_success = true; // Phase 1.3: Set success flag
524
- // Dashboard "Demo Mode / NET" used `connected`, which was never set — align with Gateway link
933
+ app.state.connection_portal.connection_success =
934
+ true; // Phase 1.3: Set success flag
935
+ // Dashboard "Demo Mode / NET" used `connected`, which was never set — align with Gateway link
525
936
  app.state.connected = true;
526
-
937
+
527
938
  // Parse activity log if present
528
939
  if let Some(activity_log) = obj.get("activityLog") {
529
- if let Some(log_array) = activity_log.as_array() {
530
- app.state.connection_portal.activity_log.clear();
940
+ if let Some(log_array) = activity_log.as_array()
941
+ {
942
+ app.state
943
+ .connection_portal
944
+ .activity_log
945
+ .clear();
531
946
  for entry in log_array {
532
- if let Some(entry_obj) = entry.as_object() {
533
- let timestamp = entry_obj.get("timestamp")
947
+ if let Some(entry_obj) =
948
+ entry.as_object()
949
+ {
950
+ let timestamp = entry_obj
951
+ .get("timestamp")
534
952
  .and_then(|v| v.as_str())
535
953
  .unwrap_or("")
536
954
  .to_string();
537
- let level_str = entry_obj.get("level")
955
+ let level_str = entry_obj
956
+ .get("level")
538
957
  .and_then(|v| v.as_str())
539
958
  .unwrap_or("info");
540
- let message = entry_obj.get("message")
959
+ let message = entry_obj
960
+ .get("message")
541
961
  .and_then(|v| v.as_str())
542
962
  .unwrap_or("")
543
963
  .to_string();
544
-
964
+
545
965
  use crate::app::LogLevel;
546
966
  let level = match level_str {
547
967
  "success" => LogLevel::Success,
@@ -549,36 +969,55 @@ fn main() -> Result<()> {
549
969
  "error" => LogLevel::Error,
550
970
  _ => LogLevel::Info,
551
971
  };
552
-
553
- app.state.connection_portal.add_log_entry(timestamp, level, message);
972
+
973
+ app.state
974
+ .connection_portal
975
+ .add_log_entry(
976
+ timestamp, level, message,
977
+ );
554
978
  }
555
979
  }
556
980
  }
557
981
  }
558
-
982
+
559
983
  // Phase 1.2: Prominent connection success notification
560
- app.add_log("═══════════════════════════════════════════".to_string());
984
+ app.add_log(
985
+ "═══════════════════════════════════════════"
986
+ .to_string(),
987
+ );
561
988
  app.add_log(format!("✅ CONNECTED TO GATEWAY"));
562
989
  app.add_log(format!(" URL: {}", url));
563
990
  app.add_log(format!(" Status: Connected"));
564
991
  app.add_log(" Note: connect checks /health + /ready; Shield/Sentinel run on the Gateway during jobs.".to_string());
565
- app.add_log("═══════════════════════════════════════════".to_string());
566
- app.add_log(format!("[MODE] Switched to CONNECTED mode"));
567
-
992
+ app.add_log(
993
+ "═══════════════════════════════════════════"
994
+ .to_string(),
995
+ );
996
+ app.add_log(format!(
997
+ "[MODE] Switched to CONNECTED mode"
998
+ ));
999
+
568
1000
  // Phase 2.1: Immediately verify connection with health check
569
- app.add_log(format!("[GATEWAY] Verifying connection health..."));
1001
+ app.add_log(format!(
1002
+ "[GATEWAY] Verifying connection health..."
1003
+ ));
570
1004
  if let Some(ref ws) = ws_client {
571
- if let Ok(health_id) = ws.send_command("gateway.health", None) {
572
- app.state.pending_gateway_health_id = Some(health_id);
1005
+ if let Ok(health_id) =
1006
+ ws.send_command("gateway.health", None)
1007
+ {
1008
+ app.state.pending_gateway_health_id =
1009
+ Some(health_id);
573
1010
  }
574
1011
  // Refresh posture / NET labels from CLI (matches gateway-backed system.status)
575
1012
  let _ = ws.send_command("system.status", None);
576
1013
  }
577
-
1014
+
578
1015
  // CRITICAL: Request render after state change to update UI
579
1016
  // CRITICAL: Use immediate render for instant success display
580
- app.request_immediate_render("gateway_connect_success");
581
-
1017
+ app.request_immediate_render(
1018
+ "gateway_connect_success",
1019
+ );
1020
+
582
1021
  // Phase 1.3: Portal will show success state (user closes with ESC)
583
1022
  // Don't auto-close - let user see success message
584
1023
  }
@@ -590,11 +1029,20 @@ fn main() -> Result<()> {
590
1029
  app.state.connected = false;
591
1030
  app.state.gateway_healthy = false;
592
1031
  app.state.gateway_db_summary = None;
593
- app.add_log(format!("✓ [{}] Disconnected from Gateway", short_id));
1032
+ app.add_log(format!(
1033
+ "✓ [{}] Disconnected from Gateway",
1034
+ short_id
1035
+ ));
594
1036
  app.add_log(format!("[MODE] Switched to LOCAL mode"));
595
1037
  app.state.pending_gateway_observability_id = None;
1038
+ app.state.pending_monitoring_refresh_id = None;
1039
+ app.state.pending_monitoring_logs_id = None;
1040
+ app.state.pending_monitoring_drill_id = None;
596
1041
  app.state.portal_observability_last_poll = None;
597
- app.state.portal_monitoring = crate::app::PortalMonitoringState::default();
1042
+ app.state.portal_monitoring =
1043
+ crate::app::PortalMonitoringState::default();
1044
+ app.state.advanced_monitoring =
1045
+ crate::app::AdvancedMonitoringState::default();
598
1046
  if let Some(ref ws) = ws_client {
599
1047
  let _ = ws.send_command("system.status", None);
600
1048
  }
@@ -602,18 +1050,33 @@ fn main() -> Result<()> {
602
1050
  }
603
1051
  // Handle setup.detect response
604
1052
  else if let Some(local_bundle) = obj.get("localBundle") {
605
- if Some(&resp.id) == app.state.pending_setup_detect_id.as_ref() {
1053
+ if Some(&resp.id)
1054
+ == app.state.pending_setup_detect_id.as_ref()
1055
+ {
606
1056
  app.state.pending_setup_detect_id = None;
607
1057
  app.state.setup_portal.detecting = false;
608
1058
  app.state.setup_portal.detecting_since = None;
609
-
610
- use crate::app::{DetectionResult, BundleStatus, CloudStatus};
611
-
612
- let bundle_status = if let Some(bundle_obj) = local_bundle.as_object() {
1059
+
1060
+ use crate::app::{
1061
+ BundleStatus, CloudStatus, DetectionResult,
1062
+ };
1063
+
1064
+ let bundle_status = if let Some(bundle_obj) =
1065
+ local_bundle.as_object()
1066
+ {
613
1067
  BundleStatus {
614
- available: bundle_obj.get("available").and_then(|v| v.as_bool()).unwrap_or(false),
615
- running: bundle_obj.get("running").and_then(|v| v.as_bool()).unwrap_or(false),
616
- path: bundle_obj.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()),
1068
+ available: bundle_obj
1069
+ .get("available")
1070
+ .and_then(|v| v.as_bool())
1071
+ .unwrap_or(false),
1072
+ running: bundle_obj
1073
+ .get("running")
1074
+ .and_then(|v| v.as_bool())
1075
+ .unwrap_or(false),
1076
+ path: bundle_obj
1077
+ .get("path")
1078
+ .and_then(|v| v.as_str())
1079
+ .map(|s| s.to_string()),
617
1080
  }
618
1081
  } else {
619
1082
  app.state.setup_portal.error = Some(
@@ -625,33 +1088,54 @@ fn main() -> Result<()> {
625
1088
  path: None,
626
1089
  }
627
1090
  };
628
-
629
- let cloud_obj = obj.get("cloudServer").and_then(|v| v.as_object());
1091
+
1092
+ let cloud_obj =
1093
+ obj.get("cloudServer").and_then(|v| v.as_object());
630
1094
  let cloud_status = CloudStatus {
631
- available: cloud_obj.and_then(|o| o.get("available")).and_then(|v| v.as_bool()).unwrap_or(false),
632
- status: cloud_obj.and_then(|o| o.get("status")).and_then(|v| v.as_str()).unwrap_or("Unknown").to_string(),
1095
+ available: cloud_obj
1096
+ .and_then(|o| o.get("available"))
1097
+ .and_then(|v| v.as_bool())
1098
+ .unwrap_or(false),
1099
+ status: cloud_obj
1100
+ .and_then(|o| o.get("status"))
1101
+ .and_then(|v| v.as_str())
1102
+ .unwrap_or("Unknown")
1103
+ .to_string(),
633
1104
  };
634
-
635
- app.state.setup_portal.detection_result = Some(DetectionResult {
636
- local_bundle: bundle_status,
637
- cloud_server: cloud_status,
638
- });
639
-
640
- app.add_log("[SETUP] Gateway detection complete".to_string());
1105
+
1106
+ app.state.setup_portal.detection_result =
1107
+ Some(DetectionResult {
1108
+ local_bundle: bundle_status,
1109
+ cloud_server: cloud_status,
1110
+ });
1111
+
1112
+ app.add_log(
1113
+ "[SETUP] Gateway detection complete".to_string(),
1114
+ );
641
1115
  }
642
1116
  }
643
1117
  // Phase 2.1: Handle gateway.health response
644
1118
  else if let Some(healthy) = obj.get("healthy") {
645
- if Some(&resp.id) == app.state.pending_gateway_health_id.as_ref() {
1119
+ if Some(&resp.id)
1120
+ == app.state.pending_gateway_health_id.as_ref()
1121
+ {
646
1122
  app.state.pending_gateway_health_id = None;
647
-
1123
+
648
1124
  let is_healthy = healthy.as_bool().unwrap_or(false);
649
- let latency = obj.get("latency").and_then(|v| v.as_u64()).unwrap_or(0);
650
- let deps_ready = obj.get("ready").and_then(|v| v.as_bool());
1125
+ let latency = obj
1126
+ .get("latency")
1127
+ .and_then(|v| v.as_u64())
1128
+ .unwrap_or(0);
1129
+ let deps_ready =
1130
+ obj.get("ready").and_then(|v| v.as_bool());
651
1131
 
652
1132
  let mut db_summary: Option<String> = None;
653
- if let Some(h) = obj.get("health").and_then(|v| v.as_object()) {
654
- if let Some(fr) = h.get("fourRunrDb").and_then(|v| v.as_object()) {
1133
+ if let Some(h) =
1134
+ obj.get("health").and_then(|v| v.as_object())
1135
+ {
1136
+ if let Some(fr) =
1137
+ h.get("fourRunrDb").and_then(|v| v.as_object())
1138
+ {
655
1139
  let mode = fr
656
1140
  .get("mode")
657
1141
  .and_then(|v| v.as_str())
@@ -689,15 +1173,20 @@ fn main() -> Result<()> {
689
1173
  app.state.gateway_healthy = false;
690
1174
  app.state.gateway_db_summary = None;
691
1175
  app.state.connection_portal.finish_connecting();
692
- let error = obj.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
693
- app.add_log(format!("⚠ [{}] Gateway health check failed: {} ({}ms)", short_id, error, latency));
1176
+ let error = obj
1177
+ .get("error")
1178
+ .and_then(|v| v.as_str())
1179
+ .unwrap_or("Unknown error");
1180
+ app.add_log(format!(
1181
+ "⚠ [{}] Gateway health check failed: {} ({}ms)",
1182
+ short_id, error, latency
1183
+ ));
694
1184
  }
695
-
1185
+
696
1186
  // CRITICAL: Request render after health state change
697
1187
  app.request_render("gateway_health_update");
698
1188
  }
699
- }
700
- else {
1189
+ } else {
701
1190
  app.add_log(format!("✓ [{}] Success", short_id));
702
1191
  }
703
1192
  } else {
@@ -708,41 +1197,60 @@ fn main() -> Result<()> {
708
1197
  }
709
1198
  } else {
710
1199
  let short_id = &resp.id[resp.id.len().saturating_sub(8)..];
711
- let error_msg = resp.payload.error.unwrap_or_else(|| "Unknown error".to_string());
712
-
1200
+ let error_msg = resp
1201
+ .payload
1202
+ .error
1203
+ .unwrap_or_else(|| "Unknown error".to_string());
1204
+
713
1205
  // Check if this is a response to agent.create command (Step 5.1)
714
1206
  if Some(&resp.id) == app.state.pending_agent_create_id.as_ref() {
715
1207
  app.state.pending_agent_create_id = None;
716
- app.add_log(format!("✗ [{}] Agent creation failed: {}", short_id, error_msg));
1208
+ app.add_log(format!(
1209
+ "✗ [{}] Agent creation failed: {}",
1210
+ short_id, error_msg
1211
+ ));
717
1212
  }
718
1213
  // Check if this is a response to agent.delete command (Step 5.5)
719
1214
  else if Some(&resp.id) == app.state.pending_agent_delete_id.as_ref() {
720
1215
  app.state.pending_agent_delete_id = None;
721
- app.add_log(format!("✗ [{}] Agent deletion failed: {}", short_id, error_msg));
722
- }
723
- else if Some(&resp.id) == app.state.pending_run_list_id.as_ref() {
1216
+ app.add_log(format!(
1217
+ "✗ [{}] Agent deletion failed: {}",
1218
+ short_id, error_msg
1219
+ ));
1220
+ } else if Some(&resp.id) == app.state.pending_run_list_id.as_ref() {
724
1221
  app.state.pending_run_list_id = None;
725
1222
  app.state.run_manager.loading = false;
726
- app.add_log(format!("✗ [{}] run.list failed: {}", short_id, error_msg));
727
- }
728
- else if Some(&resp.id) == app.state.pending_run_get_id.as_ref() {
1223
+ app.add_log(format!(
1224
+ "✗ [{}] run.list failed: {}",
1225
+ short_id, error_msg
1226
+ ));
1227
+ } else if Some(&resp.id) == app.state.pending_run_get_id.as_ref() {
729
1228
  app.state.pending_run_get_id = None;
730
- app.add_log(format!("✗ [{}] run.get failed: {}", short_id, error_msg));
731
- }
732
- else if Some(&resp.id) == app.state.pending_run_cancel_id.as_ref() {
1229
+ app.add_log(format!(
1230
+ "✗ [{}] run.get failed: {}",
1231
+ short_id, error_msg
1232
+ ));
1233
+ } else if Some(&resp.id) == app.state.pending_run_cancel_id.as_ref() {
733
1234
  app.state.pending_run_cancel_id = None;
734
- app.add_log(format!("✗ [{}] run.cancel failed: {}", short_id, error_msg));
735
- }
736
- else if Some(&resp.id) == app.state.pending_run_quick_id.as_ref() {
1235
+ app.add_log(format!(
1236
+ "✗ [{}] run.cancel failed: {}",
1237
+ short_id, error_msg
1238
+ ));
1239
+ } else if Some(&resp.id) == app.state.pending_run_quick_id.as_ref() {
737
1240
  app.state.pending_run_quick_id = None;
738
- app.add_log(format!("✗ [{}] run.quick failed: {}", short_id, error_msg));
1241
+ app.add_log(format!(
1242
+ "✗ [{}] run.quick failed: {}",
1243
+ short_id, error_msg
1244
+ ));
739
1245
  }
740
1246
  // Check if this is a response to gateway.connect command (Step 7.5)
741
- else if Some(&resp.id) == app.state.pending_gateway_connect_id.as_ref() {
1247
+ else if Some(&resp.id)
1248
+ == app.state.pending_gateway_connect_id.as_ref()
1249
+ {
742
1250
  app.state.pending_gateway_connect_id = None;
743
1251
  app.state.connection_portal.finish_connecting();
744
1252
  app.state.connection_portal.error = Some(error_msg.clone());
745
-
1253
+
746
1254
  // Parse activity log from error response if present
747
1255
  if let Some(data_value) = &resp.payload.data {
748
1256
  if let Some(obj) = data_value.as_object() {
@@ -751,18 +1259,21 @@ fn main() -> Result<()> {
751
1259
  app.state.connection_portal.activity_log.clear();
752
1260
  for entry in log_array {
753
1261
  if let Some(entry_obj) = entry.as_object() {
754
- let timestamp = entry_obj.get("timestamp")
1262
+ let timestamp = entry_obj
1263
+ .get("timestamp")
755
1264
  .and_then(|v| v.as_str())
756
1265
  .unwrap_or("")
757
1266
  .to_string();
758
- let level_str = entry_obj.get("level")
1267
+ let level_str = entry_obj
1268
+ .get("level")
759
1269
  .and_then(|v| v.as_str())
760
1270
  .unwrap_or("info");
761
- let message = entry_obj.get("message")
1271
+ let message = entry_obj
1272
+ .get("message")
762
1273
  .and_then(|v| v.as_str())
763
1274
  .unwrap_or("")
764
1275
  .to_string();
765
-
1276
+
766
1277
  use crate::app::LogLevel;
767
1278
  let level = match level_str {
768
1279
  "success" => LogLevel::Success,
@@ -770,17 +1281,22 @@ fn main() -> Result<()> {
770
1281
  "error" => LogLevel::Error,
771
1282
  _ => LogLevel::Info,
772
1283
  };
773
-
774
- app.state.connection_portal.add_log_entry(timestamp, level, message);
1284
+
1285
+ app.state.connection_portal.add_log_entry(
1286
+ timestamp, level, message,
1287
+ );
775
1288
  }
776
1289
  }
777
1290
  }
778
1291
  }
779
1292
  }
780
1293
  }
781
-
782
- app.add_log(format!("✗ [{}] Gateway connection failed: {}", short_id, error_msg));
783
-
1294
+
1295
+ app.add_log(format!(
1296
+ "✗ [{}] Gateway connection failed: {}",
1297
+ short_id, error_msg
1298
+ ));
1299
+
784
1300
  // CRITICAL: Use immediate render for instant error display
785
1301
  app.request_immediate_render("gateway_connect_error");
786
1302
  }
@@ -790,24 +1306,69 @@ fn main() -> Result<()> {
790
1306
  app.state.setup_portal.detecting = false;
791
1307
  app.state.setup_portal.detecting_since = None;
792
1308
  app.state.setup_portal.error = Some(error_msg.clone());
793
- app.add_log(format!("✗ [{}] Setup detection failed: {}", short_id, error_msg));
1309
+ app.add_log(format!(
1310
+ "✗ [{}] Setup detection failed: {}",
1311
+ short_id, error_msg
1312
+ ));
794
1313
  }
795
1314
  // Phase 2.1: Check if this is a response to gateway.health command
796
- else if Some(&resp.id) == app.state.pending_gateway_health_id.as_ref() {
1315
+ else if Some(&resp.id) == app.state.pending_gateway_health_id.as_ref()
1316
+ {
797
1317
  app.state.pending_gateway_health_id = None;
798
1318
  app.state.gateway_healthy = false;
799
1319
  app.state.gateway_db_summary = None;
800
- app.add_log(format!("⚠ [{}] Gateway health check failed: {}", short_id, error_msg));
801
-
1320
+ app.add_log(format!(
1321
+ "⚠ [{}] Gateway health check failed: {}",
1322
+ short_id, error_msg
1323
+ ));
1324
+
802
1325
  // CRITICAL: Request render after health state change
803
1326
  app.request_render("gateway_health_error");
804
- }
805
- else if Some(&resp.id) == app.state.pending_gateway_observability_id.as_ref() {
1327
+ } else if Some(&resp.id)
1328
+ == app.state.pending_gateway_observability_id.as_ref()
1329
+ {
806
1330
  app.state.pending_gateway_observability_id = None;
807
1331
  app.state.portal_monitoring.loading = false;
808
1332
  app.state.portal_observability_last_poll = Some(Instant::now());
809
1333
  app.state.portal_monitoring.error = Some(error_msg.clone());
810
1334
  app.request_render("portal_observability_err");
1335
+ } else if Some(&resp.id)
1336
+ == app.state.pending_monitoring_refresh_id.as_ref()
1337
+ {
1338
+ app.state.pending_monitoring_refresh_id = None;
1339
+ app.state.portal_monitoring.section_refresh_loading = None;
1340
+ app.state.portal_monitoring.error =
1341
+ Some(format!("monitoring.refresh failed: {}", error_msg));
1342
+ app.add_log(format!(
1343
+ "✗ [{}] monitoring.refresh failed: {}",
1344
+ short_id, error_msg
1345
+ ));
1346
+ app.request_render("monitoring_refresh_err");
1347
+ } else if Some(&resp.id)
1348
+ == app.state.pending_monitoring_logs_id.as_ref()
1349
+ {
1350
+ app.state.pending_monitoring_logs_id = None;
1351
+ app.state.portal_monitoring.logs_fetch_loading = false;
1352
+ app.state.portal_monitoring.error =
1353
+ Some(format!("monitoring.logs failed: {}", error_msg));
1354
+ app.add_log(format!(
1355
+ "✗ [{}] monitoring.logs failed: {}",
1356
+ short_id, error_msg
1357
+ ));
1358
+ app.request_render("monitoring_logs_err");
1359
+ } else if Some(&resp.id)
1360
+ == app.state.pending_monitoring_drill_id.as_ref()
1361
+ {
1362
+ app.state.pending_monitoring_drill_id = None;
1363
+ app.state.portal_monitoring.metrics_drill_loading = false;
1364
+ app.state.portal_monitoring.section_refresh_loading = None;
1365
+ app.state.portal_monitoring.error =
1366
+ Some(format!("monitoring.drill failed: {}", error_msg));
1367
+ app.add_log(format!(
1368
+ "✗ [{}] monitoring.drill failed: {}",
1369
+ short_id, error_msg
1370
+ ));
1371
+ app.request_render("monitoring_drill_err");
811
1372
  }
812
1373
  // Generic error handling
813
1374
  else {
@@ -823,12 +1384,10 @@ fn main() -> Result<()> {
823
1384
  } else {
824
1385
  ""
825
1386
  };
826
-
1387
+
827
1388
  app.add_log(format!(
828
1389
  "✗ [{}]{} Error: {}",
829
- short_id,
830
- command_hint,
831
- error_msg
1390
+ short_id, command_hint, error_msg
832
1391
  ));
833
1392
  }
834
1393
  }
@@ -856,6 +1415,9 @@ fn main() -> Result<()> {
856
1415
  if app.state.navigation.current_screen() == &Screen::PortalMonitoring
857
1416
  && app.state.operation_mode == OperationMode::Connected
858
1417
  && app.state.pending_gateway_observability_id.is_none()
1418
+ && app.state.pending_monitoring_refresh_id.is_none()
1419
+ && app.state.pending_monitoring_logs_id.is_none()
1420
+ && app.state.pending_monitoring_drill_id.is_none()
859
1421
  {
860
1422
  let interval = Duration::from_secs(5);
861
1423
  let due = app
@@ -869,7 +1431,7 @@ fn main() -> Result<()> {
869
1431
  }
870
1432
  }
871
1433
  }
872
-
1434
+
873
1435
  // ┌─────────────────────────────────────────────────────────┐
874
1436
  // │ STEP 3: RENDER (conditional - only when needed) │
875
1437
  // └─────────────────────────────────────────────────────────┘
@@ -892,24 +1454,28 @@ fn main() -> Result<()> {
892
1454
  | crate::screens::Screen::SetupPortal
893
1455
  | crate::screens::Screen::PortalMonitoring
894
1456
  );
895
-
1457
+
896
1458
  // Clear only on screen switch (but not when switching TO Setup Portal; we'll clear after autoresize)
897
1459
  let is_switching = match (&previous_screen, &current_screen) {
898
1460
  (Some(prev), current) => prev != current,
899
1461
  (None, _) => true,
900
1462
  };
901
- let switching_to_setup_portal = is_switching && matches!(current_screen, crate::screens::Screen::SetupPortal);
1463
+ let switching_to_setup_portal =
1464
+ is_switching && matches!(current_screen, crate::screens::Screen::SetupPortal);
902
1465
 
903
1466
  if is_switching && !switching_to_setup_portal {
904
1467
  #[cfg(debug_assertions)]
905
- eprintln!("[MAIN] SCREEN SWITCH: {:?} -> {:?} (clearing)", previous_screen, current_screen);
1468
+ eprintln!(
1469
+ "[MAIN] SCREEN SWITCH: {:?} -> {:?} (clearing)",
1470
+ previous_screen, current_screen
1471
+ );
906
1472
  debug_log::log_screen(
907
1473
  &format!("{:?}", previous_screen.as_ref().unwrap_or(&current_screen)),
908
1474
  &format!("{:?}", current_screen),
909
1475
  );
910
1476
  terminal.clear()?;
911
1477
  }
912
-
1478
+
913
1479
  // Connection + Setup portals: autoresize every frame (windowed CMD/conhost buffer vs viewport drift).
914
1480
  // Clear on Setup entry (generic switch clear is skipped for Setup); clear when terminal dimensions
915
1481
  // change while either portal is open. See docs/SETUP-PORTAL-WINDOWED-FIX-PLAN.md.
@@ -945,7 +1511,7 @@ fn main() -> Result<()> {
945
1511
  standalone_portal_last_terminal_dims = Some(dims);
946
1512
  }
947
1513
  }
948
-
1514
+
949
1515
  let render_start = Instant::now();
950
1516
  terminal.draw(|f| app.render(f))?;
951
1517
  let render_duration = render_start.elapsed().as_millis() as u64;
@@ -953,11 +1519,11 @@ fn main() -> Result<()> {
953
1519
  if on_standalone_portal {
954
1520
  debug_log::log_flicker_debug("DRAW_DONE", &format!("ms={}", render_duration));
955
1521
  }
956
-
1522
+
957
1523
  // Update previous screen tracking
958
1524
  previous_screen = Some(current_screen);
959
1525
  }
960
-
1526
+
961
1527
  // Setup Portal: if detection has been running too long, unstick so user can ESC or retry
962
1528
  if app.state.navigation.current_screen() == &screens::Screen::SetupPortal
963
1529
  && app.state.setup_portal.detecting
@@ -966,13 +1532,14 @@ fn main() -> Result<()> {
966
1532
  if since.elapsed() >= Duration::from_secs(8) {
967
1533
  app.state.setup_portal.detecting = false;
968
1534
  app.state.setup_portal.detecting_since = None;
969
- app.state.setup_portal.error = Some("Detection timed out. Press ESC to close or try again.".to_string());
1535
+ app.state.setup_portal.error =
1536
+ Some("Detection timed out. Press ESC to close or try again.".to_string());
970
1537
  app.add_log("[SETUP] Detection timed out".to_string());
971
1538
  app.request_immediate_render("setup_detect_timeout");
972
1539
  }
973
1540
  }
974
1541
  }
975
-
1542
+
976
1543
  // ┌─────────────────────────────────────────────────────────┐
977
1544
  // │ STEP 4: CHECK FOR INPUT (non-blocking with timeout) │
978
1545
  // └─────────────────────────────────────────────────────────┘
@@ -1033,25 +1600,20 @@ fn main() -> Result<()> {
1033
1600
  }
1034
1601
  }
1035
1602
  // If no input, loop continues immediately - animations keep running!
1036
-
1603
+
1037
1604
  // Check for pending agent deletion (Step 5.5)
1038
1605
  if app.state.agent_delete_requested {
1039
1606
  app.state.agent_delete_requested = false;
1040
1607
  app.delete_selected_agent(&ws_client);
1041
1608
  }
1042
-
1609
+
1043
1610
  // Check for IO updates (non-blocking)
1044
1611
  io_handler.update(&mut app).ok();
1045
1612
  }
1046
1613
 
1047
1614
  // Restore terminal
1048
1615
  disable_raw_mode()?;
1049
- execute!(
1050
- terminal.backend_mut(),
1051
- cursor::Show,
1052
- LeaveAlternateScreen
1053
- )?;
1616
+ execute!(terminal.backend_mut(), cursor::Show, LeaveAlternateScreen)?;
1054
1617
 
1055
1618
  Ok(())
1056
1619
  }
1057
-