Compare commits

...

11 Commits

Author SHA1 Message Date
c8f800a1e5 Implement git commit hash tracking for build display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m24s
- Add get_git_commit() method to read /var/lib/cm-dashboard/git-commit
- Replace NixOS build version with actual git commit hash
- Show deployed commit hash as 'Build:' value for accurate tracking
- Enable verification of which exact commit is deployed per host
- Update version to 0.1.42
2025-10-29 15:29:02 +01:00
fc6b3424cf Add hostname to NixOS title and make dashboard title bold
All checks were successful
Build and Release / build-and-release (push) Successful in 2m46s
- Change system panel title from 'NixOS:' to 'NixOS hostname:'
- Make main dashboard title 'cm-dashboard' bold in top bar
- Remove unused Typography::title() function to fix warnings
- Update SystemWidget::render_with_scroll to accept hostname parameter
- Update version to 0.1.41 in all Cargo.toml files and dashboard code
2025-10-29 14:24:17 +01:00
35e06c6734 Implement clean NixOS rebuild tmux popup
All checks were successful
Build and Release / build-and-release (push) Successful in 1m22s
- Replace complex ASCII logo with simple text header
- Remove extra blank lines for compact display
- Left-align text for clean appearance
- Add spacing after target line for readability
- Simplify heredoc format for better maintainability
2025-10-28 23:59:05 +01:00
783d233319 Add CM Dashboard ASCII logo to rebuild tmux popup
All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
- Display branded CM Dashboard ASCII logo in green when rebuild starts
- Shows logo immediately when tmux popup opens for better UX
- Includes rebuild target hostname and visual separator
- Enhances rebuild process with professional branding
- Bump version to v0.1.39
2025-10-28 23:12:09 +01:00
6509a2b91a Make nginx site latency thresholds configurable and simplify status logic
All checks were successful
Build and Release / build-and-release (push) Successful in 4m25s
- Replace hardcoded 500ms/2000ms thresholds with configurable nginx_latency_critical_ms
- Simplify status logic to only OK or Critical (no Warning status)
- Add validation for nginx latency threshold configuration
- Re-enable nginx site collection with configurable thresholds
- Resolves issue where sites showed critical at 2000ms despite 30s timeout setting
- Bump version to v0.1.38
2025-10-28 21:24:34 +01:00
52f8c40b86 Fix title bar layout constraints to prevent text disappearing
All checks were successful
Build and Release / build-and-release (push) Successful in 2m12s
- Set fixed width (15 chars) for left side to prevent chunk collapse
- Resolves issue where "cm-dashboard" text would flash and disappear
- Ensures consistent visibility of title text in dynamic status bar
- Bump version to v0.1.37
2025-10-28 18:56:12 +01:00
a86b5ba8f9 Implement dynamic status-based title bar with infrastructure health indicator
All checks were successful
Build and Release / build-and-release (push) Successful in 1m15s
- Title bar background now dynamically changes based on worst-case status across all hosts
- Green: all OK, Yellow: warnings present, Red: critical issues, Blue: pending, Gray: unknown
- Provides immediate visual feedback of overall infrastructure health
- Added 1-character padding on both sides of title bar
- Maintains dark text for visibility against all status background colors
- Bump version to v0.1.36
2025-10-28 18:47:02 +01:00
1b964545be Fix storage display parsing and improve title bar UI
All checks were successful
Build and Release / build-and-release (push) Successful in 1m14s
- Fix disk drive name extraction for mount points with underscores (e.g., /mnt/steampool)
- Replace confusing "1" and "2" drive names with proper device names like "sda1", "sda2"
- Update title bar with blue background and dark text styling
- Right-align host list in title bar while keeping "cm-dashboard" on left
- Bump version to v0.1.35
2025-10-28 18:32:12 +01:00
97aa1708c2 Improve service selection UI and help text
All checks were successful
Build and Release / build-and-release (push) Successful in 2m11s
- Fix service icons to use background color when selected for better visibility against blue selection background
- Combine start/stop service help text entries into single "s/S: Start/Stop Service"
- Change help text keys to lowercase (r: Rebuild Host, q: Quit)
- Bump version to v0.1.34
2025-10-28 18:17:15 +01:00
d12689f3b5 Update CLAUDE.md to reflect simplified navigation and current status
Updated documentation to reflect major UI improvements:

