diff --git a/CLAUDE.md b/CLAUDE.md index 3bd4833..b9fa3e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,20 +40,35 @@ Storage: └─ ● 18% 167.4GB/928.2GB ``` -**Outstanding Issue:** -- Dashboard displays "Build: unknown" despite agent collecting metrics correctly -- Need to investigate ZMQ communication or dashboard metric filtering +**Current Status - October 23, 2025:** +- System panel layout fully implemented with blue tree symbols ✅ +- Backup panel layout restructured per specification ✅ +- Tree symbols now use consistent blue theming across all panels ✅ +- Overflow handling restored for all widgets ("... and X more") ✅ +- Agent hash display working correctly ✅ -### Future Priorities +### Next Implementation Phase: Keyboard Navigation & UI Enhancement -**Keyboard Navigation (Dashboard):** -- Change host switching to "Shift-Tab" -- Add panel navigation with "Tab" -- Add scrolling support for overflow content +**Phase 1 - Panel Navigation (In Progress):** +- Add panel focus state management to TuiApp +- Implement Shift-Tab for panel cycling (System → Services → Backup → Network) +- Keep Tab for host switching as current behavior +- Add visual focus indicators to panel borders -**Remote Execution (Agent/Dashboard):** -- Dynamic statusbar with context shortcuts -- Remote nixos rebuild commands +**Phase 2 - Dynamic Statusbar:** +- Create bottom statusbar showing context-aware shortcuts +- System panel: "R: Rebuild", "S: Services" +- Services panel: "Space: Start/Stop", "R: Restart" +- Backup panel: "B: Trigger Backup" +- Global: "Tab: Switch Host", "Shift-Tab: Switch Panel" + +**Phase 3 - Scrolling Support:** +- Add Up/Down arrow key handling within focused panels +- Implement scroll offset tracking for overflow content +- Add scroll position indicators (↑/↓) when needed + +**Future - Remote Execution:** +- Remote nixos rebuild commands via dashboard - Service start/stop/restart controls - Backup trigger functionality diff --git a/dashboard/src/ui/mod.rs b/dashboard/src/ui/mod.rs index 466b461..f61426f 100644 --- a/dashboard/src/ui/mod.rs +++ b/dashboard/src/ui/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use crossterm::event::{Event, KeyCode}; +use crossterm::event::{Event, KeyCode, KeyModifiers}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::Style, @@ -18,6 +18,42 @@ use cm_dashboard_shared::{Metric, Status}; use theme::{Components, Layout as ThemeLayout, StatusIcons, Theme, Typography}; use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget}; +/// Panel types for focus management +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelType { + System, + Services, + Backup, + Network, +} + +impl PanelType { + /// Get all panel types in order + pub fn all() -> [PanelType; 4] { + [PanelType::System, PanelType::Services, PanelType::Backup, PanelType::Network] + } + + /// Get the next panel in cycle + pub fn next(self) -> PanelType { + match self { + PanelType::System => PanelType::Services, + PanelType::Services => PanelType::Backup, + PanelType::Backup => PanelType::Network, + PanelType::Network => PanelType::System, + } + } + + /// Get the previous panel in cycle + pub fn previous(self) -> PanelType { + match self { + PanelType::System => PanelType::Network, + PanelType::Services => PanelType::System, + PanelType::Backup => PanelType::Services, + PanelType::Network => PanelType::Backup, + } + } +} + /// Widget states for a specific host #[derive(Clone)] pub struct HostWidgets { @@ -27,6 +63,11 @@ pub struct HostWidgets { pub services_widget: ServicesWidget, /// Backup widget state pub backup_widget: BackupWidget, + /// Scroll offsets for each panel + pub system_scroll_offset: usize, + pub services_scroll_offset: usize, + pub backup_scroll_offset: usize, + pub network_scroll_offset: usize, /// Last update time for this host pub last_update: Option, } @@ -37,6 +78,10 @@ impl HostWidgets { system_widget: SystemWidget::new(), services_widget: ServicesWidget::new(), backup_widget: BackupWidget::new(), + system_scroll_offset: 0, + services_scroll_offset: 0, + backup_scroll_offset: 0, + network_scroll_offset: 0, last_update: None, } } @@ -52,6 +97,8 @@ pub struct TuiApp { available_hosts: Vec, /// Host index for navigation host_index: usize, + /// Currently focused panel + focused_panel: PanelType, /// Should quit application should_quit: bool, /// Track if user manually navigated away from localhost @@ -65,6 +112,7 @@ impl TuiApp { current_host: None, available_hosts: Vec::new(), host_index: 0, + focused_panel: PanelType::System, // Start with System panel focused should_quit: false, user_navigated_away: false, } @@ -196,7 +244,25 @@ impl TuiApp { // Refresh will be handled by main loop } KeyCode::Tab => { - self.navigate_host(1); // Tab cycles to next host + if key.modifiers.contains(KeyModifiers::SHIFT) { + // Shift+Tab cycles through panels + self.next_panel(); + } else { + // Tab cycles to next host + self.navigate_host(1); + } + } + KeyCode::BackTab => { + // BackTab (Shift+Tab on some terminals) also cycles panels + self.next_panel(); + } + KeyCode::Up => { + // Scroll up in focused panel + self.scroll_focused_panel(-1); + } + KeyCode::Down => { + // Scroll down in focused panel + self.scroll_focused_panel(1); } _ => {} } @@ -236,6 +302,66 @@ impl TuiApp { info!("Switched to host: {}", self.current_host.as_ref().unwrap()); } + /// Switch to next panel (Shift+Tab) + pub fn next_panel(&mut self) { + self.focused_panel = self.focused_panel.next(); + info!("Switched to panel: {:?}", self.focused_panel); + } + + /// Switch to previous panel (Shift+Tab in reverse) + pub fn previous_panel(&mut self) { + self.focused_panel = self.focused_panel.previous(); + info!("Switched to panel: {:?}", self.focused_panel); + } + + /// Get the currently focused panel + pub fn get_focused_panel(&self) -> PanelType { + self.focused_panel + } + + /// Scroll the focused panel up or down + pub fn scroll_focused_panel(&mut self, direction: i32) { + if let Some(hostname) = self.current_host.clone() { + let focused_panel = self.focused_panel; // Get the value before borrowing + let host_widgets = self.get_or_create_host_widgets(&hostname); + + match focused_panel { + PanelType::System => { + if direction > 0 { + host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_add(1); + } else { + host_widgets.system_scroll_offset = host_widgets.system_scroll_offset.saturating_sub(1); + } + info!("System panel scroll offset: {}", host_widgets.system_scroll_offset); + } + PanelType::Services => { + if direction > 0 { + host_widgets.services_scroll_offset = host_widgets.services_scroll_offset.saturating_add(1); + } else { + host_widgets.services_scroll_offset = host_widgets.services_scroll_offset.saturating_sub(1); + } + info!("Services panel scroll offset: {}", host_widgets.services_scroll_offset); + } + PanelType::Backup => { + if direction > 0 { + host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_add(1); + } else { + host_widgets.backup_scroll_offset = host_widgets.backup_scroll_offset.saturating_sub(1); + } + info!("Backup panel scroll offset: {}", host_widgets.backup_scroll_offset); + } + PanelType::Network => { + if direction > 0 { + host_widgets.network_scroll_offset = host_widgets.network_scroll_offset.saturating_add(1); + } else { + host_widgets.network_scroll_offset = host_widgets.network_scroll_offset.saturating_sub(1); + } + info!("Network panel scroll offset: {}", host_widgets.network_scroll_offset); + } + } + } + } + /// Render the dashboard (real btop-style multi-panel layout) pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) { let size = frame.size(); @@ -247,13 +373,13 @@ impl TuiApp { ); // Create real btop-style layout: multi-panel with borders - // Top section: title bar - // Bottom section: split into left (mem + disks) and right (CPU + processes) + // Three-section layout: title bar, main content, statusbar let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Title bar Constraint::Min(0), // Main content area + Constraint::Length(1), // Statusbar ]) .split(size); @@ -264,7 +390,7 @@ impl TuiApp { Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height) ]) - .split(main_chunks[1]); + .split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar) // Check if backup panel should be shown let show_backup = if let Some(hostname) = self.current_host.clone() { @@ -303,11 +429,15 @@ impl TuiApp { // Render services widget for current host if let Some(hostname) = self.current_host.clone() { + let is_focused = self.focused_panel == PanelType::Services; let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets .services_widget - .render(frame, content_chunks[1]); // Services takes full right side + .render_with_focus(frame, content_chunks[1], is_focused); // Services takes full right side } + + // Render statusbar at the bottom + self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area } /// Render btop-style minimal title with host status colors @@ -406,19 +536,77 @@ impl TuiApp { } } + /// Render dynamic statusbar with context-aware shortcuts + fn render_statusbar(&self, frame: &mut Frame, area: Rect) { + let shortcuts = self.get_context_shortcuts(); + let statusbar_text = shortcuts.join(" • "); + + let statusbar = Paragraph::new(statusbar_text) + .style(Typography::secondary()) + .alignment(ratatui::layout::Alignment::Center); + + frame.render_widget(statusbar, area); + } + + /// Get context-aware shortcuts based on focused panel + fn get_context_shortcuts(&self) -> Vec { + let mut shortcuts = Vec::new(); + + // Global shortcuts + shortcuts.push("Tab: Switch Host".to_string()); + shortcuts.push("Shift+Tab: Switch Panel".to_string()); + + // Scroll shortcuts (always available) + shortcuts.push("↑↓: Scroll".to_string()); + + // Panel-specific shortcuts + match self.focused_panel { + PanelType::System => { + shortcuts.push("R: Rebuild".to_string()); + } + PanelType::Services => { + shortcuts.push("Space: Start/Stop".to_string()); + shortcuts.push("R: Restart".to_string()); + } + PanelType::Backup => { + shortcuts.push("B: Trigger Backup".to_string()); + } + PanelType::Network => { + shortcuts.push("N: Network Info".to_string()); + } + } + + // Always show quit + shortcuts.push("Q: Quit".to_string()); + + shortcuts + } + fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { - let system_block = Components::widget_block("system"); + let system_block = if self.focused_panel == PanelType::System { + Components::focused_widget_block("system") + } else { + Components::widget_block("system") + }; let inner_area = system_block.inner(area); frame.render_widget(system_block, area); // Get current host widgets, create if none exist if let Some(hostname) = self.current_host.clone() { + let scroll_offset = { + let host_widgets = self.get_or_create_host_widgets(&hostname); + host_widgets.system_scroll_offset + }; let host_widgets = self.get_or_create_host_widgets(&hostname); - host_widgets.system_widget.render(frame, inner_area); + host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset); } } fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) { - let backup_block = Components::widget_block("backup"); + let backup_block = if self.focused_panel == PanelType::Backup { + Components::focused_widget_block("backup") + } else { + Components::widget_block("backup") + }; let inner_area = backup_block.inner(area); frame.render_widget(backup_block, area); diff --git a/dashboard/src/ui/theme.rs b/dashboard/src/ui/theme.rs index f65568d..2152ed8 100644 --- a/dashboard/src/ui/theme.rs +++ b/dashboard/src/ui/theme.rs @@ -292,6 +292,19 @@ impl Components { .bg(Theme::background()), ) } + + /// Widget block with focus indicator (blue border) + pub fn focused_widget_block(title: &str) -> Block<'_> { + Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().fg(Theme::highlight()).bg(Theme::background())) // Blue border for focus + .title_style( + Style::default() + .fg(Theme::highlight()) // Blue title for focus + .bg(Theme::background()), + ) + } } impl Typography { diff --git a/dashboard/src/ui/widgets/services.rs b/dashboard/src/ui/widgets/services.rs index b10c0ad..17497c0 100644 --- a/dashboard/src/ui/widgets/services.rs +++ b/dashboard/src/ui/widgets/services.rs @@ -308,7 +308,18 @@ impl Widget for ServicesWidget { } fn render(&mut self, frame: &mut Frame, area: Rect) { - let services_block = Components::widget_block("services"); + self.render_with_focus(frame, area, false); + } +} + +impl ServicesWidget { + /// Render with optional focus indicator + pub fn render_with_focus(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) { + let services_block = if is_focused { + Components::focused_widget_block("services") + } else { + Components::widget_block("services") + }; let inner_area = services_block.inner(area); frame.render_widget(services_block, area); diff --git a/dashboard/src/ui/widgets/system.rs b/dashboard/src/ui/widgets/system.rs index d00e2c9..276e6e5 100644 --- a/dashboard/src/ui/widgets/system.rs +++ b/dashboard/src/ui/widgets/system.rs @@ -399,6 +399,13 @@ impl Widget for SystemWidget { } fn render(&mut self, frame: &mut Frame, area: Rect) { + self.render_with_scroll(frame, area, 0); + } +} + +impl SystemWidget { + /// Render with scroll offset support + pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) { let mut lines = Vec::new(); // NixOS section @@ -509,7 +516,28 @@ impl Widget for SystemWidget { } } - let paragraph = Paragraph::new(Text::from(lines)); - frame.render_widget(paragraph, area); + // Apply scroll offset + let total_lines = lines.len(); + let available_height = area.height as usize; + + if total_lines > available_height { + // Content is larger than area, apply scrolling + let max_scroll = total_lines.saturating_sub(available_height); + let effective_scroll = scroll_offset.min(max_scroll); + + // Take only the visible portion after scrolling + let visible_lines: Vec = lines + .into_iter() + .skip(effective_scroll) + .take(available_height) + .collect(); + + let paragraph = Paragraph::new(Text::from(visible_lines)); + frame.render_widget(paragraph, area); + } else { + // All content fits, render normally + let paragraph = Paragraph::new(Text::from(lines)); + frame.render_widget(paragraph, area); + } } } \ No newline at end of file