1use std::time::Duration;
4
5use anyhow::Result;
6use crossterm::event::{KeyCode, KeyModifiers};
7use ratatui::{
8 layout::{Alignment, Constraint, Layout, Rect},
9 style::Style,
10 text::{Line, Span},
11 widgets::Paragraph,
12 Frame,
13};
14
15use crate::api::client::MockForgeClient;
16use crate::config::TuiConfig;
17use crate::event::{Event, EventHandler};
18use crate::keybindings::{self, Action};
19use crate::screens::{self, Screen, ScreenId};
20use crate::theme::Theme;
21use crate::tui;
22use crate::widgets::command_palette::{CommandPalette, PaletteAction};
23use crate::widgets::{help, status_bar};
24
25pub struct App {
27 config: TuiConfig,
28 servers: Vec<String>,
34 active_server: usize,
38 admin_url: String,
41 client: MockForgeClient,
42 token: Option<String>,
48 screens: Vec<Box<dyn Screen>>,
49 active_tab: usize,
50 show_help: bool,
51 command_palette: CommandPalette,
52 connected: bool,
53 error_count: usize,
54 should_quit: bool,
55 tab_bar_y: u16,
57 last_health_check: std::time::Instant,
59}
60
61impl App {
62 pub fn new(config: TuiConfig, token: Option<String>) -> Self {
64 Theme::init(config.is_light_theme());
65
66 let servers = config.all_admin_urls();
70 let active_server = 0;
71 let admin_url = servers[active_server].clone();
72
73 let client =
74 MockForgeClient::new(admin_url.clone(), token.clone()).expect("failed to build client");
75
76 let initial_tab = config.last_tab.unwrap_or(0);
77
78 let screens: Vec<Box<dyn Screen>> = vec![
79 Box::new(screens::dashboard::DashboardScreen::new()),
80 Box::new(screens::logs::LogsScreen::new()),
81 Box::new(screens::routes::RoutesScreen::new()),
82 Box::new(screens::metrics::MetricsScreen::new()),
83 Box::new(screens::config::ConfigScreen::new()),
84 Box::new(screens::chaos::ChaosScreen::new()),
85 Box::new(screens::workspaces::WorkspacesScreen::new()),
86 Box::new(screens::plugins::PluginsScreen::new()),
87 Box::new(screens::fixtures::FixturesScreen::new()),
88 Box::new(screens::health::HealthScreen::new()),
89 Box::new(screens::smoke_tests::SmokeTestsScreen::new()),
90 Box::new(screens::time_travel::TimeTravelScreen::new()),
91 Box::new(screens::chains::ChainsScreen::new()),
92 Box::new(screens::conformance::ConformanceScreen::new()),
98 Box::new(screens::verification::VerificationScreen::new()),
99 Box::new(screens::analytics::AnalyticsScreen::new()),
100 Box::new(screens::recorder::RecorderScreen::new()),
101 Box::new(screens::import::ImportScreen::new()),
102 Box::new(screens::audit::AuditScreen::new()),
103 Box::new(screens::world_state::WorldStateScreen::new()),
104 Box::new(screens::contract_diff::ContractDiffScreen::new()),
105 Box::new(screens::federation::FederationScreen::new()),
106 Box::new(screens::behavioral_cloning::BehavioralCloningScreen::new()),
107 ];
108
109 debug_assert_eq!(
116 screens.len(),
117 ScreenId::ALL.len(),
118 "screens vec must match ScreenId::ALL length"
119 );
120
121 let active_tab = if initial_tab < screens.len() {
122 initial_tab
123 } else {
124 0
125 };
126
127 Self {
128 config,
129 servers,
130 active_server,
131 admin_url,
132 client,
133 token,
134 screens,
135 active_tab,
136 show_help: false,
137 command_palette: CommandPalette::new(),
138 connected: false,
139 error_count: 0,
140 should_quit: false,
141 tab_bar_y: 1,
142 last_health_check: std::time::Instant::now(),
143 }
144 }
145
146 fn rotate_server(&mut self, step: isize) {
154 let n = self.servers.len();
155 if n <= 1 {
156 return;
157 }
158 let next = (self.active_server as isize + step).rem_euclid(n as isize) as usize;
159 self.active_server = next;
160 self.admin_url = self.servers[next].clone();
161 if let Ok(new_client) = MockForgeClient::new(self.admin_url.clone(), self.token.clone()) {
166 self.client = new_client;
167 }
168 self.connected = false;
171 let one_hour = Duration::from_secs(3600);
175 self.last_health_check = std::time::Instant::now()
176 .checked_sub(one_hour)
177 .unwrap_or_else(std::time::Instant::now);
178 }
179}
180
181#[cfg(test)]
182mod server_rotation_tests {
183 use super::*;
184
185 fn cfg_with(extras: &[&str]) -> TuiConfig {
186 TuiConfig {
187 admin_url: "http://primary:9080".into(),
188 extra_servers: extras.iter().map(|s| s.to_string()).collect(),
189 ..TuiConfig::default()
190 }
191 }
192
193 #[test]
194 fn all_admin_urls_keeps_primary_first_and_dedupes() {
195 let cfg = cfg_with(&["http://b:9080", "http://primary:9080", "http://c:9080"]);
196 let urls = cfg.all_admin_urls();
197 assert_eq!(urls, vec!["http://primary:9080", "http://b:9080", "http://c:9080"]);
198 }
199
200 #[test]
201 fn all_admin_urls_drops_empty_entries() {
202 let cfg = cfg_with(&["", "http://b:9080", ""]);
203 let urls = cfg.all_admin_urls();
204 assert_eq!(urls, vec!["http://primary:9080", "http://b:9080"]);
205 }
206
207 #[test]
208 fn rotate_server_cycles_in_both_directions() {
209 let cfg = cfg_with(&["http://b:9080", "http://c:9080"]);
210 let mut app = App::new(cfg, None);
211 assert_eq!(app.active_server, 0);
212 assert_eq!(app.admin_url, "http://primary:9080");
213
214 app.rotate_server(1);
215 assert_eq!(app.active_server, 1);
216 assert_eq!(app.admin_url, "http://b:9080");
217
218 app.rotate_server(1);
219 assert_eq!(app.active_server, 2);
220 assert_eq!(app.admin_url, "http://c:9080");
221
222 app.rotate_server(1);
224 assert_eq!(app.active_server, 0);
225
226 app.rotate_server(-1);
228 assert_eq!(app.active_server, 2);
229 assert_eq!(app.admin_url, "http://c:9080");
230 }
231
232 #[test]
233 fn rotate_server_is_noop_on_single_server() {
234 let cfg = cfg_with(&[]);
235 let mut app = App::new(cfg, None);
236 let before = app.admin_url.clone();
237 app.rotate_server(1);
238 assert_eq!(app.admin_url, before);
239 assert_eq!(app.active_server, 0);
240 }
241}
242
243impl App {
244 pub async fn run(mut self) -> Result<()> {
246 let mut terminal = tui::init()?;
247 let tick_rate = Duration::from_millis(250);
248 let mut events = EventHandler::new(tick_rate);
249 let tx = events.sender();
250
251 self.connected = self.client.ping().await;
253
254 loop {
255 terminal.draw(|frame| self.render(frame))?;
257
258 let event = events.next().await?;
260
261 match event {
262 Event::Key(key) => {
263 if key.modifiers.contains(KeyModifiers::CONTROL)
265 && matches!(key.code, KeyCode::Char('c'))
266 {
267 self.should_quit = true;
268 } else if self.command_palette.visible {
269 if let Some(action) = self.command_palette.handle_key(key) {
270 self.execute_palette_action(action);
271 }
272 } else if self.show_help {
273 if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
274 self.show_help = false;
275 }
276 } else {
277 let consumed = self.screens[self.active_tab].handle_key(key);
279 if !consumed {
280 self.handle_global_key(key);
281 }
282 }
283 }
284 Event::Tick => {
285 self.screens[self.active_tab].tick(&self.client, &tx);
286
287 if self.last_health_check.elapsed() >= Duration::from_secs(10) {
289 self.last_health_check = std::time::Instant::now();
290 let client = self.client.clone();
291 let health_tx = tx.clone();
292 tokio::spawn(async move {
293 let ok = client.ping().await;
294 if ok {
296 let _ = health_tx.send(Event::Data {
297 screen: "_health_check",
298 payload: String::new(),
299 });
300 } else {
301 let _ = health_tx.send(Event::ApiError {
302 screen: "_health_check",
303 message: "Server unreachable".into(),
304 });
305 }
306 });
307 }
308 }
309 Event::Data { screen, payload } => {
310 self.route_data(screen, &payload);
311 }
312 Event::ApiError { screen, message } => {
313 self.error_count = (self.error_count + 1).min(999);
314 self.route_error(screen, &message);
315 }
316 Event::LogLine(line) => {
317 self.connected = true;
318 if let Some(logs) = self.screens.get_mut(1) {
319 logs.push_log_line(line);
320 }
321 }
322 Event::Resize(_, _) => {}
323 Event::Mouse(mouse) => {
324 self.handle_mouse(mouse);
325 }
326 }
327
328 if self.should_quit {
329 break;
330 }
331 }
332
333 self.config.last_tab = Some(self.active_tab);
335 let _ = self.config.save();
336
337 tui::restore()?;
338 Ok(())
339 }
340
341 fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) {
342 if matches!(key.code, KeyCode::Char(':')) {
344 self.command_palette.open();
345 return;
346 }
347
348 if let Some(action) = keybindings::resolve(key) {
349 match action {
350 Action::Quit => self.should_quit = true,
351 Action::ToggleHelp => self.show_help = !self.show_help,
352 Action::NextTab => {
353 self.active_tab = (self.active_tab + 1) % self.screens.len();
354 }
355 Action::PrevTab => {
356 self.active_tab = if self.active_tab == 0 {
357 self.screens.len() - 1
358 } else {
359 self.active_tab - 1
360 };
361 }
362 Action::JumpTab(idx) => {
363 if idx < self.screens.len() {
364 self.active_tab = idx;
365 }
366 }
367 Action::Refresh => {
368 self.screens[self.active_tab].force_refresh();
369 }
370 Action::NextServer => self.rotate_server(1),
371 Action::PrevServer => self.rotate_server(-1),
372 _ => {}
373 }
374 }
375 }
376
377 fn execute_palette_action(&mut self, action: PaletteAction) {
378 match action {
379 PaletteAction::GoToScreen(idx) => {
380 if idx < self.screens.len() {
381 self.active_tab = idx;
382 }
383 }
384 PaletteAction::Refresh => {
385 self.screens[self.active_tab].force_refresh();
386 }
387 PaletteAction::ToggleHelp => {
388 self.show_help = !self.show_help;
389 }
390 PaletteAction::Quit => {
391 self.should_quit = true;
392 }
393 }
394 }
395
396 fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
397 use crossterm::event::{MouseButton, MouseEventKind};
398
399 match mouse.kind {
400 MouseEventKind::Down(MouseButton::Left) => {
401 if mouse.row == self.tab_bar_y {
403 self.handle_tab_click(mouse.column);
404 }
405 }
406 MouseEventKind::ScrollUp => {
407 let key = crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
409 self.screens[self.active_tab].handle_key(key);
410 }
411 MouseEventKind::ScrollDown => {
412 let key = crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
413 self.screens[self.active_tab].handle_key(key);
414 }
415 _ => {}
416 }
417 }
418
419 fn handle_tab_click(&mut self, column: u16) {
420 let mut x: u16 = 0;
422 for (i, screen) in self.screens.iter().enumerate() {
423 let title_len = u16::try_from(screen.title().len()).unwrap_or(u16::MAX);
424 let label_len: u16 = if i <= 9 {
425 title_len.saturating_add(4)
427 } else {
428 title_len.saturating_add(3)
430 };
431 if column >= x && column < x.saturating_add(label_len) {
432 self.active_tab = i;
433 return;
434 }
435 x = x.saturating_add(label_len);
436 }
437 }
438
439 fn route_data(&mut self, screen_key: &str, payload: &str) {
440 self.connected = true;
441
442 if screen_key == "_health_check" {
444 return;
445 }
446
447 for (i, sid) in ScreenId::ALL.iter().enumerate() {
448 if sid.data_key() == screen_key {
449 if let Some(screen) = self.screens.get_mut(i) {
450 screen.on_data(payload);
451 }
452 return;
453 }
454 }
455 }
456
457 fn route_error(&mut self, screen_key: &str, message: &str) {
458 if screen_key == "_health_check" {
461 self.connected = false;
462 return;
463 }
464
465 for (i, sid) in ScreenId::ALL.iter().enumerate() {
466 if sid.data_key() == screen_key {
467 if let Some(screen) = self.screens.get_mut(i) {
468 screen.on_error(message);
469 }
470 return;
471 }
472 }
473 }
474
475 fn render(&self, frame: &mut Frame) {
476 let area = frame.area();
477
478 if area.width < 80 || area.height < 24 {
480 let msg = Paragraph::new(format!(
481 "Terminal too small ({}x{}). Minimum: 80x24. Please resize.",
482 area.width, area.height
483 ))
484 .style(Style::default().fg(Theme::RED))
485 .alignment(Alignment::Center);
486 let centered = Rect {
487 y: area.height / 2,
488 height: 1,
489 ..area
490 };
491 frame.render_widget(msg, centered);
492 return;
493 }
494
495 let chunks = Layout::vertical([
496 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), ])
500 .split(area);
501
502 self.render_header(frame, chunks[0]);
503
504 let content_area = if let Some(err) = self.screens[self.active_tab].error() {
507 let parts =
508 Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(chunks[1]);
509 let banner = Paragraph::new(format!(" Error: {err}"))
510 .style(Style::default().fg(Theme::RED).bg(Theme::OVERLAY));
511 frame.render_widget(banner, parts[0]);
512 parts[1]
513 } else {
514 chunks[1]
515 };
516
517 self.screens[self.active_tab].render(frame, content_area);
518
519 status_bar::render(
520 frame,
521 chunks[2],
522 self.connected,
523 self.screens[self.active_tab].status_hint(),
524 self.error_count,
525 &self.admin_url,
526 );
527
528 if self.show_help {
529 help::render(frame);
530 }
531
532 if self.command_palette.visible {
533 self.command_palette.render(frame);
534 }
535 }
536
537 fn render_header(&self, frame: &mut Frame, area: Rect) {
538 let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
539
540 let conn_status = if self.connected {
542 "Connected"
543 } else {
544 "Disconnected"
545 };
546 let conn_style = if self.connected {
547 Theme::success()
548 } else {
549 Theme::error()
550 };
551 let server_indicator = if self.servers.len() > 1 {
556 format!(" [{}/{}] {}", self.active_server + 1, self.servers.len(), self.admin_url)
557 } else {
558 format!(" {}", self.admin_url)
559 };
560 let title = Line::from(vec![
561 Span::styled(" MockForge TUI ", Theme::title()),
562 Span::styled(format!("v{}", env!("CARGO_PKG_VERSION")), Theme::dim()),
563 Span::raw(" "),
564 Span::styled(conn_status, conn_style),
565 Span::styled(server_indicator, Theme::dim()),
566 ]);
567 frame.render_widget(Paragraph::new(title).style(Theme::surface()), chunks[0]);
568
569 let mut tab_spans = Vec::new();
571 for (i, screen) in self.screens.iter().enumerate() {
572 let style = if i == self.active_tab {
573 Theme::tab_active()
574 } else {
575 Theme::tab_inactive()
576 };
577 let label = if i < 9 {
578 format!(" {}:{} ", i + 1, screen.title())
579 } else if i == 9 {
580 format!(" 0:{} ", screen.title())
581 } else {
582 format!(" {} ", screen.title())
583 };
584 tab_spans.push(Span::styled(label, style));
585 tab_spans.push(Span::raw(" "));
586 }
587 let tabs = Line::from(tab_spans);
588 frame.render_widget(Paragraph::new(tabs).style(Theme::base()), chunks[1]);
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::config::TuiConfig;
596
597 #[test]
603 fn app_screens_match_screen_id_all() {
604 let app = App::new(TuiConfig::default(), None);
605 assert_eq!(
606 app.screens.len(),
607 ScreenId::ALL.len(),
608 "App::new must instantiate a Screen for every ScreenId::ALL entry"
609 );
610 }
611}