- Documented simplified navigation system (no more panel switching)
- Updated current status to October 28, 2025 with v0.1.33
- Described complete service discovery and visibility features
- Added vi-style j/k navigation documentation
- Removed outdated panel-focused navigation descriptions
- Updated visual feedback documentation for transitional icons
- Consolidated service discovery achievements and current working state
2025-10-28 17:00:40 +01:00
f22e3ee95e Simplify navigation and add vi-style keys
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
Major UI simplification and navigation improvements:

Changes:
- Removed panel selection concept entirely (no more Shift+Tab)
- Service selection always visible with blue highlighting
- Up/Down arrows now directly control service selection
- Added j/k vi-style navigation keys as alternatives to arrow keys
- Removed panel focus borders - all panels look uniform
- Service commands (s/S) work without panel focus requirements
- Updated keyboard shortcuts to reflect simplified navigation

Navigation:
- Tab: Switch hosts
- ↑↓/jk: Select service (always works)
- R: Rebuild host
- s: Start service
- S: Stop service
- q: Quit

The interface is now much simpler and more intuitive with direct service control.
2025-10-28 16:31:35 +01:00
14 changed files with 225 additions and 260 deletions

View File

@@ -18,22 +18,24 @@ All system panel features successfully implemented:
-**Tmpfs Monitoring**: Added /tmp usage to RAM section -**Tmpfs Monitoring**: Added /tmp usage to RAM section
-**Agent Deployment**: NixOS collector working in production -**Agent Deployment**: NixOS collector working in production
**Keyboard Navigation and Service Management - COMPLETED** **Simplified Navigation and Service Management - COMPLETED**
All keyboard navigation and service selection features successfully implemented: All navigation and service management features successfully implemented:
-**Panel Navigation**: Shift+Tab cycles through visible panels only (System → Services → Backup) -**Direct Service Control**: Up/Down (or j/k) arrows directly control service selection
-**Service Selection**: Up/Down arrows navigate through parent services with visual cursor -**Always Visible Selection**: Service selection highlighting always visible (no panel focus needed)
-**Focus Management**: Selection highlighting only visible when Services panel focused -**Complete Service Discovery**: All configured services visible regardless of state
-**Status Preservation**: Service health colors maintained during selection (green/red icons) -**Transitional Visual Feedback**: Service operations show directional arrows (↑ ↓ ↻)
-**Smart Panel Switching**: Only cycles through panels with data (backup panel conditional) -**Simplified Interface**: Removed panel switching complexity, uniform appearance
-**Scroll Support**: All panels support content scrolling with proper overflow indicators -**Vi-style Navigation**: Added j/k keys for vim users alongside arrow keys
**Current Status - October 27, 2025:** **Current Status - October 28, 2025:**
- All keyboard navigation features working correctly ✅ - All service discovery and display features working correctly ✅
- Service selection cursor implemented with focus-aware highlighting - Simplified navigation system implemented
- Panel scrolling fixed for System, Services, and Backup panels - Service selection always visible with direct control
- Complete service visibility (all configured services show regardless of state) ✅
- Transitional service icons working with proper color handling ✅
- Build display working: "Build: 25.05.20251004.3bcc93c" ✅ - Build display working: "Build: 25.05.20251004.3bcc93c" ✅
- Agent version display working: "Agent: v0.1.17" ✅ - Agent version display working: "Agent: v0.1.33" ✅
- Cross-host version comparison implemented ✅ - Cross-host version comparison implemented ✅
- Automated binary release system working ✅ - Automated binary release system working ✅
- SMART data consolidated into disk collector ✅ - SMART data consolidated into disk collector ✅
@@ -76,36 +78,35 @@ Storage:
**Backup panel visibility fixed - only shows when meaningful data exists ✅** **Backup panel visibility fixed - only shows when meaningful data exists ✅**
**SSH-based rebuild system fully implemented and working ✅** **SSH-based rebuild system fully implemented and working ✅**
### Current Keyboard Navigation Implementation ### Current Simplified Navigation Implementation
**Navigation Controls:** **Navigation Controls:**
- **Tab**: Switch between hosts (cmbox, srv01, srv02, steambox, etc.) - **Tab**: Switch between hosts (cmbox, srv01, srv02, steambox, etc.)
- **Shift+Tab**: Cycle through visible panels (System → Services → Backup → System) - **↑↓ or j/k**: Move service selection cursor (always works)
- **Up/Down (System/Backup)**: Scroll through panel content
- **Up/Down (Services)**: Move service selection cursor between parent services
- **q**: Quit dashboard - **q**: Quit dashboard
**Panel-Specific Features:** **Service Control:**
- **System Panel**: Scrollable content with CPU, RAM, Storage details - **s**: Start selected service
- **Services Panel**: Service selection cursor for parent services only (docker, nginx, postgresql, etc.) - **S**: Stop selected service
- **Backup Panel**: Scrollable repository list with proper overflow handling - **R**: Rebuild current host (works from any context)
**Visual Feedback:** **Visual Features:**
- **Focused Panel**: Blue border and title highlighting - **Service Selection**: Always visible blue background highlighting current service
- **Service Selection**: Blue background with preserved status icon colors (green ● for active, red ● for failed) - **Status Icons**: Green ● (active), Yellow ◐ (inactive), Red ◯ (failed), ? (unknown)
- **Focus-Aware Selection**: Selection highlighting only visible when Services panel focused - **Transitional Icons**: Blue ↑ (starting), ↓ (stopping), ↻ (restarting) when not selected
- **Dynamic Statusbar**: Context-aware shortcuts based on focused panel - **Transitional Icons**: Dark gray arrows when service is selected (for visibility)
- **Uniform Interface**: All panels have consistent appearance (no focus borders)
### Remote Command Execution - WORKING ✅ ### Service Discovery and Display - WORKING ✅
**All Issues Resolved (as of 2025-10-24):** **All Issues Resolved (as of 2025-10-28):**
-**ZMQ Command Protocol**: Extended with ServiceControl and SystemRebuild variants -**Complete Service Discovery**: Uses `systemctl list-unit-files` + `list-units --all` for comprehensive service detection
-**Agent Handlers**: systemctl and nixos-rebuild execution with maintenance mode -**All Services Visible**: Shows all configured services regardless of current state (active/inactive)
-**Dashboard Integration**: Keyboard shortcuts execute commands -**Proper Status Display**: Active services show green ●, inactive show yellow ◐, failed show red ◯
-**Service Control**: Fixed toggle logic - replaced with separate 's' (start) and 'S' (stop) -**Transitional Icons**: Visual feedback during service operations with proper color handling
-**System Rebuild**: Fixed permission issues and sandboxing problems -**Simplified Navigation**: Removed panel complexity, direct service control always available
-**Git Clone Approach**: Implemented for nixos-rebuild to avoid directory permissions -**Service Control**: Start (s) and Stop (S) commands work from anywhere
-**Visual Feedback**: Directional arrows for service status (↑ starting, ↓ stopping, ↻ restarting) -**System Rebuild**: SSH + tmux popup approach for reliable remote rebuilds
### Terminal Popup for Real-time Output - IMPLEMENTED ✅ ### Terminal Popup for Real-time Output - IMPLEMENTED ✅

