Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35e06c6734 | |||
| 783d233319 | |||
| 6509a2b91a | |||
| 52f8c40b86 | |||
| a86b5ba8f9 | |||
| 1b964545be | |||
| 97aa1708c2 | |||
| d12689f3b5 | |||
| f22e3ee95e | |||
| e890c5e810 | |||
| 078c30a592 | |||
| a847674004 | |||
| 2618f6b62f | |||
| c3fc5a181d | |||
| 3f45a172b3 | |||
| 5b12c12228 |
71
CLAUDE.md
71
CLAUDE.md
@@ -18,22 +18,24 @@ All system panel features successfully implemented:
|
||||
- ✅ **Tmpfs Monitoring**: Added /tmp usage to RAM section
|
||||
- ✅ **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:
|
||||
- ✅ **Panel Navigation**: Shift+Tab cycles through visible panels only (System → Services → Backup)
|
||||
- ✅ **Service Selection**: Up/Down arrows navigate through parent services with visual cursor
|
||||
- ✅ **Focus Management**: Selection highlighting only visible when Services panel focused
|
||||
- ✅ **Status Preservation**: Service health colors maintained during selection (green/red icons)
|
||||
- ✅ **Smart Panel Switching**: Only cycles through panels with data (backup panel conditional)
|
||||
- ✅ **Scroll Support**: All panels support content scrolling with proper overflow indicators
|
||||
All navigation and service management features successfully implemented:
|
||||
- ✅ **Direct Service Control**: Up/Down (or j/k) arrows directly control service selection
|
||||
- ✅ **Always Visible Selection**: Service selection highlighting always visible (no panel focus needed)
|
||||
- ✅ **Complete Service Discovery**: All configured services visible regardless of state
|
||||
- ✅ **Transitional Visual Feedback**: Service operations show directional arrows (↑ ↓ ↻)
|
||||
- ✅ **Simplified Interface**: Removed panel switching complexity, uniform appearance
|
||||
- ✅ **Vi-style Navigation**: Added j/k keys for vim users alongside arrow keys
|
||||
|
||||
**Current Status - October 27, 2025:**
|
||||
- All keyboard navigation features working correctly ✅
|
||||
- Service selection cursor implemented with focus-aware highlighting ✅
|
||||
- Panel scrolling fixed for System, Services, and Backup panels ✅
|
||||
**Current Status - October 28, 2025:**
|
||||
- All service discovery and display features working correctly ✅
|
||||
- Simplified navigation system implemented ✅
|
||||
- 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" ✅
|
||||
- Agent version display working: "Agent: v0.1.17" ✅
|
||||
- Agent version display working: "Agent: v0.1.33" ✅
|
||||
- Cross-host version comparison implemented ✅
|
||||
- Automated binary release system working ✅
|
||||
- SMART data consolidated into disk collector ✅
|
||||
@@ -76,36 +78,35 @@ Storage:
|
||||
**Backup panel visibility fixed - only shows when meaningful data exists ✅**
|
||||
**SSH-based rebuild system fully implemented and working ✅**
|
||||
|
||||
### Current Keyboard Navigation Implementation
|
||||
### Current Simplified Navigation Implementation
|
||||
|
||||
**Navigation Controls:**
|
||||
- **Tab**: Switch between hosts (cmbox, srv01, srv02, steambox, etc.)
|
||||
- **Shift+Tab**: Cycle through visible panels (System → Services → Backup → System)
|
||||
- **Up/Down (System/Backup)**: Scroll through panel content
|
||||
- **Up/Down (Services)**: Move service selection cursor between parent services
|
||||
- **↑↓ or j/k**: Move service selection cursor (always works)
|
||||
- **q**: Quit dashboard
|
||||
|
||||
**Panel-Specific Features:**
|
||||
- **System Panel**: Scrollable content with CPU, RAM, Storage details
|
||||
- **Services Panel**: Service selection cursor for parent services only (docker, nginx, postgresql, etc.)
|
||||
- **Backup Panel**: Scrollable repository list with proper overflow handling
|
||||
**Service Control:**
|
||||
- **s**: Start selected service
|
||||
- **S**: Stop selected service
|
||||
- **R**: Rebuild current host (works from any context)
|
||||
|
||||
**Visual Feedback:**
|
||||
- **Focused Panel**: Blue border and title highlighting
|
||||
- **Service Selection**: Blue background with preserved status icon colors (green ● for active, red ● for failed)
|
||||
- **Focus-Aware Selection**: Selection highlighting only visible when Services panel focused
|
||||
- **Dynamic Statusbar**: Context-aware shortcuts based on focused panel
|
||||
**Visual Features:**
|
||||
- **Service Selection**: Always visible blue background highlighting current service
|
||||
- **Status Icons**: Green ● (active), Yellow ◐ (inactive), Red ◯ (failed), ? (unknown)
|
||||
- **Transitional Icons**: Blue ↑ (starting), ↓ (stopping), ↻ (restarting) when not selected
|
||||
- **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):**
|
||||
- ✅ **ZMQ Command Protocol**: Extended with ServiceControl and SystemRebuild variants
|
||||
- ✅ **Agent Handlers**: systemctl and nixos-rebuild execution with maintenance mode
|
||||
- ✅ **Dashboard Integration**: Keyboard shortcuts execute commands
|
||||
- ✅ **Service Control**: Fixed toggle logic - replaced with separate 's' (start) and 'S' (stop)
|
||||
- ✅ **System Rebuild**: Fixed permission issues and sandboxing problems
|
||||
- ✅ **Git Clone Approach**: Implemented for nixos-rebuild to avoid directory permissions
|
||||
- ✅ **Visual Feedback**: Directional arrows for service status (↑ starting, ↓ stopping, ↻ restarting)
|
||||
**All Issues Resolved (as of 2025-10-28):**
|
||||
- ✅ **Complete Service Discovery**: Uses `systemctl list-unit-files` + `list-units --all` for comprehensive service detection
|
||||
- ✅ **All Services Visible**: Shows all configured services regardless of current state (active/inactive)
|
||||
- ✅ **Proper Status Display**: Active services show green ●, inactive show yellow ◐, failed show red ◯
|
||||
- ✅ **Transitional Icons**: Visual feedback during service operations with proper color handling
|
||||
- ✅ **Simplified Navigation**: Removed panel complexity, direct service control always available
|
||||
- ✅ **Service Control**: Start (s) and Stop (S) commands work from anywhere
|
||||
- ✅ **System Rebuild**: SSH + tmux popup approach for reliable remote rebuilds
|
||||
|
||||
### Terminal Popup for Real-time Output - IMPLEMENTED ✅
|
||||
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.24"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -291,7 +291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.24"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -314,7 +314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.24"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.25"
|
||||
version = "0.1.40"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -274,7 +274,6 @@ impl Agent {
|
||||
let action_str = match action {
|
||||
ServiceAction::Start => "start",
|
||||
ServiceAction::Stop => "stop",
|
||||
ServiceAction::Restart => "restart",
|
||||
ServiceAction::Status => "status",
|
||||
};
|
||||
|
||||
@@ -299,7 +298,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Force refresh metrics after service control to update service status
|
||||
if matches!(action, ServiceAction::Start | ServiceAction::Stop | ServiceAction::Restart) {
|
||||
if matches!(action, ServiceAction::Start | ServiceAction::Stop) {
|
||||
info!("Triggering immediate metric refresh after service control");
|
||||
if let Err(e) = self.collect_metrics_only().await {
|
||||
error!("Failed to refresh metrics after service control: {}", e);
|
||||
|
||||
@@ -136,8 +136,21 @@ impl SystemdCollector {
|
||||
/// Auto-discover interesting services to monitor (internal version that doesn't update state)
|
||||
fn discover_services_internal(&self) -> Result<(Vec<String>, std::collections::HashMap<String, ServiceStatusInfo>)> {
|
||||
debug!("Starting systemd service discovery with status caching");
|
||||
// Get all services (includes inactive, running, failed - everything)
|
||||
let units_output = Command::new("systemctl")
|
||||
|
||||
// First: Get all service unit files (includes services that have never been started)
|
||||
let unit_files_output = Command::new("systemctl")
|
||||
.arg("list-unit-files")
|
||||
.arg("--type=service")
|
||||
.arg("--no-pager")
|
||||
.arg("--plain")
|
||||
.output()?;
|
||||
|
||||
if !unit_files_output.status.success() {
|
||||
return Err(anyhow::anyhow!("systemctl list-unit-files command failed"));
|
||||
}
|
||||
|
||||
// Second: Get runtime status of all units
|
||||
let units_status_output = Command::new("systemctl")
|
||||
.arg("list-units")
|
||||
.arg("--type=service")
|
||||
.arg("--all")
|
||||
@@ -145,22 +158,33 @@ impl SystemdCollector {
|
||||
.arg("--plain")
|
||||
.output()?;
|
||||
|
||||
if !units_output.status.success() {
|
||||
return Err(anyhow::anyhow!("systemctl system command failed"));
|
||||
if !units_status_output.status.success() {
|
||||
return Err(anyhow::anyhow!("systemctl list-units command failed"));
|
||||
}
|
||||
|
||||
let units_str = String::from_utf8(units_output.stdout)?;
|
||||
let unit_files_str = String::from_utf8(unit_files_output.stdout)?;
|
||||
let units_status_str = String::from_utf8(units_status_output.stdout)?;
|
||||
let mut services = Vec::new();
|
||||
|
||||
// Use configuration instead of hardcoded values
|
||||
let excluded_services = &self.config.excluded_services;
|
||||
let service_name_filters = &self.config.service_name_filters;
|
||||
|
||||
// Parse all services and cache their status information
|
||||
// Parse all service unit files to get complete service list
|
||||
let mut all_service_names = std::collections::HashSet::new();
|
||||
let mut status_cache = std::collections::HashMap::new();
|
||||
|
||||
for line in units_str.lines() {
|
||||
for line in unit_files_str.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() >= 2 && fields[0].ends_with(".service") {
|
||||
let service_name = fields[0].trim_end_matches(".service");
|
||||
all_service_names.insert(service_name.to_string());
|
||||
debug!("Found service unit file: {}", service_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse runtime status for all units
|
||||
let mut status_cache = std::collections::HashMap::new();
|
||||
for line in units_status_str.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() >= 4 && fields[0].ends_with(".service") {
|
||||
let service_name = fields[0].trim_end_matches(".service");
|
||||
@@ -177,8 +201,19 @@ impl SystemdCollector {
|
||||
sub_state: sub_state.clone(),
|
||||
});
|
||||
|
||||
all_service_names.insert(service_name.to_string());
|
||||
debug!("Parsed service: {} (load:{}, active:{}, sub:{})", service_name, load_state, active_state, sub_state);
|
||||
debug!("Got runtime status for service: {} (load:{}, active:{}, sub:{})", service_name, load_state, active_state, sub_state);
|
||||
}
|
||||
}
|
||||
|
||||
// For services found in unit files but not in runtime status, set default inactive status
|
||||
for service_name in &all_service_names {
|
||||
if !status_cache.contains_key(service_name) {
|
||||
status_cache.insert(service_name.to_string(), ServiceStatusInfo {
|
||||
load_state: "not-loaded".to_string(),
|
||||
active_state: "inactive".to_string(),
|
||||
sub_state: "dead".to_string(),
|
||||
});
|
||||
debug!("Service {} found in unit files but not runtime - marked as inactive", service_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,10 +555,8 @@ impl SystemdCollector {
|
||||
for (site_name, url) in &sites {
|
||||
match self.check_site_latency(url) {
|
||||
Ok(latency_ms) => {
|
||||
let status = if latency_ms < 500.0 {
|
||||
let status = if latency_ms < self.config.nginx_latency_critical_ms {
|
||||
Status::Ok
|
||||
} else if latency_ms < 2000.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Critical
|
||||
};
|
||||
|
||||
@@ -112,6 +112,5 @@ pub enum AgentCommand {
|
||||
pub enum ServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
Status,
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ pub struct SystemdConfig {
|
||||
pub nginx_check_interval_seconds: u64,
|
||||
pub http_timeout_seconds: u64,
|
||||
pub http_connect_timeout_seconds: u64,
|
||||
pub nginx_latency_critical_ms: f32,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
if config.notifications.enabled {
|
||||
if config.notifications.smtp_host.is_empty() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.25"
|
||||
version = "0.1.40"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -294,14 +294,6 @@ impl Dashboard {
|
||||
/// Execute a UI command by sending it to the appropriate agent
|
||||
async fn execute_ui_command(&self, command: UiCommand) -> Result<()> {
|
||||
match command {
|
||||
UiCommand::ServiceRestart { hostname, service_name } => {
|
||||
info!("Sending restart command for service {} on {}", service_name, hostname);
|
||||
let agent_command = AgentCommand::ServiceControl {
|
||||
service_name,
|
||||
action: ServiceAction::Restart,
|
||||
};
|
||||
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
|
||||
}
|
||||
UiCommand::ServiceStart { hostname, service_name } => {
|
||||
info!("Sending start command for service {} on {}", service_name, hostname);
|
||||
let agent_command = AgentCommand::ServiceControl {
|
||||
|
||||
@@ -35,7 +35,6 @@ pub enum AgentCommand {
|
||||
pub enum ServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
Status,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use app::Dashboard;
|
||||
|
||||
/// Get hardcoded version
|
||||
fn get_version() -> &'static str {
|
||||
"v0.1.25"
|
||||
"v0.1.33"
|
||||
}
|
||||
|
||||
/// Check if running inside tmux session
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
@@ -22,7 +22,6 @@ use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
|
||||
/// Commands that can be triggered from the UI
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UiCommand {
|
||||
ServiceRestart { hostname: String, service_name: String },
|
||||
ServiceStart { hostname: String, service_name: String },
|
||||
ServiceStop { hostname: String, service_name: String },
|
||||
TriggerBackup { hostname: String },
|
||||
@@ -32,22 +31,12 @@ pub enum UiCommand {
|
||||
/// Types of commands for status tracking
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommandType {
|
||||
ServiceRestart,
|
||||
ServiceStart,
|
||||
ServiceStop,
|
||||
BackupTrigger,
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[derive(Clone)]
|
||||
@@ -65,7 +54,7 @@ pub struct HostWidgets {
|
||||
/// Last update time for this host
|
||||
pub last_update: Option<Instant>,
|
||||
/// Pending service transitions for immediate visual feedback
|
||||
pub pending_service_transitions: HashMap<String, (CommandType, String)>, // service_name -> (command_type, original_status)
|
||||
pub pending_service_transitions: HashMap<String, (CommandType, String, Instant)>, // service_name -> (command_type, original_status, start_time)
|
||||
}
|
||||
|
||||
impl HostWidgets {
|
||||
@@ -94,8 +83,6 @@ pub struct TuiApp {
|
||||
available_hosts: Vec<String>,
|
||||
/// 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
|
||||
@@ -111,7 +98,6 @@ 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,
|
||||
config,
|
||||
@@ -256,39 +242,30 @@ impl TuiApp {
|
||||
self.navigate_host(1);
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
match self.focused_panel {
|
||||
PanelType::System => {
|
||||
// Simple tmux popup with SSH rebuild using configured user and alias
|
||||
// System rebuild command - works on any panel for current host
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
// Launch tmux popup with SSH using config values
|
||||
let ssh_command = format!(
|
||||
"ssh -tt {}@{} 'bash -ic {}'",
|
||||
// Create command that shows CM Dashboard logo and then rebuilds
|
||||
let logo_and_rebuild = format!(
|
||||
r"cat << 'EOF'
|
||||
NixOS System Rebuild
|
||||
Target: {}
|
||||
|
||||
EOF
|
||||
ssh -tt {}@{} 'bash -ic {}'",
|
||||
hostname,
|
||||
self.config.ssh.rebuild_user,
|
||||
hostname,
|
||||
self.config.ssh.rebuild_alias
|
||||
);
|
||||
|
||||
std::process::Command::new("tmux")
|
||||
.arg("display-popup")
|
||||
.arg(&ssh_command)
|
||||
.arg(&logo_and_rebuild)
|
||||
.spawn()
|
||||
.ok(); // Ignore errors, tmux will handle them
|
||||
}
|
||||
}
|
||||
PanelType::Services => {
|
||||
// Service restart command
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
if self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()) {
|
||||
return Ok(Some(UiCommand::ServiceRestart { hostname, service_name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
info!("Manual refresh requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
if self.focused_panel == PanelType::Services {
|
||||
// Service start command
|
||||
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()) {
|
||||
@@ -296,9 +273,7 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
if self.focused_panel == PanelType::Services {
|
||||
// Service stop command
|
||||
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()) {
|
||||
@@ -306,36 +281,34 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
if self.focused_panel == PanelType::Backup {
|
||||
// Trigger backup
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone());
|
||||
return Ok(Some(UiCommand::TriggerBackup { hostname }));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
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::Up | KeyCode::Char('k') => {
|
||||
// Move service selection up
|
||||
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 => {
|
||||
// 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 | KeyCode::Char('j') => {
|
||||
// Move service selection down
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
let total_services = {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.services_widget.get_total_services_count()
|
||||
};
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.services_widget.select_next(total_services);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Scroll down in focused panel
|
||||
self.scroll_focused_panel(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -376,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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -431,7 +385,6 @@ impl TuiApp {
|
||||
let should_execute = match (&command_type, current_status.as_deref()) {
|
||||
(CommandType::ServiceStart, Some("inactive") | Some("failed") | Some("dead")) => true,
|
||||
(CommandType::ServiceStop, Some("active")) => true,
|
||||
(CommandType::ServiceRestart, Some("active") | Some("inactive") | Some("failed") | Some("dead")) => true,
|
||||
(CommandType::ServiceStart, Some("active")) => {
|
||||
// Already running - don't execute
|
||||
false
|
||||
@@ -447,26 +400,25 @@ impl TuiApp {
|
||||
_ => true, // Default: allow other combinations
|
||||
};
|
||||
|
||||
if should_execute {
|
||||
// ALWAYS store the pending transition for immediate visual feedback, even if we don't execute
|
||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||
// Store the pending transition for immediate visual feedback
|
||||
host_widgets.pending_service_transitions.insert(
|
||||
target.clone(),
|
||||
(command_type, current_status.unwrap_or_else(|| "unknown".to_string()))
|
||||
(command_type, current_status.unwrap_or_else(|| "unknown".to_string()), Instant::now())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
should_execute
|
||||
}
|
||||
|
||||
/// Clear pending transitions when real status updates arrive
|
||||
/// Clear pending transitions when real status updates arrive or timeout
|
||||
fn clear_completed_transitions(&mut self, hostname: &str, service_metrics: &[&Metric]) {
|
||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||
let mut completed_services = Vec::new();
|
||||
|
||||
// Check each pending transition to see if real status has changed
|
||||
for (service_name, (command_type, original_status)) in &host_widgets.pending_service_transitions {
|
||||
for (service_name, (command_type, original_status, _start_time)) in &host_widgets.pending_service_transitions {
|
||||
|
||||
// Look for status metric for this service
|
||||
for metric in service_metrics {
|
||||
if metric.name == format!("service_{}_status", service_name) {
|
||||
@@ -478,7 +430,6 @@ impl TuiApp {
|
||||
let expected_change = match command_type {
|
||||
CommandType::ServiceStart => &new_status == "active",
|
||||
CommandType::ServiceStop => &new_status != "active",
|
||||
CommandType::ServiceRestart => true, // Any change indicates restart completed
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -498,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)
|
||||
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
|
||||
@@ -621,7 +519,7 @@ 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 is_focused = true; // Always show service selection
|
||||
let (scroll_offset, pending_transitions) = {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
(host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone())
|
||||
@@ -645,48 +543,87 @@ impl TuiApp {
|
||||
|
||||
if self.available_hosts.is_empty() {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create spans for each host with status indicators
|
||||
let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())];
|
||||
// Calculate worst-case status across all hosts
|
||||
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)
|
||||
);
|
||||
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() {
|
||||
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)
|
||||
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
|
||||
spans.push(Span::styled(
|
||||
// Add status icon with background color as foreground against status background
|
||||
host_spans.push(Span::styled(
|
||||
format!("{} ", status_icon),
|
||||
Style::default().fg(status_color),
|
||||
Style::default().fg(Theme::background()).bg(background_color),
|
||||
));
|
||||
|
||||
if Some(host) == self.current_host.as_ref() {
|
||||
// Selected host in bold bright white
|
||||
spans.push(Span::styled(
|
||||
// Selected host in bold background color against status background
|
||||
host_spans.push(Span::styled(
|
||||
host.clone(),
|
||||
Typography::title().add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(Theme::background())
|
||||
.bg(background_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
// Other hosts in normal style with status color
|
||||
spans.push(Span::styled(
|
||||
// Other hosts in normal background color against status background
|
||||
host_spans.push(Span::styled(
|
||||
host.clone(),
|
||||
Style::default().fg(status_color),
|
||||
Style::default().fg(Theme::background()).bg(background_color),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let title_line = Line::from(spans);
|
||||
let title = Paragraph::new(vec![title_line]);
|
||||
// Add right padding
|
||||
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
|
||||
@@ -750,38 +687,18 @@ impl TuiApp {
|
||||
|
||||
// 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("S: Start".to_string());
|
||||
shortcuts.push("Shift+S: Stop".to_string());
|
||||
shortcuts.push("R: Restart".to_string());
|
||||
}
|
||||
PanelType::Backup => {
|
||||
shortcuts.push("B: Trigger Backup".to_string());
|
||||
}
|
||||
}
|
||||
shortcuts.push("↑↓/jk: Select Service".to_string());
|
||||
shortcuts.push("r: Rebuild Host".to_string());
|
||||
shortcuts.push("s/S: Start/Stop Service".to_string());
|
||||
|
||||
// Always show quit
|
||||
shortcuts.push("Q: Quit".to_string());
|
||||
shortcuts.push("q: Quit".to_string());
|
||||
|
||||
shortcuts
|
||||
}
|
||||
|
||||
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {
|
||||
let system_block = if self.focused_panel == PanelType::System {
|
||||
Components::focused_widget_block("system")
|
||||
} else {
|
||||
Components::widget_block("system")
|
||||
};
|
||||
let system_block = 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
|
||||
@@ -796,11 +713,7 @@ impl TuiApp {
|
||||
}
|
||||
|
||||
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let backup_block = if self.focused_panel == PanelType::Backup {
|
||||
Components::focused_widget_block("backup")
|
||||
} else {
|
||||
Components::widget_block("backup")
|
||||
};
|
||||
let backup_block = Components::widget_block("backup");
|
||||
let inner_area = backup_block.inner(area);
|
||||
frame.render_widget(backup_block, area);
|
||||
|
||||
|
||||
@@ -289,18 +289,6 @@ 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 {
|
||||
|
||||
@@ -129,12 +129,11 @@ impl ServicesWidget {
|
||||
}
|
||||
|
||||
/// Get status icon for service, considering pending transitions for visual feedback
|
||||
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, pending_transitions: &HashMap<String, (CommandType, String)>) -> (String, String, ratatui::prelude::Color) {
|
||||
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) -> (String, String, ratatui::prelude::Color) {
|
||||
// Check if this service has a pending transition
|
||||
if let Some((command_type, _original_status)) = pending_transitions.get(service_name) {
|
||||
if let Some((command_type, _original_status, _start_time)) = pending_transitions.get(service_name) {
|
||||
// Show transitional icons for pending commands
|
||||
let (icon, status_text) = match command_type {
|
||||
CommandType::ServiceRestart => ("↻", "restarting"),
|
||||
CommandType::ServiceStart => ("↑", "starting"),
|
||||
CommandType::ServiceStop => ("↓", "stopping"),
|
||||
_ => return (StatusIcons::get_icon(info.widget_status).to_string(), info.status.clone(), Theme::status_color(info.widget_status)), // Not a service command
|
||||
@@ -162,7 +161,7 @@ impl ServicesWidget {
|
||||
name: &str,
|
||||
info: &ServiceInfo,
|
||||
is_last: bool,
|
||||
pending_transitions: &HashMap<String, (CommandType, String)>,
|
||||
pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>,
|
||||
) -> Vec<ratatui::text::Span<'static>> {
|
||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||
let short_name = if name.len() > 18 {
|
||||
@@ -233,13 +232,14 @@ impl ServicesWidget {
|
||||
/// Get currently selected service name (for actions)
|
||||
pub fn get_selected_service(&self) -> Option<String> {
|
||||
// Build the same display list to find the selected service
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new();
|
||||
|
||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (parent_name, parent_info) in parent_services {
|
||||
display_lines.push((parent_name.clone(), parent_info.widget_status, false, None));
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone()));
|
||||
|
||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
||||
let mut sorted_subs = sub_list.clone();
|
||||
@@ -247,17 +247,19 @@ impl ServicesWidget {
|
||||
|
||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
||||
let is_last_sub = i == sorted_subs.len() - 1;
|
||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
||||
display_lines.push((
|
||||
format!("{}_{}", parent_name, sub_name), // Use parent_sub format for sub-services
|
||||
sub_name.clone(),
|
||||
sub_info.widget_status,
|
||||
true,
|
||||
Some((sub_info.clone(), is_last_sub)),
|
||||
full_sub_name,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
display_lines.get(self.selected_index).map(|(name, _, _, _)| name.clone())
|
||||
display_lines.get(self.selected_index).map(|(_, _, _, _, raw_name)| raw_name.clone())
|
||||
}
|
||||
|
||||
/// Get total count of selectable services (parent services only, not sub-services)
|
||||
@@ -440,12 +442,8 @@ impl Widget for ServicesWidget {
|
||||
impl ServicesWidget {
|
||||
|
||||
/// 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)>) {
|
||||
let services_block = if is_focused {
|
||||
Components::focused_widget_block("services")
|
||||
} else {
|
||||
Components::widget_block("services")
|
||||
};
|
||||
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 = Components::widget_block("services");
|
||||
let inner_area = services_block.inner(area);
|
||||
frame.render_widget(services_block, area);
|
||||
|
||||
@@ -474,9 +472,9 @@ impl ServicesWidget {
|
||||
}
|
||||
|
||||
/// Render services list with pending transitions awareness
|
||||
fn render_services_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String)>) {
|
||||
// Build hierarchical service list for display (same as existing logic)
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
||||
fn render_services_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) {
|
||||
// Build hierarchical service list for display - include raw service name for pending transition lookups
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new(); // Added raw service name
|
||||
|
||||
// Sort parent services alphabetically for consistent order
|
||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||
@@ -485,7 +483,7 @@ impl ServicesWidget {
|
||||
for (parent_name, parent_info) in parent_services {
|
||||
// Add parent service line
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None)); // false = not sub-service
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone())); // Include raw name
|
||||
|
||||
// Add sub-services for this parent (if any)
|
||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
||||
@@ -495,12 +493,14 @@ impl ServicesWidget {
|
||||
|
||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
||||
let is_last_sub = i == sorted_subs.len() - 1;
|
||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
||||
// Store sub-service info for custom span rendering
|
||||
display_lines.push((
|
||||
sub_name.clone(),
|
||||
sub_info.widget_status,
|
||||
true,
|
||||
Some((sub_info.clone(), is_last_sub)),
|
||||
full_sub_name, // Raw service name for pending transition lookup
|
||||
)); // true = sub-service, with is_last info
|
||||
}
|
||||
}
|
||||
@@ -533,7 +533,7 @@ impl ServicesWidget {
|
||||
.constraints(vec![Constraint::Length(1); lines_to_show])
|
||||
.split(area);
|
||||
|
||||
for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
|
||||
for (i, (line_text, line_status, is_sub, sub_info, raw_service_name)) in visible_lines.iter().enumerate()
|
||||
{
|
||||
let actual_index = effective_scroll + i; // Real index in the full list
|
||||
|
||||
@@ -551,34 +551,44 @@ impl ServicesWidget {
|
||||
let (service_info, is_last) = sub_info.as_ref().unwrap();
|
||||
self.create_sub_service_spans_with_transitions(line_text, service_info, *is_last, pending_transitions)
|
||||
} else {
|
||||
// Parent services - check if this parent service has a pending transition
|
||||
if pending_transitions.contains_key(line_text) {
|
||||
// Parent services - check if this parent service has a pending transition using RAW service name
|
||||
if pending_transitions.contains_key(raw_service_name) {
|
||||
// Create spans with transitional status
|
||||
let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo {
|
||||
let (icon, status_text, _) = self.get_service_icon_and_status(raw_service_name, &ServiceInfo {
|
||||
status: "".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
widget_status: *line_status
|
||||
}, pending_transitions);
|
||||
|
||||
// Use blue for transitional icons when not selected, background color when selected
|
||||
let icon_color = if is_selected && !*is_sub && is_focused {
|
||||
Theme::background() // Dark background color for visibility against blue selection
|
||||
} else {
|
||||
Theme::highlight() // Blue for normal case
|
||||
};
|
||||
|
||||
vec![
|
||||
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_color)),
|
||||
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
|
||||
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
|
||||
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(status_color)),
|
||||
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(icon_color)),
|
||||
]
|
||||
} else {
|
||||
StatusIcons::create_status_spans(*line_status, line_text)
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
// IMPORTANT: Don't override transitional icons that show pending commands
|
||||
if is_selected && !*is_sub && is_focused && !pending_transitions.contains_key(line_text) {
|
||||
// Show selection highlighting even when transitional icons are present
|
||||
if is_selected && !*is_sub && is_focused {
|
||||
for (i, span) in spans.iter_mut().enumerate() {
|
||||
if i == 0 {
|
||||
// First span is the status icon - preserve its color
|
||||
span.style = span.style.bg(Theme::highlight());
|
||||
// First span is the status icon - use background color for visibility against blue selection
|
||||
span.style = span.style
|
||||
.bg(Theme::highlight())
|
||||
.fg(Theme::background());
|
||||
} else {
|
||||
// Other spans (text) get full selection highlighting
|
||||
span.style = span.style
|
||||
|
||||
@@ -230,20 +230,49 @@ impl SystemWidget {
|
||||
|
||||
/// Extract pool name from disk metric name
|
||||
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}
|
||||
// Since pool_name can contain underscores, work backwards from known metric suffixes
|
||||
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
|
||||
}
|
||||
|
||||
/// Extract drive name from disk metric name
|
||||
fn extract_drive_name(&self, metric_name: &str) -> Option<String> {
|
||||
// Pattern: disk_pool_drive_metric
|
||||
let parts: Vec<&str> = metric_name.split('_').collect();
|
||||
if parts.len() >= 3 && parts[0] == "disk" {
|
||||
return Some(parts[2].to_string());
|
||||
// Pattern: disk_{pool_name}_{drive_name}_{metric_type}
|
||||
// Since pool_name can contain underscores, work backwards from known metric suffixes
|
||||
if metric_name.starts_with("disk_") {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.25"
|
||||
version = "0.1.40"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Reference in New Issue
Block a user