use crate::{ controls::button::{ActionButton, ButtonIntent, render_button}, interaction_result::InteractionResult, ipc_client::{DaemonStatus, IpcConnectionState}, render_context::RenderContext, screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, }; use crossterm::event::KeyCode; use ratatui::{ Frame, layout::Rect, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; use std::{ any::Any, sync::atomic::{AtomicUsize, Ordering}, }; use tokio::sync::watch; pub struct OverviewScreen { connection_rx: watch::Receiver, daemon_rx: watch::Receiver, _focus: Focus, scroll_offset: usize, content_height: AtomicUsize, viewport_height: AtomicUsize, } #[derive(Clone, Copy, PartialEq, Eq)] enum Focus { Back, } impl OverviewScreen { pub fn new( connection_rx: watch::Receiver, daemon_rx: watch::Receiver, ) -> Self { Self { connection_rx, daemon_rx, _focus: Focus::Back, scroll_offset: 0, content_height: AtomicUsize::new(0), viewport_height: AtomicUsize::new(1), } } fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { let conn = self.connection_rx.borrow().clone(); let daemon = self.daemon_rx.borrow().clone(); let mut lines = Vec::new(); lines.push(Line::from(Span::styled("Connection", theme.text.heading))); lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); let omikron = daemon.components.get(&iota_ipc::ComponentId::Omikron); let omikron_label = match omikron.map(|health| health.status) { Some(iota_ipc::HealthStatus::Healthy) => "[OK] Connected", Some(iota_ipc::HealthStatus::Degraded) => "[WARN] Connecting or unavailable", Some(iota_ipc::HealthStatus::Failed) => "[FAIL] Authentication failed", None => "Unknown", }; lines.push(Line::from(format!(" Omikron: {omikron_label}"))); if let Some(message) = omikron.and_then(|health| health.message.as_deref()) { lines.push(Line::from(format!(" Omikron detail: {message}"))); } lines.push(Line::from("")); lines.push(Line::from(Span::styled("Daemon", theme.text.heading))); lines.push(Line::from(format!( " Version: {}", version_or_unknown(&daemon.version) ))); lines.push(Line::from(format!( " Instance: {}", truncate_id(&daemon.instance_id) ))); let phase = daemon .startup_phase .map(|p| format!("{:?}", p)) .unwrap_or_else(|| "Unknown".into()); lines.push(Line::from(format!(" Phase: {}", phase))); let lifecycle = daemon .lifecycle .map(|l| format!("{:?}", l)) .unwrap_or_else(|| "Unknown".into()); lines.push(Line::from(format!(" Lifecycle: {}", lifecycle))); let health = match daemon.health { iota_ipc::HealthStatus::Healthy => "[OK] Healthy", iota_ipc::HealthStatus::Degraded => "[WARN] Degraded", iota_ipc::HealthStatus::Failed => "[FAIL] Failed", }; lines.push(Line::from(format!(" Health: {health}"))); if let Some(ref reason) = daemon.degraded_reason { lines.push(Line::from(Span::styled( format!(" Degraded: {reason}"), theme.status.warning, ))); } let mode = daemon .deployment_mode .map(|m| format!("{:?}", m)) .unwrap_or_else(|| "Unknown".into()); lines.push(Line::from(format!(" Deployment: {mode}"))); let supervisor = daemon .supervisor .map(|s| format!("{:?}", s)) .unwrap_or_else(|| "Unknown".into()); lines.push(Line::from(format!(" Supervisor: {supervisor}"))); if !daemon.components.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled("Components", theme.text.heading))); for (id, health) in &daemon.components { let status_str = match health.status { iota_ipc::HealthStatus::Healthy => "[OK] healthy", iota_ipc::HealthStatus::Degraded => "[WARN] degraded", iota_ipc::HealthStatus::Failed => "[FAIL] failed", }; let suffix = health .message .as_deref() .map(|m| format!(" ({m})")) .unwrap_or_default(); lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix))); } } lines.push(Line::from("")); lines.push(Line::from(Span::styled( "Press Esc or B to return to the dashboard", theme.text.muted, ))); lines } } fn connection_label(conn: &IpcConnectionState) -> String { match conn { IpcConnectionState::Connected => "Connected".into(), IpcConnectionState::Connecting => "Connecting...".into(), IpcConnectionState::Reconnecting { attempt } => { format!("Reconnecting (attempt {attempt})...") } IpcConnectionState::Incompatible { message } => { format!("Incompatible: {message}") } IpcConnectionState::Failed { message } => format!("Failed: {message}"), IpcConnectionState::Disconnected => "Disconnected".into(), } } fn version_or_unknown(v: &str) -> String { if v.is_empty() { "Unknown".into() } else { v.into() } } fn truncate_id(id: &str) -> String { if id.len() > 8 { format!("{}…", &id[..8]) } else { id.into() } } impl Screen for OverviewScreen { fn as_any(&self) -> &dyn Any { self } fn as_any_mut(&mut self) -> &mut dyn Any { self } fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let block = Block::default() .title(" Overview ") .borders(Borders::ALL) .border_style(context.theme.borders.normal) .title_style(context.theme.borders.title); let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) } else { let inner = block.inner(rect); f.render_widget(block, rect); inner }; let rows = ratatui::layout::Layout::vertical([ ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Length(1), ]) .split(inner); let lines = self.build_lines(context.theme); self.content_height.store(lines.len(), Ordering::Relaxed); self.viewport_height .store(rows[0].height as usize, Ordering::Relaxed); let par = Paragraph::new(lines) .wrap(Wrap { trim: true }) .scroll((self.scroll_offset as u16, 0)); f.render_widget(par, rows[0]); render_button( f, rows[1], ActionButton { label: "Back", intent: ButtonIntent::Cancel, focused: self._focus == Focus::Back, enabled: true, }, context.theme, ); _hits.register(rows[1], AppAction::Back); } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; match event.code { KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { InteractionResult::CloseScreen } KeyCode::Down | KeyCode::Char('j') => { let max = self .content_height .load(Ordering::Relaxed) .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); InteractionResult::Handled } KeyCode::Up | KeyCode::Char('k') => { self.scroll_offset = self.scroll_offset.saturating_sub(1); InteractionResult::Handled } KeyCode::PageDown => { let page = self.viewport_height.load(Ordering::Relaxed).max(1); let max = self .content_height .load(Ordering::Relaxed) .saturating_sub(page); self.scroll_offset = self.scroll_offset.saturating_add(page).min(max); InteractionResult::Handled } KeyCode::PageUp => { let page = self.viewport_height.load(Ordering::Relaxed).max(1); self.scroll_offset = self.scroll_offset.saturating_sub(page); InteractionResult::Handled } KeyCode::Home => { self.scroll_offset = 0; InteractionResult::Handled } KeyCode::End => { self.scroll_offset = self .content_height .load(Ordering::Relaxed) .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); InteractionResult::Handled } _ => InteractionResult::Unhandled, } } fn handle_action(&mut self, action: AppAction) -> InteractionResult { if action == AppAction::Back { InteractionResult::CloseScreen } else { InteractionResult::Unhandled } } fn key_hints(&self) -> Vec { vec![ KeyHint { keys: "Up/Down", action: "Scroll", }, KeyHint { keys: "PgUp/PgDn", action: "Page", }, KeyHint { keys: "Esc/B", action: "Back", }, KeyHint { keys: "F6", action: "Header", }, ] } }