6
Cargo.lock generated
View File

@@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.31" version = "0.1.40"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -291,7 +291,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.31" version = "0.1.40"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -314,7 +314,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-shared" name = "cm-dashboard-shared"
version = "0.1.31" version = "0.1.40"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.32" version = "0.1.42"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -37,6 +37,22 @@ impl NixOSCollector {
} }
/// Get configuration hash from deployed nix store system /// Get configuration hash from deployed nix store system
/// Get git commit hash from rebuild process
fn get_git_commit(&self) -> Result<String, Box<dyn std::error::Error>> {
let commit_file = "/var/lib/cm-dashboard/git-commit";
match std::fs::read_to_string(commit_file) {
Ok(content) => {
let commit_hash = content.trim();
if commit_hash.len() >= 7 {
Ok(commit_hash.to_string())
} else {
Err("Git commit hash too short".into())
}
}
Err(e) => Err(format!("Failed to read git commit file: {}", e).into())
}
}
fn get_config_hash(&self) -> Result<String, Box<dyn std::error::Error>> { fn get_config_hash(&self) -> Result<String, Box<dyn std::error::Error>> {
// Read the symlink target of /run/current-system to get nix store path // Read the symlink target of /run/current-system to get nix store path
let output = Command::new("readlink") let output = Command::new("readlink")
@@ -74,25 +90,25 @@ impl Collector for NixOSCollector {
let mut metrics = Vec::new(); let mut metrics = Vec::new();
let timestamp = chrono::Utc::now().timestamp() as u64; let timestamp = chrono::Utc::now().timestamp() as u64;
// Collect NixOS build information (config hash) // Collect git commit information (shows what's actually deployed)
match self.get_config_hash() { match self.get_git_commit() {
Ok(config_hash) => { Ok(git_commit) => {
metrics.push(Metric { metrics.push(Metric {
name: "system_nixos_build".to_string(), name: "system_nixos_build".to_string(),
value: MetricValue::String(config_hash), value: MetricValue::String(git_commit),
unit: None, unit: None,
description: Some("NixOS deployed configuration hash".to_string()), description: Some("Git commit hash of deployed configuration".to_string()),
status: Status::Ok, status: Status::Ok,
timestamp, timestamp,
}); });
} }
Err(e) => { Err(e) => {
debug!("Failed to get config hash: {}", e); debug!("Failed to get git commit: {}", e);
metrics.push(Metric { metrics.push(Metric {
name: "system_nixos_build".to_string(), name: "system_nixos_build".to_string(),
value: MetricValue::String("unknown".to_string()), value: MetricValue::String("unknown".to_string()),
unit: None, unit: None,
description: Some("NixOS config hash (failed to detect)".to_string()), description: Some("Git commit hash (failed to detect)".to_string()),
status: Status::Unknown, status: Status::Unknown,
timestamp, timestamp,
}); });

View File

@@ -555,10 +555,8 @@ impl SystemdCollector {
for (site_name, url) in &sites { for (site_name, url) in &sites {
match self.check_site_latency(url) { match self.check_site_latency(url) {
Ok(latency_ms) => { Ok(latency_ms) => {
let status = if latency_ms < 500.0 { let status = if latency_ms < self.config.nginx_latency_critical_ms {
Status::Ok Status::Ok
} else if latency_ms < 2000.0 {
Status::Warning
} else { } else {
Status::Critical Status::Critical
}; };

View File

@@ -108,6 +108,7 @@ pub struct SystemdConfig {
pub nginx_check_interval_seconds: u64, pub nginx_check_interval_seconds: u64,
pub http_timeout_seconds: u64, pub http_timeout_seconds: u64,
pub http_connect_timeout_seconds: u64, pub http_connect_timeout_seconds: u64,
pub nginx_latency_critical_ms: f32,
} }

View File

@@ -83,6 +83,13 @@ pub fn validate_config(config: &AgentConfig) -> Result<()> {
} }
} }
// Validate systemd configuration
if config.collectors.systemd.enabled {
if config.collectors.systemd.nginx_latency_critical_ms <= 0.0 {
bail!("Nginx latency critical threshold must be positive");
}
}
// Validate SMTP configuration // Validate SMTP configuration
if config.notifications.enabled { if config.notifications.enabled {
if config.notifications.smtp_host.is_empty() { if config.notifications.smtp_host.is_empty() {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.32" version = "0.1.42"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -14,7 +14,7 @@ use app::Dashboard;
/// Get hardcoded version /// Get hardcoded version
fn get_version() -> &'static str { fn get_version() -> &'static str {
"v0.1.32" "v0.1.42"
} }
/// Check if running inside tmux session /// Check if running inside tmux session

View File

@@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers}; use crossterm::event::{Event, KeyCode};
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::Style, style::Style,
@@ -37,15 +37,6 @@ pub enum CommandType {
} }
/// Panel types for focus management /// Panel types for focus management
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelType {
System,
Services,
Backup,
}
impl PanelType {
}
/// Widget states for a specific host /// Widget states for a specific host
#[derive(Clone)] #[derive(Clone)]
@@ -92,8 +83,6 @@ pub struct TuiApp {
available_hosts: Vec<String>, available_hosts: Vec<String>,
/// Host index for navigation /// Host index for navigation
host_index: usize, host_index: usize,
/// Currently focused panel
focused_panel: PanelType,
/// Should quit application /// Should quit application
should_quit: bool, should_quit: bool,
/// Track if user manually navigated away from localhost /// Track if user manually navigated away from localhost
@@ -109,7 +98,6 @@ impl TuiApp {
current_host: None, current_host: None,
available_hosts: Vec::new(), available_hosts: Vec::new(),
host_index: 0, host_index: 0,
focused_panel: PanelType::System, // Start with System panel focused
should_quit: false, should_quit: false,
user_navigated_away: false, user_navigated_away: false,
config, config,
@@ -256,69 +244,71 @@ impl TuiApp {
KeyCode::Char('r') => { KeyCode::Char('r') => {
// System rebuild command - works on any panel for current host // System rebuild command - works on any panel for current host
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
// Launch tmux popup with SSH using config values // Create command that shows CM Dashboard logo and then rebuilds
let ssh_command = format!( let logo_and_rebuild = format!(
"ssh -tt {}@{} 'bash -ic {}'", r"cat << 'EOF'
NixOS System Rebuild
Target: {}
EOF
ssh -tt {}@{} 'bash -ic {}'",
hostname,
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
hostname, hostname,
self.config.ssh.rebuild_alias self.config.ssh.rebuild_alias
); );
std::process::Command::new("tmux") std::process::Command::new("tmux")
.arg("display-popup") .arg("display-popup")
.arg(&ssh_command) .arg(&logo_and_rebuild)
.spawn() .spawn()
.ok(); // Ignore errors, tmux will handle them .ok(); // Ignore errors, tmux will handle them
} }
} }
KeyCode::Char('s') => { KeyCode::Char('s') => {
if self.focused_panel == PanelType::Services { // Service start command
// Service start command if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) {
if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) { return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
}
} }
} }
} }
KeyCode::Char('S') => { KeyCode::Char('S') => {
if self.focused_panel == PanelType::Services { // Service stop command
// Service stop command if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) {
if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) { return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
}
} }
} }
} }
KeyCode::Char('b') => { KeyCode::Char('b') => {
if self.focused_panel == PanelType::Backup { // Trigger backup
// Trigger backup if let Some(hostname) = self.current_host.clone() {
if let Some(hostname) = self.current_host.clone() { self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone());
self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone()); return Ok(Some(UiCommand::TriggerBackup { hostname }));
return Ok(Some(UiCommand::TriggerBackup { hostname }));
}
} }
} }
KeyCode::Tab => { KeyCode::Tab => {
if key.modifiers.contains(KeyModifiers::SHIFT) { // Tab cycles to next host
// Shift+Tab cycles through panels self.navigate_host(1);
self.next_panel(); }
} else { KeyCode::Up | KeyCode::Char('k') => {
// Tab cycles to next host // Move service selection up
self.navigate_host(1); if let Some(hostname) = self.current_host.clone() {
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.services_widget.select_previous();
} }
} }
KeyCode::BackTab => { KeyCode::Down | KeyCode::Char('j') => {
// BackTab (Shift+Tab on some terminals) also cycles panels // Move service selection down
self.next_panel(); if let Some(hostname) = self.current_host.clone() {
} let total_services = {
KeyCode::Up => { let host_widgets = self.get_or_create_host_widgets(&hostname);
// Scroll up in focused panel host_widgets.services_widget.get_total_services_count()
self.scroll_focused_panel(-1); };
} let host_widgets = self.get_or_create_host_widgets(&hostname);
KeyCode::Down => { host_widgets.services_widget.select_next(total_services);
// Scroll down in focused panel }
self.scroll_focused_panel(1);
} }
_ => {} _ => {}
} }
@@ -359,25 +349,6 @@ impl TuiApp {
} }
/// Switch to next panel (Shift+Tab) - only cycles through visible panels
pub fn next_panel(&mut self) {
let visible_panels = self.get_visible_panels();
if visible_panels.len() <= 1 {
return; // Can't switch if only one or no panels visible
}
// Find current panel index in visible panels
if let Some(current_index) = visible_panels.iter().position(|&p| p == self.focused_panel) {
// Move to next visible panel
let next_index = (current_index + 1) % visible_panels.len();
self.focused_panel = visible_panels[next_index];
} else {
// Current panel not visible, switch to first visible panel
self.focused_panel = visible_panels[0];
}
info!("Switched to panel: {:?}", self.focused_panel);
}
@@ -478,61 +449,8 @@ impl TuiApp {
} }
} }
/// 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 => {
// For services panel, Up/Down moves selection cursor, not scroll
let total_services = host_widgets.services_widget.get_total_services_count();
if direction > 0 {
host_widgets.services_widget.select_next(total_services);
info!("Services selection moved down");
} else {
host_widgets.services_widget.select_previous();
info!("Services selection moved up");
}
}
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);
}
}
}
}
/// Get list of currently visible panels
fn get_visible_panels(&self) -> Vec<PanelType> {
let mut visible_panels = vec![PanelType::System, PanelType::Services];
// Check if backup panel should be shown
if let Some(hostname) = &self.current_host {
if let Some(host_widgets) = self.host_widgets.get(hostname) {
if host_widgets.backup_widget.has_data() {
visible_panels.push(PanelType::Backup);
}
}
}
visible_panels
}
/// Render the dashboard (real btop-style multi-panel layout) /// Render the dashboard (real btop-style multi-panel layout)
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) { pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
@@ -601,7 +519,7 @@ impl TuiApp {
// Render services widget for current host // Render services widget for current host
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let is_focused = self.focused_panel == PanelType::Services; let is_focused = true; // Always show service selection
let (scroll_offset, pending_transitions) = { let (scroll_offset, pending_transitions) = {
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
(host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone()) (host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone())
@@ -625,48 +543,87 @@ impl TuiApp {
if self.available_hosts.is_empty() { if self.available_hosts.is_empty() {
let title_text = "cm-dashboard • no hosts discovered"; let title_text = "cm-dashboard • no hosts discovered";
let title = Paragraph::new(title_text).style(Typography::title()); let title = Paragraph::new(title_text)
.style(Style::default().fg(Theme::background()).bg(Theme::status_color(Status::Unknown)));
frame.render_widget(title, area); frame.render_widget(title, area);
return; return;
} }
// Create spans for each host with status indicators // Calculate worst-case status across all hosts
let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())]; let mut worst_status = Status::Ok;
for host in &self.available_hosts {
let host_status = self.calculate_host_status(host, metric_store);
worst_status = Status::aggregate(&[worst_status, host_status]);
}
// Use the worst status color as background
let background_color = Theme::status_color(worst_status);
// Split the title bar into left and right sections
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(15), Constraint::Min(0)])
.split(area);
// Left side: "cm-dashboard" text
let left_span = Span::styled(
" cm-dashboard",
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
);
let left_title = Paragraph::new(Line::from(vec![left_span]))
.style(Style::default().bg(background_color));
frame.render_widget(left_title, chunks[0]);
// Right side: hosts with status indicators
let mut host_spans = Vec::new();
for (i, host) in self.available_hosts.iter().enumerate() { for (i, host) in self.available_hosts.iter().enumerate() {
if i > 0 { if i > 0 {
spans.push(Span::styled(" ", Typography::title())); host_spans.push(Span::styled(
" ",
Style::default().fg(Theme::background()).bg(background_color)
));
} }
// Always show normal status icon based on metrics (no command status at host level) // Always show normal status icon based on metrics (no command status at host level)
let host_status = self.calculate_host_status(host, metric_store); let host_status = self.calculate_host_status(host, metric_store);
let (status_icon, status_color) = (StatusIcons::get_icon(host_status), Theme::status_color(host_status)); let status_icon = StatusIcons::get_icon(host_status);
// Add status icon // Add status icon with background color as foreground against status background
spans.push(Span::styled( host_spans.push(Span::styled(
format!("{} ", status_icon), format!("{} ", status_icon),
Style::default().fg(status_color), Style::default().fg(Theme::background()).bg(background_color),
)); ));
if Some(host) == self.current_host.as_ref() { if Some(host) == self.current_host.as_ref() {
// Selected host in bold bright white // Selected host in bold background color against status background
spans.push(Span::styled( host_spans.push(Span::styled(
host.clone(), host.clone(),
Typography::title().add_modifier(Modifier::BOLD), Style::default()
.fg(Theme::background())
.bg(background_color)
.add_modifier(Modifier::BOLD),
)); ));
} else { } else {
// Other hosts in normal style with status color // Other hosts in normal background color against status background
spans.push(Span::styled( host_spans.push(Span::styled(
host.clone(), host.clone(),
Style::default().fg(status_color), Style::default().fg(Theme::background()).bg(background_color),
)); ));
} }
} }
let title_line = Line::from(spans); // Add right padding
let title = Paragraph::new(vec![title_line]); host_spans.push(Span::styled(
" ",
Style::default().fg(Theme::background()).bg(background_color)
));
frame.render_widget(title, area); let host_line = Line::from(host_spans);
let host_title = Paragraph::new(vec![host_line])
.style(Style::default().bg(background_color))
.alignment(ratatui::layout::Alignment::Right);
frame.render_widget(host_title, chunks[1]);
} }
/// Calculate overall status for a host based on its metrics /// Calculate overall status for a host based on its metrics
@@ -730,38 +687,18 @@ impl TuiApp {
// Global shortcuts // Global shortcuts
shortcuts.push("Tab: Switch Host".to_string()); shortcuts.push("Tab: Switch Host".to_string());
shortcuts.push("Shift+Tab: Switch Panel".to_string()); shortcuts.push("↑↓/jk: Select Service".to_string());
shortcuts.push("r: Rebuild Host".to_string());
// Scroll shortcuts (always available) shortcuts.push("s/S: Start/Stop Service".to_string());
shortcuts.push("↑↓: Scroll".to_string());
// Global rebuild shortcut (works on any panel)
shortcuts.push("R: Rebuild Host".to_string());
// Panel-specific shortcuts
match self.focused_panel {
PanelType::Services => {
shortcuts.push("S: Start".to_string());
shortcuts.push("Shift+S: Stop".to_string());
}
PanelType::Backup => {
shortcuts.push("B: Trigger Backup".to_string());
}
_ => {}
}
// Always show quit // Always show quit
shortcuts.push("Q: Quit".to_string()); shortcuts.push("q: Quit".to_string());
shortcuts shortcuts
} }
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {
let system_block = if self.focused_panel == PanelType::System { let system_block = Components::widget_block("system");
Components::focused_widget_block("system")
} else {
Components::widget_block("system")
};
let inner_area = system_block.inner(area); let inner_area = system_block.inner(area);
frame.render_widget(system_block, area); frame.render_widget(system_block, area);
// Get current host widgets, create if none exist // Get current host widgets, create if none exist
@@ -771,16 +708,12 @@ impl TuiApp {
host_widgets.system_scroll_offset host_widgets.system_scroll_offset
}; };
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset); host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset, &hostname);
} }
} }
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) { fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
let backup_block = if self.focused_panel == PanelType::Backup { let backup_block = Components::widget_block("backup");
Components::focused_widget_block("backup")
} else {
Components::widget_block("backup")
};
let inner_area = backup_block.inner(area); let inner_area = backup_block.inner(area);
frame.render_widget(backup_block, area); frame.render_widget(backup_block, area);

View File

@@ -289,27 +289,9 @@ impl Components {
) )
} }
/// 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 { impl Typography {
/// Main title style (dashboard header)
pub fn title() -> Style {
Style::default()
.fg(Theme::primary_text())
.bg(Theme::background())
}
/// Widget title style (panel headers) - bold bright white /// Widget title style (panel headers) - bold bright white
pub fn widget_title() -> Style { pub fn widget_title() -> Style {

View File

@@ -443,11 +443,7 @@ impl ServicesWidget {
/// Render with focus, scroll, and pending transitions for visual feedback /// Render with focus, scroll, and pending transitions for visual feedback
pub fn render_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) { pub fn render_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) {
let services_block = if is_focused { let services_block = Components::widget_block("services");
Components::focused_widget_block("services")
} else {
Components::widget_block("services")
};
let inner_area = services_block.inner(area); let inner_area = services_block.inner(area);
frame.render_widget(services_block, area); frame.render_widget(services_block, area);
@@ -583,14 +579,16 @@ impl ServicesWidget {
} }
}; };
// Apply selection highlighting to parent services only, preserving status icon color // Apply selection highlighting to parent services only, making icons background color when selected
// Only show selection when Services panel is focused // Only show selection when Services panel is focused
// Show selection highlighting even when transitional icons are present // Show selection highlighting even when transitional icons are present
if is_selected && !*is_sub && is_focused { if is_selected && !*is_sub && is_focused {
for (i, span) in spans.iter_mut().enumerate() { for (i, span) in spans.iter_mut().enumerate() {
if i == 0 { if i == 0 {
// First span is the status icon - preserve its color // First span is the status icon - use background color for visibility against blue selection
span.style = span.style.bg(Theme::highlight()); span.style = span.style
.bg(Theme::highlight())
.fg(Theme::background());
} else { } else {
// Other spans (text) get full selection highlighting // Other spans (text) get full selection highlighting
span.style = span.style span.style = span.style

View File

@@ -230,9 +230,30 @@ impl SystemWidget {
/// Extract pool name from disk metric name /// Extract pool name from disk metric name
fn extract_pool_name(&self, metric_name: &str) -> Option<String> { fn extract_pool_name(&self, metric_name: &str) -> Option<String> {
if let Some(captures) = metric_name.strip_prefix("disk_") { // Pattern: disk_{pool_name}_{drive_name}_{metric_type}
if let Some(pos) = captures.find('_') { // Since pool_name can contain underscores, work backwards from known metric suffixes
return Some(captures[..pos].to_string()); if metric_name.starts_with("disk_") {
// First try drive-specific metrics that have device names
if let Some(suffix_pos) = metric_name.rfind("_temperature")
.or_else(|| metric_name.rfind("_wear_percent"))
.or_else(|| metric_name.rfind("_health")) {
// Find the second-to-last underscore to get pool name
let before_suffix = &metric_name[..suffix_pos];
if let Some(drive_start) = before_suffix.rfind('_') {
return Some(metric_name[5..drive_start].to_string()); // Skip "disk_"
}
}
// For pool-level metrics (usage_percent, used_gb, total_gb), take everything before the metric suffix
else if let Some(suffix_pos) = metric_name.rfind("_usage_percent")
.or_else(|| metric_name.rfind("_used_gb"))
.or_else(|| metric_name.rfind("_total_gb")) {
return Some(metric_name[5..suffix_pos].to_string()); // Skip "disk_"
}
// Fallback to old behavior for unknown patterns
else if let Some(captures) = metric_name.strip_prefix("disk_") {
if let Some(pos) = captures.find('_') {
return Some(captures[..pos].to_string());
}
} }
} }
None None
@@ -240,10 +261,18 @@ impl SystemWidget {
/// Extract drive name from disk metric name /// Extract drive name from disk metric name
fn extract_drive_name(&self, metric_name: &str) -> Option<String> { fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
// Pattern: disk_pool_drive_metric // Pattern: disk_{pool_name}_{drive_name}_{metric_type}
let parts: Vec<&str> = metric_name.split('_').collect(); // Since pool_name can contain underscores, work backwards from known metric suffixes
if parts.len() >= 3 && parts[0] == "disk" { if metric_name.starts_with("disk_") {
return Some(parts[2].to_string()); if let Some(suffix_pos) = metric_name.rfind("_temperature")
.or_else(|| metric_name.rfind("_wear_percent"))
.or_else(|| metric_name.rfind("_health")) {
// Find the second-to-last underscore to get the drive name
let before_suffix = &metric_name[..suffix_pos];
if let Some(drive_start) = before_suffix.rfind('_') {
return Some(before_suffix[drive_start + 1..].to_string());
}
}
} }
None None
} }
@@ -410,12 +439,12 @@ impl Widget for SystemWidget {
impl SystemWidget { impl SystemWidget {
/// Render with scroll offset support /// Render with scroll offset support
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) { pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize, hostname: &str) {
let mut lines = Vec::new(); let mut lines = Vec::new();
// NixOS section // NixOS section
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled("NixOS:", Typography::widget_title()) Span::styled(format!("NixOS {}:", hostname), Typography::widget_title())
])); ]));
let build_text = self.nixos_build.as_deref().unwrap_or("unknown"); let build_text = self.nixos_build.as_deref().unwrap_or("unknown");

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard-shared" name = "cm-dashboard-shared"
version = "0.1.32" version = "0.1.42"
edition = "2021" edition = "2021"
[dependencies] [dependencies]