4runr-os 2.10.75 → 2.10.76

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.
@@ -838,16 +838,16 @@ impl App {
838
838
  }
839
839
 
840
840
  pub fn open_shield_dashboard(&mut self, ws: Option<&WebSocketClient>) {
841
- self.state.pending_shield_load_id = None;
842
841
  self.push_overlay(Screen::ShieldDashboard);
843
842
  self.state
844
843
  .logs
845
844
  .push_back("[NAV] Opening Shield (production safety layer)...".into());
846
845
  if self.state.operation_mode == OperationMode::Connected {
847
846
  if let Some(ws) = ws {
848
- self.begin_shield_load_request(ws);
847
+ self.begin_shield_load_request(ws, false);
849
848
  }
850
849
  } else {
850
+ self.state.shield_dashboard.loading = false;
851
851
  self.state.shield_dashboard.error = Some(
852
852
  "Connect to Gateway first (connect portal).".to_string(),
853
853
  );
@@ -855,20 +855,39 @@ impl App {
855
855
  self.request_immediate_render("open_shield_dashboard");
856
856
  }
857
857
 
858
- pub fn begin_shield_load_request(&mut self, ws: &WebSocketClient) {
858
+ pub fn begin_shield_load_request(&mut self, ws: &WebSocketClient, force: bool) {
859
859
  if self.state.operation_mode != OperationMode::Connected {
860
860
  return;
861
861
  }
862
- if self.state.pending_shield_load_id.is_some() {
862
+ if !force && self.state.pending_shield_load_id.is_some() {
863
863
  return;
864
864
  }
865
- self.state.shield_dashboard.loading = true;
865
+ if force {
866
+ self.state.pending_shield_load_id = None;
867
+ }
868
+ self.state.shield_dashboard.mark_loading();
866
869
  self.state.shield_dashboard.error = None;
867
870
  if let Ok(id) = ws.send_command("shield.load", None) {
868
871
  self.state.pending_shield_load_id = Some(id);
869
872
  } else {
870
- self.state.shield_dashboard.loading = false;
873
+ self.state.shield_dashboard.fail_loading(
874
+ "Failed to send shield.load to CLI.".to_string(),
875
+ );
876
+ }
877
+ }
878
+
879
+ pub fn open_run_from_shield_block(&mut self, ws: Option<&WebSocketClient>, run_id: &str) {
880
+ self.push_overlay(Screen::RunManager);
881
+ self.state.run_manager.detail_run_id = Some(run_id.to_string());
882
+ self.add_log(format!(
883
+ "[SHIELD] Opening blocked run {} in Run Manager",
884
+ &run_id[run_id.len().saturating_sub(8)..]
885
+ ));
886
+ if let Some(ws) = ws {
887
+ self.begin_run_list_request(ws, false);
888
+ self.begin_run_get_request(ws, run_id);
871
889
  }
890
+ self.request_immediate_render("shield_open_run");
872
891
  }
873
892
 
874
893
  pub fn open_sentinel_config(&mut self, ws: Option<&WebSocketClient>) {
@@ -2652,6 +2671,7 @@ impl App {
2652
2671
  key: KeyEvent,
2653
2672
  ws_client: Option<&WebSocketClient>,
2654
2673
  ) -> anyhow::Result<bool> {
2674
+ use crate::ui::shield_dashboard::ShieldTab;
2655
2675
  use crossterm::event::KeyModifiers;
2656
2676
 
2657
2677
  if key.modifiers.contains(KeyModifiers::CONTROL) {
@@ -2662,14 +2682,84 @@ impl App {
2662
2682
  }
2663
2683
 
2664
2684
  match key.code {
2685
+ KeyCode::Tab => {
2686
+ self.state.shield_dashboard.tab = self.state.shield_dashboard.tab.next();
2687
+ self.request_render("shield_tab");
2688
+ }
2689
+ KeyCode::BackTab => {
2690
+ self.state.shield_dashboard.tab = self.state.shield_dashboard.tab.prev();
2691
+ self.request_render("shield_tab_back");
2692
+ }
2693
+ KeyCode::Left => {
2694
+ self.state.shield_dashboard.tab = self.state.shield_dashboard.tab.prev();
2695
+ self.request_render("shield_tab_left");
2696
+ }
2697
+ KeyCode::Right => {
2698
+ self.state.shield_dashboard.tab = self.state.shield_dashboard.tab.next();
2699
+ self.request_render("shield_tab_right");
2700
+ }
2701
+ KeyCode::Up => {
2702
+ if self.state.shield_dashboard.tab == ShieldTab::Enforcement
2703
+ && self.state.shield_dashboard.selected_block_index > 0
2704
+ {
2705
+ self.state.shield_dashboard.selected_block_index -= 1;
2706
+ self.request_render("shield_block_up");
2707
+ }
2708
+ }
2709
+ KeyCode::Down => {
2710
+ if self.state.shield_dashboard.tab == ShieldTab::Enforcement {
2711
+ let max = self.state.shield_dashboard.recent_blocks.len();
2712
+ if max > 0 && self.state.shield_dashboard.selected_block_index + 1 < max {
2713
+ self.state.shield_dashboard.selected_block_index += 1;
2714
+ self.request_render("shield_block_down");
2715
+ }
2716
+ }
2717
+ }
2718
+ KeyCode::Enter => {
2719
+ if self.state.shield_dashboard.tab == ShieldTab::Enforcement {
2720
+ if let Some(row) = self
2721
+ .state
2722
+ .shield_dashboard
2723
+ .recent_blocks
2724
+ .get(self.state.shield_dashboard.selected_block_index)
2725
+ {
2726
+ let run_id = row.id.clone();
2727
+ self.open_run_from_shield_block(ws_client, &run_id);
2728
+ }
2729
+ }
2730
+ }
2665
2731
  KeyCode::Char('r') | KeyCode::Char('R') => {
2666
2732
  if self.state.operation_mode == OperationMode::Connected {
2667
2733
  if let Some(ws) = ws_client {
2668
- self.begin_shield_load_request(ws);
2734
+ self.begin_shield_load_request(ws, true);
2669
2735
  }
2736
+ } else {
2737
+ self.state.shield_dashboard.fail_loading(
2738
+ "Connect to Gateway first (connect portal).".to_string(),
2739
+ );
2670
2740
  }
2671
2741
  self.request_render("shield_refresh");
2672
2742
  }
2743
+ KeyCode::Char('o') | KeyCode::Char('O') => {
2744
+ self.push_overlay(Screen::RunManager);
2745
+ if let Some(ws) = ws_client {
2746
+ self.begin_run_list_request(ws, false);
2747
+ }
2748
+ self.add_log("[SHIELD] Opened Run Manager".to_string());
2749
+ self.request_render("shield_open_runs");
2750
+ }
2751
+ KeyCode::Char('p') | KeyCode::Char('P') => {
2752
+ if self.state.operation_mode == OperationMode::Connected {
2753
+ self.state.navigation.navigate_to_base(Screen::PortalMonitoring);
2754
+ if let Some(ws) = ws_client {
2755
+ self.begin_portal_observability_request(ws);
2756
+ }
2757
+ self.add_log("[SHIELD] Opened Portal Monitoring".to_string());
2758
+ } else {
2759
+ self.add_log("[SHIELD] Connect to Gateway first.".to_string());
2760
+ }
2761
+ self.request_render("shield_open_monitoring");
2762
+ }
2673
2763
  KeyCode::Char('a') | KeyCode::Char('A') => {
2674
2764
  self.state.shield_dashboard.auto_refresh_enabled =
2675
2765
  !self.state.shield_dashboard.auto_refresh_enabled;
@@ -205,9 +205,26 @@ fn main() -> Result<()> {
205
205
  };
206
206
  if shield_poll {
207
207
  if let Some(ws) = &ws_client {
208
- app.begin_shield_load_request(ws);
208
+ app.begin_shield_load_request(ws, false);
209
209
  }
210
210
  }
211
+
212
+ // Loading timeout — never spin forever if CLI deduped or dropped a response
213
+ let loading_stuck = {
214
+ let sd = &app.state.shield_dashboard;
215
+ sd.loading
216
+ && sd
217
+ .loading_started
218
+ .map(|t| t.elapsed() >= std::time::Duration::from_secs(12))
219
+ .unwrap_or(false)
220
+ };
221
+ if loading_stuck {
222
+ app.state.shield_dashboard.fail_loading(
223
+ "Shield load timed out — press R to refresh.".to_string(),
224
+ );
225
+ app.state.pending_shield_load_id = None;
226
+ app.request_render("shield_load_timeout");
227
+ }
211
228
  }
212
229
 
213
230
  // Run Manager: live poll run.list every ~3s when enabled (no manual R)
@@ -1625,8 +1642,9 @@ fn main() -> Result<()> {
1625
1642
  ));
1626
1643
  } else if Some(&resp.id) == app.state.pending_shield_load_id.as_ref() {
1627
1644
  app.state.pending_shield_load_id = None;
1628
- app.state.shield_dashboard.loading = false;
1629
- app.state.shield_dashboard.error = Some(error_msg.clone());
1645
+ app.state
1646
+ .shield_dashboard
1647
+ .fail_loading(error_msg.clone());
1630
1648
  app.add_log(format!(
1631
1649
  "✗ [{}] shield.load failed: {}",
1632
1650
  short_id, error_msg
@@ -34,8 +34,44 @@ pub struct ShieldBlockRow {
34
34
  pub created_at: String,
35
35
  }
36
36
 
37
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+ pub enum ShieldTab {
39
+ Overview,
40
+ Enforcement,
41
+ Help,
42
+ }
43
+
44
+ impl ShieldTab {
45
+ pub fn label(self) -> &'static str {
46
+ match self {
47
+ ShieldTab::Overview => "Overview",
48
+ ShieldTab::Enforcement => "Enforcement",
49
+ ShieldTab::Help => "Help",
50
+ }
51
+ }
52
+
53
+ pub fn next(self) -> Self {
54
+ match self {
55
+ ShieldTab::Overview => ShieldTab::Enforcement,
56
+ ShieldTab::Enforcement => ShieldTab::Help,
57
+ ShieldTab::Help => ShieldTab::Overview,
58
+ }
59
+ }
60
+
61
+ pub fn prev(self) -> Self {
62
+ match self {
63
+ ShieldTab::Overview => ShieldTab::Help,
64
+ ShieldTab::Enforcement => ShieldTab::Overview,
65
+ ShieldTab::Help => ShieldTab::Enforcement,
66
+ }
67
+ }
68
+ }
69
+
37
70
  #[derive(Debug, Clone)]
38
71
  pub struct ShieldDashboardState {
72
+ pub tab: ShieldTab,
73
+ pub selected_block_index: usize,
74
+ pub loaded_once: bool,
39
75
  pub enabled: bool,
40
76
  pub mode: String,
41
77
  pub health_status: String,
@@ -51,6 +87,7 @@ pub struct ShieldDashboardState {
51
87
  pub warnings: Vec<String>,
52
88
  pub recent_blocks: Vec<ShieldBlockRow>,
53
89
  pub loading: bool,
90
+ pub loading_started: Option<std::time::Instant>,
54
91
  pub error: Option<String>,
55
92
  pub last_refresh: Option<std::time::Instant>,
56
93
  pub auto_refresh_enabled: bool,
@@ -60,6 +97,9 @@ pub struct ShieldDashboardState {
60
97
  impl Default for ShieldDashboardState {
61
98
  fn default() -> Self {
62
99
  Self {
100
+ tab: ShieldTab::Overview,
101
+ selected_block_index: 0,
102
+ loaded_once: false,
63
103
  enabled: false,
64
104
  mode: "off".to_string(),
65
105
  health_status: String::new(),
@@ -75,6 +115,7 @@ impl Default for ShieldDashboardState {
75
115
  warnings: Vec::new(),
76
116
  recent_blocks: Vec::new(),
77
117
  loading: false,
118
+ loading_started: None,
78
119
  error: None,
79
120
  last_refresh: None,
80
121
  auto_refresh_enabled: true,
@@ -94,7 +135,9 @@ impl ShieldDashboardState {
94
135
  recent: Vec<ShieldBlockRow>,
95
136
  ) {
96
137
  self.loading = false;
138
+ self.loading_started = None;
97
139
  self.error = None;
140
+ self.loaded_once = true;
98
141
  self.enabled = health.enabled;
99
142
  self.mode = health.mode.clone();
100
143
  self.health_status = health.status.clone();
@@ -109,8 +152,24 @@ impl ShieldDashboardState {
109
152
  self.rewrites_total = metrics.rewrites;
110
153
  self.warnings = warnings;
111
154
  self.recent_blocks = recent;
155
+ if self.selected_block_index >= self.recent_blocks.len() {
156
+ self.selected_block_index = 0;
157
+ }
112
158
  self.last_refresh = Some(std::time::Instant::now());
113
159
  }
160
+
161
+ pub fn mark_loading(&mut self) {
162
+ self.loading = true;
163
+ if self.loading_started.is_none() {
164
+ self.loading_started = Some(std::time::Instant::now());
165
+ }
166
+ }
167
+
168
+ pub fn fail_loading(&mut self, message: String) {
169
+ self.loading = false;
170
+ self.loading_started = None;
171
+ self.error = Some(message);
172
+ }
114
173
  }
115
174
 
116
175
  #[derive(Debug, Clone, Default)]
@@ -315,21 +374,31 @@ pub fn render(f: &mut Frame, state: &AppState) {
315
374
 
316
375
  use ratatui::layout::{Constraint, Direction, Layout};
317
376
 
377
+ let refresh_hint = if sd.loading {
378
+ " ↻"
379
+ } else {
380
+ ""
381
+ };
318
382
  let live = if sd.auto_refresh_enabled { " · LIVE" } else { "" };
319
383
  let header_title = format!(
320
- " 🛡️ Shield — {} · {} block(s) · {} mask(s){} ",
321
- sd.mode.to_uppercase(),
384
+ " 🛡️ Shield — {} · {} block(s) · {} mask(s){}{} ",
385
+ if sd.loaded_once {
386
+ sd.mode.to_uppercase()
387
+ } else {
388
+ "…".to_string()
389
+ },
322
390
  sd.blocks_total,
323
391
  sd.masks_total,
324
- live
392
+ live,
393
+ refresh_hint
325
394
  );
326
395
 
327
396
  let chunks = Layout::default()
328
397
  .direction(Direction::Vertical)
329
398
  .constraints([
330
399
  Constraint::Length(3),
331
- Constraint::Min(10),
332
- Constraint::Length(8),
400
+ Constraint::Length(3),
401
+ Constraint::Min(8),
333
402
  Constraint::Length(3),
334
403
  ])
335
404
  .split(area);
@@ -341,22 +410,53 @@ pub fn render(f: &mut Frame, state: &AppState) {
341
410
  .style(Style::default().bg(BG_PANEL));
342
411
  f.render_widget(header, chunks[0]);
343
412
 
344
- let mid = Layout::default()
345
- .direction(Direction::Horizontal)
346
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
347
- .split(chunks[1]);
348
-
349
- render_status_panel(f, mid[0], sd);
350
- render_metrics_panel(f, mid[1], sd);
351
- render_enforcement_panel(f, chunks[2], sd);
413
+ render_tab_bar(f, chunks[1], sd);
414
+
415
+ if !sd.loaded_once && sd.loading {
416
+ let block = Block::default()
417
+ .title(" Loading ")
418
+ .borders(Borders::ALL)
419
+ .border_style(Style::default().fg(CYBER_CYAN));
420
+ let inner = block.inner(chunks[2]);
421
+ f.render_widget(block, chunks[2]);
422
+ f.render_widget(
423
+ Paragraph::new(vec![
424
+ Line::from(""),
425
+ Line::from("Fetching Shield health, config, metrics, and run history…")
426
+ .style(Style::default().fg(TEXT_PRIMARY)),
427
+ Line::from(""),
428
+ Line::from("Press R to retry if this takes more than a few seconds.")
429
+ .style(Style::default().fg(TEXT_DIM)),
430
+ ])
431
+ .alignment(Alignment::Center),
432
+ inner,
433
+ );
434
+ } else {
435
+ match sd.tab {
436
+ ShieldTab::Overview => {
437
+ let mid = Layout::default()
438
+ .direction(Direction::Horizontal)
439
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
440
+ .split(chunks[2]);
441
+ render_status_panel(f, mid[0], sd);
442
+ render_metrics_panel(f, mid[1], sd);
443
+ }
444
+ ShieldTab::Enforcement => render_enforcement_panel(f, chunks[2], sd),
445
+ ShieldTab::Help => render_help_panel(f, chunks[2], sd),
446
+ }
447
+ }
352
448
 
353
- let footer_msg = if sd.loading {
354
- "Loading live Shield state from Gateway…".to_string()
355
- } else if let Some(e) = &sd.error {
356
- e.clone()
449
+ let status_line = if let Some(e) = &sd.error {
450
+ format!(" {} | ", e)
451
+ } else if sd.loading {
452
+ "Refreshing… | ".to_string()
357
453
  } else {
358
- "R Refresh · A Live toggle · ESC Close".to_string()
454
+ String::new()
359
455
  };
456
+ let footer_msg = format!(
457
+ "{}Tab/←→ Switch view · ↑↓ Select block · Enter Open run · R Refresh · O Runs · P Monitoring · A Live · ESC Close",
458
+ status_line
459
+ );
360
460
  let footer_color = if sd.error.is_some() {
361
461
  Color::Rgb(255, 69, 69)
362
462
  } else {
@@ -376,6 +476,68 @@ pub fn render(f: &mut Frame, state: &AppState) {
376
476
  );
377
477
  }
378
478
 
479
+ fn render_tab_bar(f: &mut Frame, area: Rect, sd: &ShieldDashboardState) {
480
+ let tabs = [ShieldTab::Overview, ShieldTab::Enforcement, ShieldTab::Help];
481
+ let spans: Vec<Span> = tabs
482
+ .iter()
483
+ .flat_map(|tab| {
484
+ let active = *tab == sd.tab;
485
+ [
486
+ Span::styled(
487
+ format!(" {} ", tab.label()),
488
+ Style::default()
489
+ .fg(if active { SHIELD_ORANGE } else { TEXT_DIM })
490
+ .add_modifier(if active {
491
+ Modifier::BOLD
492
+ } else {
493
+ Modifier::empty()
494
+ }),
495
+ ),
496
+ Span::raw("│"),
497
+ ]
498
+ })
499
+ .collect();
500
+ let block = Block::default()
501
+ .borders(Borders::LEFT | Borders::RIGHT)
502
+ .border_style(Style::default().fg(TEXT_MUTED));
503
+ f.render_widget(
504
+ Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
505
+ block.inner(area),
506
+ );
507
+ f.render_widget(block, area);
508
+ }
509
+
510
+ fn render_help_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState) {
511
+ let block = Block::default()
512
+ .title(" How Shield works in production ")
513
+ .borders(Borders::ALL)
514
+ .border_style(Style::default().fg(BRAND_PURPLE))
515
+ .style(Style::default().bg(BG_PANEL));
516
+ let inner = block.inner(area);
517
+ f.render_widget(block, area);
518
+
519
+ let mut lines: Vec<Line> = ABOUT_SHIELD
520
+ .iter()
521
+ .map(|l| Line::from(*l).style(Style::default().fg(TEXT_PRIMARY)))
522
+ .collect();
523
+ lines.push(Line::from(""));
524
+ lines.push(
525
+ Line::from("Navigation from this screen:")
526
+ .style(Style::default().fg(NEON_GREEN).bold()),
527
+ );
528
+ lines.push(Line::from(" O — open Run Manager (all runs, SHIELD badge on blocks)"));
529
+ lines.push(Line::from(" P — open Portal Monitoring (Prometheus Shield counters)"));
530
+ lines.push(Line::from(" Enforcement tab → Enter on a row opens that blocked run"));
531
+ if !sd.warnings.is_empty() {
532
+ lines.push(Line::from(""));
533
+ lines.push(Line::from("Active warnings:").style(Style::default().fg(AMBER_WARN).bold()));
534
+ for w in &sd.warnings {
535
+ lines.push(Line::from(format!(" ⚠ {}", w)).style(Style::default().fg(AMBER_WARN)));
536
+ }
537
+ }
538
+ f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
539
+ }
540
+
379
541
  fn render_status_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState) {
380
542
  let block = Block::default()
381
543
  .title(" Status & detectors ")
@@ -495,7 +657,7 @@ fn metric_line(label: &str, value: u64, color: Color) -> Line<'static> {
495
657
  fn render_enforcement_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState) {
496
658
  let block = Block::default()
497
659
  .title(format!(
498
- " Recent enforcement ({} blocked run(s) in history) ",
660
+ " Blocked runs ({}) ↑↓ select · Enter open in Run Manager ",
499
661
  sd.recent_blocks.len()
500
662
  ))
501
663
  .borders(Borders::ALL)
@@ -508,23 +670,31 @@ fn render_enforcement_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState
508
670
  let empty = vec![
509
671
  Line::from(""),
510
672
  Line::from("No blocked runs in Gateway history yet.").style(Style::default().fg(TEXT_DIM)),
511
- Line::from("Shield is active on every run — blocks appear here when production").style(Style::default().fg(TEXT_MUTED)),
512
- Line::from("traffic triggers injection/PII policy (failed status + SHIELD in Run Manager).").style(Style::default().fg(TEXT_MUTED)),
673
+ Line::from(""),
674
+ Line::from("Shield still evaluates every agent/API run on input.").style(Style::default().fg(TEXT_MUTED)),
675
+ Line::from("Press O to open Run Manager · P for live Prometheus metrics.").style(Style::default().fg(NEON_GREEN)),
513
676
  ];
514
- f.render_widget(Paragraph::new(empty), inner);
677
+ f.render_widget(Paragraph::new(empty).alignment(Alignment::Center), inner);
515
678
  return;
516
679
  }
517
680
 
518
681
  let items: Vec<ListItem> = sd
519
682
  .recent_blocks
520
683
  .iter()
521
- .map(|r| {
684
+ .enumerate()
685
+ .map(|(idx, r)| {
522
686
  let short_id = if r.id.len() > 8 {
523
687
  &r.id[r.id.len() - 8..]
524
688
  } else {
525
689
  &r.id
526
690
  };
691
+ let prefix = if idx == sd.selected_block_index {
692
+ "▶ "
693
+ } else {
694
+ " "
695
+ };
527
696
  ListItem::new(Line::from(vec![
697
+ Span::styled(prefix, Style::default().fg(SHIELD_ORANGE).bold()),
528
698
  Span::styled(
529
699
  format!("{} ", r.created_at),
530
700
  Style::default().fg(TEXT_MUTED),
@@ -535,7 +705,11 @@ fn render_enforcement_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState
535
705
  ),
536
706
  Span::styled(
537
707
  format!("{} — ", r.name),
538
- Style::default().fg(TEXT_PRIMARY),
708
+ Style::default().fg(if idx == sd.selected_block_index {
709
+ CYBER_CYAN
710
+ } else {
711
+ TEXT_PRIMARY
712
+ }),
539
713
  ),
540
714
  Span::styled(
541
715
  r.reason.chars().take(48).collect::<String>(),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "4runr-os",
3
- "version": "2.10.75",
3
+ "version": "2.10.76",
4
4
  "type": "module",
5
- "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.75: Dedicated Shield dashboard (shield command) live metrics, detectors, enforcement history.",
5
+ "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.76: Shield dashboard usablefix shield.load dedupe hang, tabs, shortcuts, enforcement drill-down.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "4runr": "dist/index.js",