1mod app;
6mod constants;
7mod dashboard;
8mod helpers;
9pub mod log_capture;
10mod rate_estimator;
11mod remote;
12mod system_stats;
13mod types;
14mod ui;
15#[cfg(test)]
16mod ui_tests;
17
18use crate::tui::constants::styles;
19pub use app::{TuiApp, TuiAppBuilder, ViewMode};
20pub use dashboard::{BackendView, BufferPoolStats, DashboardState};
21pub use log_capture::{LogBuffer, LogMakeWriter};
22pub(crate) use remote::spawn_dashboard_reader;
23pub use remote::{
24 bind_dashboard_listener, run_dashboard_publisher, run_dashboard_publisher_on_listener,
25};
26pub use system_stats::{SystemMonitor, SystemStats};
27
28use anyhow::Result;
29use crossterm::{
30 event::{self, Event, KeyCode, KeyEventKind},
31 execute,
32 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
33};
34use futures::future::BoxFuture;
35use ratatui::{Terminal, backend::CrosstermBackend};
36use ratatui::{
37 layout::Rect,
38 style::{Color, Stylize},
39 text::Line,
40};
41use std::io;
42use std::net::SocketAddr;
43use std::sync::Arc;
44use std::time::Duration;
45use tokio::sync::mpsc;
46
47type TuiTerminal = Terminal<CrosstermBackend<io::Stdout>>;
48
49pub struct StartupTuiSession {
50 terminal: Option<TuiTerminal>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub(crate) enum RemoteDashboardStatus {
55 Connecting {
56 target: SocketAddr,
57 },
58 Connected {
59 target: SocketAddr,
60 },
61 Reconnecting {
62 target: SocketAddr,
63 retry_delay: Duration,
64 last_error: String,
65 },
66}
67
68#[derive(Debug, Clone)]
69pub(crate) struct AttachedDashboard {
70 pub latest_state: Option<Arc<DashboardState>>,
71 render_cache: Option<Arc<ui::AttachedRenderCache>>,
72 placeholder_lines: Vec<Line<'static>>,
73 pub status: RemoteDashboardStatus,
74}
75
76impl AttachedDashboard {
77 fn new(status: RemoteDashboardStatus, latest_state: Option<Arc<DashboardState>>) -> Self {
78 let render_cache = latest_state
79 .as_deref()
80 .map(|state| Arc::new(ui::build_attached_render_cache(state, &status)));
81 let placeholder_lines = build_attached_placeholder_lines(&status);
82
83 Self {
84 latest_state,
85 render_cache,
86 placeholder_lines,
87 status,
88 }
89 }
90
91 fn connecting(target: SocketAddr) -> Self {
92 Self::new(RemoteDashboardStatus::Connecting { target }, None)
93 }
94
95 fn connected(target: SocketAddr, latest_state: Option<Arc<DashboardState>>) -> Self {
96 Self::new(RemoteDashboardStatus::Connected { target }, latest_state)
97 }
98
99 fn reconnecting(
100 target: SocketAddr,
101 retry_delay: Duration,
102 latest_state: Option<Arc<DashboardState>>,
103 last_error: impl Into<String>,
104 ) -> Self {
105 Self::new(
106 RemoteDashboardStatus::Reconnecting {
107 target,
108 retry_delay,
109 last_error: last_error.into(),
110 },
111 latest_state,
112 )
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum TuiInputAction {
118 None,
119 Quit,
120 ToggleLogFullscreen,
121 ToggleDetails,
122}
123
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125struct AttachedViewOverrides {
126 view_mode: Option<ViewMode>,
127 show_details: Option<bool>,
128}
129
130impl AttachedViewOverrides {
131 fn toggle_log_fullscreen(&mut self, current_state: Option<&DashboardState>) {
132 let current_mode = self
133 .view_mode
134 .or_else(|| current_state.map(|state| state.view_mode))
135 .unwrap_or(ViewMode::Normal);
136 self.view_mode = Some(match current_mode {
137 ViewMode::Normal => ViewMode::LogFullscreen,
138 ViewMode::LogFullscreen => ViewMode::Normal,
139 });
140 }
141
142 fn toggle_details(&mut self, current_state: Option<&DashboardState>) {
143 let show_details = self
144 .show_details
145 .or_else(|| current_state.map(|state| state.show_details))
146 .unwrap_or(false);
147 self.show_details = Some(!show_details);
148 }
149}
150
151fn setup_terminal() -> Result<TuiTerminal> {
153 enable_raw_mode()?;
154 let mut stdout = io::stdout();
155 execute!(stdout, EnterAlternateScreen)?;
156 let backend = CrosstermBackend::new(stdout);
157 let mut terminal = Terminal::new(backend)?;
158 terminal.clear()?;
159 Ok(terminal)
160}
161
162fn restore_terminal(terminal: &mut TuiTerminal) -> Result<()> {
164 terminal.clear()?;
166
167 disable_raw_mode()?;
168 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
169 terminal.show_cursor()?;
170
171 Ok(())
172}
173
174async fn with_terminal_session<T>(
175 run: impl for<'a> FnOnce(&'a mut TuiTerminal) -> BoxFuture<'a, Result<T>>,
176) -> Result<T> {
177 let mut terminal = setup_terminal()?;
178
179 let original_hook = std::panic::take_hook();
180 std::panic::set_hook(Box::new(move |panic_info| {
181 let _ = disable_raw_mode();
182 let _ = execute!(io::stdout(), LeaveAlternateScreen);
183 original_hook(panic_info);
184 }));
185
186 let result = run(&mut terminal).await;
187
188 restore_terminal(&mut terminal)?;
189 result
190}
191
192pub fn start_startup_tui_session() -> Result<StartupTuiSession> {
201 let mut terminal = setup_terminal()?;
202 install_terminal_panic_hook();
203 draw_initial_dashboard(&mut terminal)?;
204 Ok(StartupTuiSession {
205 terminal: Some(terminal),
206 })
207}
208
209fn install_terminal_panic_hook() {
210 let original_hook = std::panic::take_hook();
211 std::panic::set_hook(Box::new(move |panic_info| {
212 let _ = disable_raw_mode();
213 let _ = execute!(io::stdout(), LeaveAlternateScreen);
214 original_hook(panic_info);
215 }));
216}
217
218fn draw_initial_dashboard<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>) -> Result<()>
219where
220 B::Error: Send + Sync + 'static,
221{
222 terminal.draw(|f| ui::render_ui(f, &initial_dashboard_state(), None, None, None, None))?;
223 Ok(())
224}
225
226fn initial_dashboard_state() -> DashboardState {
227 DashboardState {
228 metrics: dashboard::DashboardMetrics::default(),
229 backend_views: Vec::new(),
230 top_users: Vec::new(),
231 client_history: Vec::new(),
232 system_stats: SystemStats::default(),
233 view_mode: ViewMode::Normal,
234 show_details: false,
235 log_lines: Vec::new(),
236 buffer_pool: None,
237 }
238}
239
240impl StartupTuiSession {
241 async fn run_tui(
242 mut self,
243 mut app: TuiApp,
244 shutdown_tx: mpsc::Sender<()>,
245 mut shutdown_rx: mpsc::Receiver<()>,
246 ) -> Result<()> {
247 let Some(mut terminal) = self.terminal.take() else {
248 return Ok(());
249 };
250
251 let result = run_app(&mut terminal, &mut app, &mut shutdown_rx).await;
252 restore_terminal(&mut terminal)?;
253
254 let _ = shutdown_tx.send(()).await;
255 result
256 }
257}
258
259impl Drop for StartupTuiSession {
260 fn drop(&mut self) {
261 if let Some(mut terminal) = self.terminal.take() {
262 let _ = restore_terminal(&mut terminal);
263 }
264 }
265}
266
267pub async fn run_tui(
285 mut app: TuiApp,
286 shutdown_tx: mpsc::Sender<()>,
287 mut shutdown_rx: mpsc::Receiver<()>,
288) -> Result<()> {
289 let result = with_terminal_session(move |terminal| {
290 Box::pin(async move { run_app(terminal, &mut app, &mut shutdown_rx).await })
291 })
292 .await;
293
294 let _ = shutdown_tx.send(()).await;
296
297 result
298}
299
300pub async fn run_tui_from_startup_session(
305 session: StartupTuiSession,
306 app: TuiApp,
307 shutdown_tx: mpsc::Sender<()>,
308 shutdown_rx: mpsc::Receiver<()>,
309) -> Result<()> {
310 session.run_tui(app, shutdown_tx, shutdown_rx).await
311}
312
313pub async fn run_attached_tui(connect_addr: SocketAddr) -> Result<()> {
319 anyhow::ensure!(
320 connect_addr.ip().is_loopback(),
321 "Attached dashboard client must connect to a loopback address: {connect_addr}"
322 );
323 let (state_tx, mut state_rx) =
324 tokio::sync::watch::channel(AttachedDashboard::connecting(connect_addr));
325 let (log_tail_tx, log_tail_rx) =
326 tokio::sync::watch::channel(attached_log_line_limit(ViewMode::Normal));
327 let _reader = spawn_dashboard_reader(connect_addr, state_tx, log_tail_rx);
328 with_terminal_session(move |terminal| {
329 Box::pin(async move { run_attached_app(terminal, &mut state_rx, log_tail_tx).await })
330 })
331 .await
332}
333
334async fn run_app<B: ratatui::backend::Backend>(
336 terminal: &mut Terminal<B>,
337 app: &mut TuiApp,
338 shutdown_rx: &mut mpsc::Receiver<()>,
339) -> Result<()>
340where
341 B::Error: Send + Sync + 'static,
342{
343 let mut update_interval = tokio::time::interval(Duration::from_millis(250));
345 let mut input_rx = spawn_tui_input_reader(true);
346
347 terminal
349 .draw(|f| ui::render_ui(f, &renderable_dashboard_state(app), None, None, None, None))?;
350
351 loop {
352 tokio::select! {
353 _ = shutdown_rx.recv() => {
355 break;
356 }
357 Some(action) = input_rx.recv() => {
358 if handle_local_tui_action(action, app) {
359 break;
360 }
361 terminal.draw(|f| {
362 ui::render_local_ui(
363 f,
364 &renderable_dashboard_state(app),
365 app.log_buffer(),
366 None,
367 None,
368 None,
369 None,
370 )
371 })?;
372 }
373 _ = update_interval.tick() => {
375 app.update();
376 terminal.draw(|f| {
377 ui::render_local_ui(
378 f,
379 &renderable_dashboard_state(app),
380 app.log_buffer(),
381 None,
382 None,
383 None,
384 None,
385 )
386 })?;
387 }
388 }
389 }
390
391 Ok(())
392}
393
394fn renderable_dashboard_state(app: &TuiApp) -> DashboardState {
395 app.snapshot_state_with_log_limit(Some(0))
396}
397
398async fn run_attached_app<B: ratatui::backend::Backend>(
399 terminal: &mut Terminal<B>,
400 state_rx: &mut tokio::sync::watch::Receiver<AttachedDashboard>,
401 log_tail_tx: tokio::sync::watch::Sender<usize>,
402) -> Result<()>
403where
404 B::Error: Send + Sync + 'static,
405{
406 let mut update_interval = tokio::time::interval(Duration::from_millis(250));
407 let mut input_rx = spawn_tui_input_reader(true);
408 let mut view_overrides = AttachedViewOverrides::default();
409
410 terminal.draw(|f| {
411 let attached = state_rx.borrow();
412 draw_attached_dashboard(f, &attached, &view_overrides);
413 })?;
414
415 loop {
416 tokio::select! {
417 Some(action) = input_rx.recv() => {
418 if handle_attached_tui_action(
419 action,
420 &mut view_overrides,
421 state_rx.borrow().latest_state.as_deref(),
422 &log_tail_tx,
423 ) {
424 break;
425 }
426
427 terminal.draw(|f| {
428 let attached = state_rx.borrow();
429 draw_attached_dashboard(f, &attached, &view_overrides);
430 })?;
431 }
432 _ = update_interval.tick() => {
433 terminal.draw(|f| {
434 let attached = state_rx.borrow();
435 draw_attached_dashboard(f, &attached, &view_overrides);
436 })?;
437 }
438 }
439 }
440
441 Ok(())
442}
443
444fn draw_attached_dashboard(
445 f: &mut ratatui::Frame,
446 attached: &AttachedDashboard,
447 view_overrides: &AttachedViewOverrides,
448) {
449 if let Some(rendered) = renderable_attached_state(attached, view_overrides) {
450 ui::render_attached_ui(
451 f,
452 rendered.state,
453 rendered.render_cache,
454 rendered.view_mode,
455 rendered.show_details,
456 );
457 return;
458 }
459
460 let chunks = attached_placeholder_chunks(f.area());
461
462 let title = Line::from(vec![
463 "NNTP Proxy "
464 .fg(crate::tui::constants::styles::BORDER_ACTIVE)
465 .bold(),
466 "- Attached Dashboard".fg(Color::White),
467 ]);
468
469 let footer = Line::from(vec![
470 "Press ".fg(crate::tui::constants::styles::LABEL),
471 "q".fg(crate::tui::constants::styles::VALUE_INFO).bold(),
472 " or ".fg(crate::tui::constants::styles::LABEL),
473 "Esc".fg(crate::tui::constants::styles::VALUE_INFO).bold(),
474 " to exit".fg(crate::tui::constants::styles::LABEL),
475 ]);
476
477 render_centered_line(f, chunks[0], &title);
478 render_centered_lines(f, chunks[1], &attached.placeholder_lines);
479 render_centered_line(f, chunks[2], &footer);
480}
481
482fn attached_placeholder_chunks(area: Rect) -> [Rect; 3] {
483 let inner = Rect::new(
484 area.x.saturating_add(area.width.min(1)),
485 area.y.saturating_add(area.height.min(1)),
486 area.width.saturating_sub(2),
487 area.height.saturating_sub(2),
488 );
489 let title = 3.min(inner.height);
490 let remaining_after_title = inner.height.saturating_sub(title);
491 let footer = 3.min(remaining_after_title);
492 let body = remaining_after_title.saturating_sub(footer);
493
494 [
495 Rect::new(inner.x, inner.y, inner.width, title),
496 Rect::new(inner.x, inner.y.saturating_add(title), inner.width, body),
497 Rect::new(
498 inner.x,
499 inner.y.saturating_add(title).saturating_add(body),
500 inner.width,
501 footer,
502 ),
503 ]
504}
505
506fn render_centered_lines(f: &mut ratatui::Frame, area: Rect, lines: &[Line<'_>]) {
507 if lines.is_empty() || area.width == 0 || area.height == 0 {
508 return;
509 }
510
511 let visible_lines = area.height.min(lines.len() as u16);
512 let start_y = area.y + area.height.saturating_sub(visible_lines) / 2;
513
514 for (offset, line) in lines.iter().take(visible_lines as usize).enumerate() {
515 let y = start_y + offset as u16;
516 render_line_centered_at(f, area, y, line);
517 }
518}
519
520fn render_centered_line(f: &mut ratatui::Frame, area: Rect, line: &Line<'_>) {
521 if area.width == 0 || area.height == 0 {
522 return;
523 }
524
525 let y = area.y + area.height.saturating_sub(1) / 2;
526 render_line_centered_at(f, area, y, line);
527}
528
529fn render_line_centered_at(f: &mut ratatui::Frame, area: Rect, y: u16, line: &Line<'_>) {
530 let line_width = line.width() as u16;
531 let x = area.x + area.width.saturating_sub(line_width) / 2;
532 f.buffer_mut().set_line(x, y, line, area.width);
533}
534
535#[derive(Debug, Clone, Copy)]
536struct RenderableAttachedState<'a> {
537 state: &'a DashboardState,
538 render_cache: &'a ui::AttachedRenderCache,
539 view_mode: ViewMode,
540 show_details: bool,
541}
542
543fn renderable_attached_state<'a>(
544 attached: &'a AttachedDashboard,
545 view_overrides: &AttachedViewOverrides,
546) -> Option<RenderableAttachedState<'a>> {
547 let state = attached.latest_state.as_deref()?;
548 let render_cache = attached.render_cache.as_deref()?;
549 Some(RenderableAttachedState {
550 state,
551 render_cache,
552 view_mode: view_overrides.view_mode.unwrap_or(state.view_mode),
553 show_details: view_overrides.show_details.unwrap_or(state.show_details),
554 })
555}
556
557fn build_attached_placeholder_lines(status: &RemoteDashboardStatus) -> Vec<Line<'static>> {
558 match status {
559 RemoteDashboardStatus::Connecting { target } => vec![
560 Line::from(vec![
561 "Connecting to ".fg(styles::LABEL),
562 target.to_string().fg(styles::VALUE_INFO).bold(),
563 "...".fg(styles::LABEL),
564 ]),
565 Line::from("Waiting for the first dashboard snapshot."),
566 ],
567 RemoteDashboardStatus::Connected { target } => vec![
568 Line::from(vec![
569 "Connected to ".fg(styles::LABEL),
570 target.to_string().fg(styles::VALUE_PRIMARY).bold(),
571 ]),
572 Line::from("Waiting for the first dashboard snapshot."),
573 ],
574 RemoteDashboardStatus::Reconnecting {
575 target,
576 retry_delay,
577 last_error,
578 } => vec![
579 Line::from(vec![
580 "Reconnecting to ".fg(styles::LABEL),
581 target.to_string().fg(Color::Yellow).bold(),
582 format!(" in {:.1}s", retry_delay.as_secs_f32()).fg(styles::LABEL),
583 ]),
584 Line::from(vec![
585 "Last error: ".fg(styles::LABEL),
586 last_error.clone().fg(Color::Yellow),
587 ]),
588 ],
589 }
590}
591
592fn key_event_action(
593 key: crossterm::event::KeyEvent,
594 allow_dashboard_actions: bool,
595) -> TuiInputAction {
596 if key.kind == KeyEventKind::Release {
597 return TuiInputAction::None;
598 }
599
600 match key.code {
601 KeyCode::Char('q') | KeyCode::Esc => TuiInputAction::Quit,
602 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
603 TuiInputAction::Quit
604 }
605 KeyCode::Char('l') if allow_dashboard_actions => TuiInputAction::ToggleLogFullscreen,
606 KeyCode::Char('d') if allow_dashboard_actions => TuiInputAction::ToggleDetails,
607 _ => TuiInputAction::None,
608 }
609}
610
611fn spawn_tui_input_reader(
612 allow_dashboard_actions: bool,
613) -> mpsc::UnboundedReceiver<TuiInputAction> {
614 let (tx, rx) = mpsc::unbounded_channel();
615 tokio::task::spawn_blocking(move || {
616 loop {
617 match event::poll(Duration::from_millis(50)) {
618 Ok(true) => {
619 let Ok(event) = event::read() else {
620 break;
621 };
622 let Event::Key(key) = event else {
623 continue;
624 };
625 let action = key_event_action(key, allow_dashboard_actions);
626 if action != TuiInputAction::None && tx.send(action).is_err() {
627 break;
628 }
629 }
630 Ok(false) => {
631 if tx.is_closed() {
632 break;
633 }
634 }
635 Err(_) => break,
636 }
637 }
638 });
639 rx
640}
641
642fn handle_local_tui_action(action: TuiInputAction, app: &mut TuiApp) -> bool {
643 match action {
644 TuiInputAction::Quit => true,
645 TuiInputAction::ToggleLogFullscreen => {
646 app.toggle_log_fullscreen();
647 false
648 }
649 TuiInputAction::ToggleDetails => {
650 app.toggle_details();
651 false
652 }
653 TuiInputAction::None => false,
654 }
655}
656
657fn handle_attached_tui_action(
658 action: TuiInputAction,
659 view_overrides: &mut AttachedViewOverrides,
660 current_state: Option<&DashboardState>,
661 log_tail_tx: &tokio::sync::watch::Sender<usize>,
662) -> bool {
663 match action {
664 TuiInputAction::Quit => true,
665 TuiInputAction::ToggleLogFullscreen => {
666 view_overrides.toggle_log_fullscreen(current_state);
667 let view_mode = view_overrides
668 .view_mode
669 .or_else(|| current_state.map(|state| state.view_mode))
670 .unwrap_or(ViewMode::Normal);
671 let _ = log_tail_tx.send(attached_log_line_limit(view_mode));
672 false
673 }
674 TuiInputAction::ToggleDetails => {
675 view_overrides.toggle_details(current_state);
676 false
677 }
678 TuiInputAction::None => false,
679 }
680}
681
682const fn attached_log_line_limit(view_mode: ViewMode) -> usize {
683 match view_mode {
684 ViewMode::Normal => remote::REMOTE_DASHBOARD_LOG_LINE_LIMIT,
685 ViewMode::LogFullscreen => remote::REMOTE_DASHBOARD_FULLSCREEN_LOG_LINE_LIMIT,
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::config::Server;
693 use crate::metrics::MetricsCollector;
694 use crate::router::BackendSelector;
695 use crate::types::Port;
696 use std::sync::Arc;
697
698 #[test]
699 fn key_event_action_quits_on_q_and_escape() {
700 let q = crossterm::event::KeyEvent::new(KeyCode::Char('q'), event::KeyModifiers::NONE);
701 let esc = crossterm::event::KeyEvent::new(KeyCode::Esc, event::KeyModifiers::NONE);
702
703 assert_eq!(key_event_action(q, true), TuiInputAction::Quit);
704 assert_eq!(key_event_action(esc, true), TuiInputAction::Quit);
705 }
706
707 #[test]
708 fn key_event_action_supports_dashboard_toggles_only_when_enabled() {
709 let log_toggle =
710 crossterm::event::KeyEvent::new(KeyCode::Char('l'), event::KeyModifiers::NONE);
711 let details_toggle =
712 crossterm::event::KeyEvent::new(KeyCode::Char('d'), event::KeyModifiers::NONE);
713
714 assert_eq!(
715 key_event_action(log_toggle, true),
716 TuiInputAction::ToggleLogFullscreen
717 );
718 assert_eq!(
719 key_event_action(details_toggle, true),
720 TuiInputAction::ToggleDetails
721 );
722 assert_eq!(key_event_action(log_toggle, false), TuiInputAction::None);
723 assert_eq!(
724 key_event_action(details_toggle, false),
725 TuiInputAction::None
726 );
727 }
728
729 #[test]
730 fn key_event_action_ignores_key_releases_and_other_input() {
731 let released = crossterm::event::KeyEvent {
732 code: KeyCode::Char('q'),
733 modifiers: event::KeyModifiers::NONE,
734 kind: KeyEventKind::Release,
735 state: event::KeyEventState::NONE,
736 };
737 let other = crossterm::event::KeyEvent::new(KeyCode::Char('x'), event::KeyModifiers::NONE);
738
739 assert_eq!(key_event_action(released, true), TuiInputAction::None);
740 assert_eq!(key_event_action(other, true), TuiInputAction::None);
741 }
742
743 #[test]
744 fn key_event_action_accepts_repeat_events_for_attached_tui() {
745 let repeated = crossterm::event::KeyEvent {
746 code: KeyCode::Char('d'),
747 modifiers: event::KeyModifiers::NONE,
748 kind: KeyEventKind::Repeat,
749 state: event::KeyEventState::NONE,
750 };
751
752 assert_eq!(
753 key_event_action(repeated, true),
754 TuiInputAction::ToggleDetails
755 );
756 }
757
758 #[test]
759 fn handle_attached_tui_action_applies_overrides() {
760 let state = DashboardState {
761 metrics: crate::tui::dashboard::DashboardMetrics::default(),
762 backend_views: Vec::new(),
763 top_users: Vec::new(),
764 client_history: Vec::new(),
765 system_stats: SystemStats::default(),
766 view_mode: ViewMode::Normal,
767 show_details: false,
768 log_lines: vec!["line".to_string()],
769 buffer_pool: None,
770 };
771 let mut overrides = AttachedViewOverrides::default();
772 let (log_tail_tx, log_tail_rx) =
773 tokio::sync::watch::channel(attached_log_line_limit(ViewMode::Normal));
774
775 assert!(!handle_attached_tui_action(
776 TuiInputAction::ToggleDetails,
777 &mut overrides,
778 Some(&state),
779 &log_tail_tx,
780 ));
781 assert!(!handle_attached_tui_action(
782 TuiInputAction::ToggleLogFullscreen,
783 &mut overrides,
784 Some(&state),
785 &log_tail_tx,
786 ));
787
788 let attached =
789 AttachedDashboard::connected("127.0.0.1:8120".parse().unwrap(), Some(Arc::new(state)));
790 let rendered = renderable_attached_state(&attached, &overrides).expect("dashboard state");
791 assert!(attached.render_cache.is_some());
792 assert!(rendered.show_details);
793 assert_eq!(rendered.view_mode, ViewMode::LogFullscreen);
794 assert_eq!(rendered.state.log_lines, vec!["line".to_string()]);
795 assert_eq!(
796 *log_tail_rx.borrow(),
797 attached_log_line_limit(ViewMode::LogFullscreen)
798 );
799 }
800
801 #[test]
802 fn attached_log_line_limit_tracks_view_mode() {
803 assert_eq!(
804 attached_log_line_limit(ViewMode::Normal),
805 remote::REMOTE_DASHBOARD_LOG_LINE_LIMIT
806 );
807 assert_eq!(
808 attached_log_line_limit(ViewMode::LogFullscreen),
809 remote::REMOTE_DASHBOARD_FULLSCREEN_LOG_LINE_LIMIT
810 );
811 }
812
813 #[test]
814 fn attached_placeholder_lines_reflect_connection_state() {
815 let connecting = build_attached_placeholder_lines(&RemoteDashboardStatus::Connecting {
816 target: "127.0.0.1:8120".parse().unwrap(),
817 })
818 .into_iter()
819 .map(|line| line.to_string())
820 .collect::<Vec<_>>();
821 let reconnecting = build_attached_placeholder_lines(&RemoteDashboardStatus::Reconnecting {
822 target: "127.0.0.1:8120".parse().unwrap(),
823 retry_delay: Duration::from_secs(1),
824 last_error: "connection refused".to_string(),
825 })
826 .into_iter()
827 .map(|line| line.to_string())
828 .collect::<Vec<_>>();
829
830 assert!(
831 connecting
832 .iter()
833 .any(|line| line.contains("Connecting to 127.0.0.1:8120"))
834 );
835 assert!(
836 reconnecting
837 .iter()
838 .any(|line| line.contains("Reconnecting to 127.0.0.1:8120 in 1.0s"))
839 );
840 assert!(
841 reconnecting
842 .iter()
843 .any(|line| line.contains("Last error: connection refused"))
844 );
845 }
846
847 #[test]
848 fn attached_placeholder_chunks_match_previous_geometry() {
849 let chunks = attached_placeholder_chunks(Rect::new(0, 0, 80, 24));
850 assert_eq!(chunks[0], Rect::new(1, 1, 78, 3));
851 assert_eq!(chunks[1], Rect::new(1, 4, 78, 16));
852 assert_eq!(chunks[2], Rect::new(1, 20, 78, 3));
853 }
854
855 #[tokio::test]
856 async fn run_attached_tui_rejects_non_loopback_target() {
857 let err = run_attached_tui("10.0.0.5:8120".parse().unwrap())
858 .await
859 .expect_err("non-loopback attach should fail");
860
861 assert!(format!("{err:#}").contains("must connect to a loopback address"));
862 }
863
864 #[test]
865 fn renderable_dashboard_state_leaves_logs_for_direct_local_rendering() {
866 let log_buffer = LogBuffer::new();
867 for i in 0..300 {
868 log_buffer.push(format!("line {i}"));
869 }
870
871 let app = TuiAppBuilder::new(
872 MetricsCollector::new(1),
873 Arc::new(BackendSelector::new()),
874 Arc::from(vec![
875 Server::builder("backend.example.com", Port::try_new(119).unwrap())
876 .name("Backend")
877 .build()
878 .unwrap(),
879 ]),
880 )
881 .with_log_buffer(log_buffer)
882 .build();
883
884 let state = renderable_dashboard_state(&app);
885
886 assert!(state.log_lines.is_empty());
887 assert_eq!(
888 app.log_buffer().recent_lines(1).first().map(String::as_str),
889 Some("line 299")
890 );
891 }
892
893 #[test]
894 fn renderable_attached_state_applies_local_overrides() {
895 let state = DashboardState {
896 metrics: crate::tui::dashboard::DashboardMetrics::default(),
897 backend_views: Vec::new(),
898 top_users: Vec::new(),
899 client_history: Vec::new(),
900 system_stats: SystemStats::default(),
901 view_mode: ViewMode::Normal,
902 show_details: false,
903 log_lines: vec!["line".to_string()],
904 buffer_pool: None,
905 };
906 let attached =
907 AttachedDashboard::connected("127.0.0.1:8120".parse().unwrap(), Some(Arc::new(state)));
908 let mut overrides = AttachedViewOverrides::default();
909
910 overrides.toggle_details(attached.latest_state.as_deref());
911 overrides.toggle_log_fullscreen(attached.latest_state.as_deref());
912
913 let rendered = renderable_attached_state(&attached, &overrides).expect("dashboard state");
914 assert!(attached.render_cache.is_some());
915 assert!(rendered.show_details);
916 assert_eq!(rendered.view_mode, ViewMode::LogFullscreen);
917 assert_eq!(rendered.state.log_lines, vec!["line".to_string()]);
918 }
919
920 #[test]
921 fn renderable_attached_state_preserves_local_overrides_across_new_remote_snapshots() {
922 let state = DashboardState {
923 metrics: crate::tui::dashboard::DashboardMetrics::default(),
924 backend_views: Vec::new(),
925 top_users: Vec::new(),
926 client_history: Vec::new(),
927 system_stats: SystemStats::default(),
928 view_mode: ViewMode::Normal,
929 show_details: false,
930 log_lines: vec!["first".to_string()],
931 buffer_pool: None,
932 };
933 let mut overrides = AttachedViewOverrides::default();
934 overrides.toggle_details(Some(&state));
935 overrides.toggle_log_fullscreen(Some(&state));
936
937 let next_state = DashboardState {
938 log_lines: vec!["second".to_string()],
939 ..state
940 };
941 let attached = AttachedDashboard::connected(
942 "127.0.0.1:8120".parse().unwrap(),
943 Some(Arc::new(next_state)),
944 );
945
946 let rendered = renderable_attached_state(&attached, &overrides).expect("dashboard state");
947 assert!(attached.render_cache.is_some());
948 assert!(rendered.show_details);
949 assert_eq!(rendered.view_mode, ViewMode::LogFullscreen);
950 assert_eq!(rendered.state.log_lines, vec!["second".to_string()]);
951 }
952}