Skip to main content

nntp_proxy/tui/
mod.rs

1//! Terminal User Interface (TUI) for the NNTP proxy
2//!
3//! Provides real-time visualization of proxy metrics when running in an interactive terminal.
4
5mod 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
151/// Setup the terminal for TUI rendering
152fn 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
162/// Restore the terminal to its original state
163fn restore_terminal(terminal: &mut TuiTerminal) -> Result<()> {
164    // Clear the terminal first to prevent escape sequences leaking to shell
165    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
192/// Enter the alternate screen immediately and draw the normal dashboard shell.
193///
194/// The returned session owns the terminal until it is handed to the live local
195/// TUI. This keeps interactive launches from presenting a blank terminal while
196/// config loading, proxy construction, and connection prewarm run.
197///
198/// # Errors
199/// Returns terminal setup or draw errors.
200pub 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
267/// Run the TUI event loop
268///
269/// This function takes ownership of the terminal and runs until the user presses 'q' or Ctrl+C,
270/// or until a shutdown signal is received externally.
271/// When the TUI exits, it sends a shutdown signal via the provided channel.
272///
273/// # Arguments
274/// * `app` - The TUI application state
275/// * `shutdown_tx` - Sender to signal shutdown when TUI exits
276/// * `shutdown_rx` - Receiver to listen for external shutdown signals
277///
278/// # Returns
279/// Ok(()) when the TUI exits normally, or an error if terminal operations fail
280///
281/// # Errors
282/// Returns any terminal setup, drawing, input, shutdown-signal, or restore error
283/// encountered while running the interactive UI.
284pub 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    // Signal shutdown when TUI exits
295    let _ = shutdown_tx.send(()).await;
296
297    result
298}
299
300/// Run the local TUI using a terminal session that was opened before startup.
301///
302/// # Errors
303/// Returns terminal drawing, input, shutdown, or restore errors.
304pub 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
313/// Run the TUI as a remote dashboard client connected to a headless publisher.
314///
315/// # Errors
316/// Returns an error when the target is non-loopback or when terminal setup,
317/// drawing, input, or restore operations fail.
318pub 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
334/// Main TUI event loop
335async 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    // Create update interval (4 times per second for responsive UI)
344    let mut update_interval = tokio::time::interval(Duration::from_millis(250));
345    let mut input_rx = spawn_tui_input_reader(true);
346
347    // Initial render
348    terminal
349        .draw(|f| ui::render_ui(f, &renderable_dashboard_state(app), None, None, None, None))?;
350
351    loop {
352        tokio::select! {
353            // External shutdown signal
354            _ = 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 timer - check for events only on ticks to reduce overhead
374            _ = 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}