diff --git a/crates/lantern-tui/src/bus_diagnostics_render.rs b/crates/lantern-tui/src/bus_diagnostics_render.rs new file mode 100644 index 00000000..ec9c333d --- /dev/null +++ b/crates/lantern-tui/src/bus_diagnostics_render.rs @@ -0,0 +1,116 @@ +use lantern_app::{ApplicationView, MonitoringDiagnosticsView}; +use ratatui::text::Line; + +/// Presentation-only Bus Diagnostics view. +/// +/// All values originate in the immutable monitoring snapshot exposed through `ApplicationView`. +/// This renderer has no transport handle and cannot issue Modbus requests. +pub(crate) fn bus_diagnostics_lines(view: &ApplicationView) -> Vec> { + if view.active_session().is_none() { + return vec![ + Line::from("Verified session required."), + Line::from( + "Bus diagnostics reads immutable application snapshots and issues no Modbus requests.", + ), + ]; + } + + let monitoring = view.monitoring(); + let mut lines = vec![ + Line::from(format!( + "session={} profile={} connectivity={:?}", + view.active_session() + .map_or_else(|| "—".to_owned(), |id| id.get().to_string()), + view.session().verified_profile_id().unwrap_or("—"), + view.session().phase(), + )), + Line::from( + "Immutable diagnostics snapshot only; opening this screen creates no bus traffic.", + ), + Line::from(""), + ]; + + lines.extend( + diagnostics_text(monitoring.diagnostics) + .into_iter() + .map(Line::from), + ); + if let Some(error) = &monitoring.error { + lines.push(Line::from("")); + lines.push(Line::from(format!("MONITORING ERROR: {error}"))); + } + lines +} + +fn diagnostics_text(diagnostics: MonitoringDiagnosticsView) -> Vec { + vec![ + "RTU / transport".to_owned(), + format!( + " round-trip-p95={} bus-utilization={}", + format_micros(diagnostics.round_trip_p95_micros), + format_ppm(diagnostics.bus_utilization_ppm), + ), + format!( + " timeout-events={} queue-full={}", + diagnostics.timeout_events, diagnostics.queue_full, + ), + "Polling".to_owned(), + format!( + " plan-utilization={} deadline-skips={} result-drops={}", + format_ppm(diagnostics.plan_utilization_ppm), + diagnostics.poll_deadlines_skipped, + diagnostics.poll_results_dropped, + ), + "Bounded consumer queues".to_owned(), + format!( + " csv-drops={} fault-drops={} diagnostics-drops={}", + diagnostics.csv_drops, diagnostics.fault_drops, diagnostics.diagnostics_drops, + ), + ] +} + +fn format_micros(value: Option) -> String { + value.map_or_else(|| "—".to_owned(), |value| format!("{value}µs")) +} + +fn format_ppm(ppm: u32) -> String { + let whole = ppm / 10_000; + let tenth = (ppm % 10_000) / 1_000; + format!("{whole}.{tenth}%") +} + +#[cfg(test)] +mod tests { + use lantern_app::MonitoringDiagnosticsView; + + use super::diagnostics_text; + + #[test] + fn diagnostics_text_projects_existing_snapshot_without_transport_state() { + let text = diagnostics_text(MonitoringDiagnosticsView { + round_trip_p95_micros: Some(875), + plan_utilization_ppm: 321_000, + bus_utilization_ppm: 456_000, + timeout_events: 7, + queue_full: 2, + poll_deadlines_skipped: 3, + poll_results_dropped: 4, + csv_drops: 5, + fault_drops: 6, + diagnostics_drops: 7, + }) + .join("\n"); + + assert!(text.contains("round-trip-p95=875µs bus-utilization=45.6%")); + assert!(text.contains("timeout-events=7 queue-full=2")); + assert!(text.contains("plan-utilization=32.1% deadline-skips=3 result-drops=4")); + assert!(text.contains("csv-drops=5 fault-drops=6 diagnostics-drops=7")); + } + + #[test] + fn diagnostics_text_keeps_missing_latency_explicit() { + let text = diagnostics_text(MonitoringDiagnosticsView::default()).join("\n"); + assert!(text.contains("round-trip-p95=—")); + assert!(text.contains("bus-utilization=0.0%")); + } +} diff --git a/crates/lantern-tui/src/lib.rs b/crates/lantern-tui/src/lib.rs index 47e09d44..1b3e5d5c 100644 --- a/crates/lantern-tui/src/lib.rs +++ b/crates/lantern-tui/src/lib.rs @@ -3,6 +3,7 @@ #![forbid(unsafe_code)] mod backup_render; +mod bus_diagnostics_render; mod fault_keymap; mod fault_render; mod fault_state; @@ -44,6 +45,7 @@ use ratatui::{ use crate::{ backup_render::backup_lines, + bus_diagnostics_render::bus_diagnostics_lines, screens::render_screen, widgets::{render_footer, render_header, render_modal, render_navigation, render_too_small}, }; @@ -69,9 +71,14 @@ pub fn render(frame: &mut Frame<'_>, view: &ApplicationView, ui: &UiState, theme render_header(frame, header, view, theme); render_navigation(frame, navigation, ui, theme); - if ui.screen == Screen::Backup { + let custom_lines = match ui.screen { + Screen::Backup => Some(backup_lines(view, ui)), + Screen::BusDiagnostics => Some(bus_diagnostics_lines(view)), + _ => None, + }; + if let Some(lines) = custom_lines { let scroll = u16::try_from(ui.scroll_offset).unwrap_or(u16::MAX); - let paragraph = Paragraph::new(Text::from(backup_lines(view, ui))) + let paragraph = Paragraph::new(Text::from(lines)) .block(Block::bordered().title(format!(" {} ", ui.screen.title()))) .wrap(Wrap { trim: false }) .scroll((scroll, 0)) @@ -152,6 +159,24 @@ mod tests { assert!(!text.contains("presentation boundary is ready")); } + #[test] + fn bus_diagnostics_screen_is_not_a_placeholder() { + let backend = TestBackend::new(120, 32); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let view = ApplicationView::default(); + let ui = UiState { + screen: Screen::BusDiagnostics, + ..UiState::default() + }; + terminal + .draw(|frame| render(frame, &view, &ui, Theme::new(false))) + .expect("draw"); + let text = buffer_text(terminal.backend().buffer()); + assert!(text.contains("Verified session required.")); + assert!(text.contains("issues no Modbus requests")); + assert!(!text.contains("later diagnostics integration")); + } + #[test] fn undersized_terminal_renders_only_resize_warning() { let backend = TestBackend::new(40, 10);