Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 651b801de3 | |||
| 71b9f93d7c | |||
| ae70946c61 | |||
| 2910b7d875 |
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.20"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -291,7 +291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.20"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -314,7 +314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.20"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.21"
|
||||
version = "0.1.25"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -270,7 +270,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
/// Handle systemd service control commands
|
||||
async fn handle_service_control(&self, service_name: &str, action: &ServiceAction) -> Result<()> {
|
||||
async fn handle_service_control(&mut self, service_name: &str, action: &ServiceAction) -> Result<()> {
|
||||
let action_str = match action {
|
||||
ServiceAction::Start => "start",
|
||||
ServiceAction::Stop => "stop",
|
||||
@@ -300,9 +300,12 @@ impl Agent {
|
||||
|
||||
// Force refresh metrics after service control to update service status
|
||||
if matches!(action, ServiceAction::Start | ServiceAction::Stop | ServiceAction::Restart) {
|
||||
info!("Triggering metric refresh after service control");
|
||||
// Note: We can't call self.collect_metrics_only() here due to borrowing issues
|
||||
// The next metric collection cycle will pick up the changes
|
||||
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);
|
||||
} else {
|
||||
info!("Service status refreshed immediately after {} {}", action_str, service_name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -556,8 +556,8 @@ impl Collector for DiskCollector {
|
||||
|
||||
// Drive wear level (for SSDs)
|
||||
if let Some(wear) = drive.wear_level {
|
||||
let wear_status = if wear >= 90.0 { Status::Critical }
|
||||
else if wear >= 80.0 { Status::Warning }
|
||||
let wear_status = if wear >= self.config.wear_critical_percent { Status::Critical }
|
||||
else if wear >= self.config.wear_warning_percent { Status::Warning }
|
||||
else { Status::Ok };
|
||||
|
||||
metrics.push(Metric {
|
||||
|
||||
@@ -187,7 +187,7 @@ impl MemoryCollector {
|
||||
}
|
||||
|
||||
// Monitor tmpfs (/tmp) usage
|
||||
if let Ok(tmpfs_metrics) = self.get_tmpfs_metrics() {
|
||||
if let Ok(tmpfs_metrics) = self.get_tmpfs_metrics(status_tracker) {
|
||||
metrics.extend(tmpfs_metrics);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ impl MemoryCollector {
|
||||
}
|
||||
|
||||
/// Get tmpfs (/tmp) usage metrics
|
||||
fn get_tmpfs_metrics(&self) -> Result<Vec<Metric>, CollectorError> {
|
||||
fn get_tmpfs_metrics(&self, status_tracker: &mut StatusTracker) -> Result<Vec<Metric>, CollectorError> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("df")
|
||||
@@ -249,12 +249,15 @@ impl MemoryCollector {
|
||||
let mut metrics = Vec::new();
|
||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||
|
||||
// Calculate status using same thresholds as main memory
|
||||
let tmp_status = self.calculate_usage_status("memory_tmp_usage_percent", usage_percent, status_tracker);
|
||||
|
||||
metrics.push(Metric {
|
||||
name: "memory_tmp_usage_percent".to_string(),
|
||||
value: MetricValue::Float(usage_percent),
|
||||
unit: Some("%".to_string()),
|
||||
description: Some("tmpfs /tmp usage percentage".to_string()),
|
||||
status: Status::Ok,
|
||||
status: tmp_status,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use crate::config::NixOSConfig;
|
||||
///
|
||||
/// Collects NixOS-specific system information including:
|
||||
/// - NixOS version and build information
|
||||
/// - Currently active/logged in users
|
||||
pub struct NixOSCollector {
|
||||
}
|
||||
|
||||
@@ -65,27 +64,6 @@ impl NixOSCollector {
|
||||
Err("Could not extract hash from nix store path".into())
|
||||
}
|
||||
|
||||
/// Get currently active users
|
||||
fn get_active_users(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("who").output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("who command failed".into());
|
||||
}
|
||||
|
||||
let who_output = String::from_utf8_lossy(&output.stdout);
|
||||
let mut users = std::collections::HashSet::new();
|
||||
|
||||
for line in who_output.lines() {
|
||||
if let Some(username) = line.split_whitespace().next() {
|
||||
if !username.is_empty() {
|
||||
users.insert(username.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(users.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -121,31 +99,6 @@ impl Collector for NixOSCollector {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect active users
|
||||
match self.get_active_users() {
|
||||
Ok(users) => {
|
||||
let users_str = users.join(", ");
|
||||
metrics.push(Metric {
|
||||
name: "system_active_users".to_string(),
|
||||
value: MetricValue::String(users_str),
|
||||
unit: None,
|
||||
description: Some("Currently active users".to_string()),
|
||||
status: Status::Ok,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to get active users: {}", e);
|
||||
metrics.push(Metric {
|
||||
name: "system_active_users".to_string(),
|
||||
value: MetricValue::String("unknown".to_string()),
|
||||
unit: None,
|
||||
description: Some("Active users (failed to detect)".to_string()),
|
||||
status: Status::Unknown,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect config hash
|
||||
match self.get_config_hash() {
|
||||
|
||||
@@ -160,27 +160,37 @@ impl HostStatusManager {
|
||||
|
||||
/// Process a metric - updates status and queues for aggregated notifications if status changed
|
||||
pub async fn process_metric(&mut self, metric: &Metric, _notification_manager: &mut crate::notifications::NotificationManager) -> bool {
|
||||
let old_status = self.service_statuses.get(&metric.name).copied();
|
||||
let new_status = metric.status;
|
||||
let old_service_status = self.service_statuses.get(&metric.name).copied();
|
||||
let old_host_status = self.current_host_status;
|
||||
let new_service_status = metric.status;
|
||||
|
||||
// Update status
|
||||
self.update_service_status(metric.name.clone(), new_status);
|
||||
// Update status (this recalculates host status internally)
|
||||
self.update_service_status(metric.name.clone(), new_service_status);
|
||||
|
||||
// Check if status actually changed (ignore first-time status setting)
|
||||
if let Some(old_status) = old_status {
|
||||
if old_status != new_status {
|
||||
debug!("Status change detected for {}: {:?} -> {:?}", metric.name, old_status, new_status);
|
||||
let new_host_status = self.current_host_status;
|
||||
let mut status_changed = false;
|
||||
|
||||
// Check if service status actually changed (ignore first-time status setting)
|
||||
if let Some(old_service_status) = old_service_status {
|
||||
if old_service_status != new_service_status {
|
||||
debug!("Service status change detected for {}: {:?} -> {:?}", metric.name, old_service_status, new_service_status);
|
||||
|
||||
// Queue change for aggregated notification (not immediate)
|
||||
self.queue_status_change(&metric.name, old_status, new_status);
|
||||
self.queue_status_change(&metric.name, old_service_status, new_service_status);
|
||||
|
||||
return true; // Status changed - caller should trigger immediate transmission
|
||||
status_changed = true;
|
||||
}
|
||||
} else {
|
||||
debug!("Initial status set for {}: {:?}", metric.name, new_status);
|
||||
debug!("Initial status set for {}: {:?}", metric.name, new_service_status);
|
||||
}
|
||||
|
||||
false // No status change (or first-time status)
|
||||
// Check if host status changed (this should trigger immediate transmission)
|
||||
if old_host_status != new_host_status {
|
||||
debug!("Host status change detected: {:?} -> {:?}", old_host_status, new_host_status);
|
||||
status_changed = true;
|
||||
}
|
||||
|
||||
status_changed // Return true if either service or host status changed
|
||||
}
|
||||
|
||||
/// Queue status change for aggregated notification
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.21"
|
||||
version = "0.1.25"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -263,12 +263,7 @@ impl Dashboard {
|
||||
cmd_output.output_line
|
||||
);
|
||||
|
||||
// Forward to TUI if not headless
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
tui_app.add_terminal_output(&cmd_output.hostname, cmd_output.output_line);
|
||||
|
||||
// Note: Popup stays open for manual review - close with ESC/Q
|
||||
}
|
||||
// Command output (terminal popup removed - output not displayed)
|
||||
}
|
||||
|
||||
last_metrics_check = Instant::now();
|
||||
|
||||
@@ -14,7 +14,7 @@ use app::Dashboard;
|
||||
|
||||
/// Get hardcoded version
|
||||
fn get_version() -> &'static str {
|
||||
"v0.1.21"
|
||||
"v0.1.25"
|
||||
}
|
||||
|
||||
/// Check if running inside tmux session
|
||||
|
||||
@@ -7,7 +7,7 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
pub mod theme;
|
||||
@@ -28,12 +28,6 @@ pub enum UiCommand {
|
||||
TriggerBackup { hostname: String },
|
||||
}
|
||||
|
||||
/// Command execution status for visual feedback
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommandStatus {
|
||||
/// Command is executing
|
||||
InProgress { command_type: CommandType, target: String, start_time: std::time::Instant },
|
||||
}
|
||||
|
||||
/// Types of commands for status tracking
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -70,8 +64,8 @@ pub struct HostWidgets {
|
||||
pub backup_scroll_offset: usize,
|
||||
/// Last update time for this host
|
||||
pub last_update: Option<Instant>,
|
||||
/// Active command status for visual feedback
|
||||
pub command_status: Option<CommandStatus>,
|
||||
/// Pending service transitions for immediate visual feedback
|
||||
pub pending_service_transitions: HashMap<String, (CommandType, String)>, // service_name -> (command_type, original_status)
|
||||
}
|
||||
|
||||
impl HostWidgets {
|
||||
@@ -84,55 +78,11 @@ impl HostWidgets {
|
||||
services_scroll_offset: 0,
|
||||
backup_scroll_offset: 0,
|
||||
last_update: None,
|
||||
command_status: None,
|
||||
pending_service_transitions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal popup for streaming command output
|
||||
#[derive(Clone)]
|
||||
pub struct TerminalPopup {
|
||||
/// Is the popup currently visible
|
||||
pub visible: bool,
|
||||
/// Command being executed
|
||||
pub _command_type: CommandType,
|
||||
/// Target hostname
|
||||
pub hostname: String,
|
||||
/// Target service/operation name
|
||||
pub target: String,
|
||||
/// Output lines collected so far
|
||||
pub output_lines: Vec<String>,
|
||||
/// Scroll offset for the output
|
||||
pub scroll_offset: usize,
|
||||
/// Start time of the operation
|
||||
pub start_time: Instant,
|
||||
}
|
||||
|
||||
impl TerminalPopup {
|
||||
pub fn _new(command_type: CommandType, hostname: String, target: String) -> Self {
|
||||
Self {
|
||||
visible: true,
|
||||
_command_type: command_type,
|
||||
hostname,
|
||||
target,
|
||||
output_lines: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_output_line(&mut self, line: String) {
|
||||
self.output_lines.push(line);
|
||||
// Auto-scroll to bottom when new content arrives
|
||||
if self.output_lines.len() > 20 {
|
||||
self.scroll_offset = self.output_lines.len().saturating_sub(20);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Main TUI application
|
||||
pub struct TuiApp {
|
||||
@@ -150,8 +100,6 @@ pub struct TuiApp {
|
||||
should_quit: bool,
|
||||
/// Track if user manually navigated away from localhost
|
||||
user_navigated_away: bool,
|
||||
/// Terminal popup for streaming command output
|
||||
terminal_popup: Option<TerminalPopup>,
|
||||
/// Dashboard configuration
|
||||
config: DashboardConfig,
|
||||
}
|
||||
@@ -166,7 +114,6 @@ impl TuiApp {
|
||||
focused_panel: PanelType::System, // Start with System panel focused
|
||||
should_quit: false,
|
||||
user_navigated_away: false,
|
||||
terminal_popup: None,
|
||||
config,
|
||||
}
|
||||
}
|
||||
@@ -180,8 +127,6 @@ impl TuiApp {
|
||||
|
||||
/// Update widgets with metrics from store (only for current host)
|
||||
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
||||
// Check for command timeouts first
|
||||
self.check_command_timeouts();
|
||||
|
||||
// Check for rebuild completion by agent hash change
|
||||
|
||||
@@ -215,6 +160,9 @@ impl TuiApp {
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
// Clear completed transitions first
|
||||
self.clear_completed_transitions(&hostname, &service_metrics);
|
||||
|
||||
// Now get host widgets and update them
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
|
||||
@@ -256,9 +204,9 @@ impl TuiApp {
|
||||
// Sort hosts alphabetically
|
||||
let mut sorted_hosts = hosts.clone();
|
||||
|
||||
// Keep hosts that have ongoing commands even if they're offline
|
||||
// Keep hosts that have pending transitions even if they're offline
|
||||
for (hostname, host_widgets) in &self.host_widgets {
|
||||
if let Some(CommandStatus::InProgress { .. }) = &host_widgets.command_status {
|
||||
if !host_widgets.pending_service_transitions.is_empty() {
|
||||
if !sorted_hosts.contains(hostname) {
|
||||
sorted_hosts.push(hostname.clone());
|
||||
}
|
||||
@@ -297,38 +245,6 @@ impl TuiApp {
|
||||
/// Handle keyboard input
|
||||
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> {
|
||||
if let Event::Key(key) = event {
|
||||
// If terminal popup is visible, handle popup-specific keys first
|
||||
if let Some(ref mut popup) = self.terminal_popup {
|
||||
if popup.visible {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
popup.close();
|
||||
self.terminal_popup = None;
|
||||
return Ok(None);
|
||||
}
|
||||
KeyCode::Up => {
|
||||
popup.scroll_offset = popup.scroll_offset.saturating_sub(1);
|
||||
return Ok(None);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max_scroll = if popup.output_lines.len() > 20 {
|
||||
popup.output_lines.len() - 20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
popup.scroll_offset = (popup.scroll_offset + 1).min(max_scroll);
|
||||
return Ok(None);
|
||||
}
|
||||
KeyCode::Char('q') => {
|
||||
popup.close();
|
||||
self.terminal_popup = None;
|
||||
return Ok(None);
|
||||
}
|
||||
_ => return Ok(None), // Consume all other keys when popup is open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
self.should_quit = true;
|
||||
@@ -361,8 +277,9 @@ impl TuiApp {
|
||||
PanelType::Services => {
|
||||
// Service restart command
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone());
|
||||
return Ok(Some(UiCommand::ServiceRestart { hostname, service_name }));
|
||||
if self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()) {
|
||||
return Ok(Some(UiCommand::ServiceRestart { hostname, service_name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -374,8 +291,9 @@ impl TuiApp {
|
||||
if self.focused_panel == PanelType::Services {
|
||||
// Service start command
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
self.start_command(&hostname, CommandType::ServiceStart, service_name.clone());
|
||||
return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
|
||||
if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) {
|
||||
return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,8 +301,9 @@ impl TuiApp {
|
||||
if self.focused_panel == PanelType::Services {
|
||||
// Service stop command
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
self.start_command(&hostname, CommandType::ServiceStop, service_name.clone());
|
||||
return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
|
||||
if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) {
|
||||
return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -495,69 +414,90 @@ impl TuiApp {
|
||||
self.should_quit
|
||||
}
|
||||
|
||||
/// Start command execution and track status for visual feedback
|
||||
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) {
|
||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||
host_widgets.command_status = Some(CommandStatus::InProgress {
|
||||
command_type,
|
||||
target,
|
||||
start_time: Instant::now(),
|
||||
});
|
||||
/// Get current service status for state-aware command validation
|
||||
fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option<String> {
|
||||
if let Some(host_widgets) = self.host_widgets.get(hostname) {
|
||||
return host_widgets.services_widget.get_service_status(service_name);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mark command as completed successfully
|
||||
pub fn _complete_command(&mut self, hostname: &str) {
|
||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||
// Simply clear the command status when completed
|
||||
host_widgets.command_status = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Check for command timeouts and automatically clear them
|
||||
pub fn check_command_timeouts(&mut self) {
|
||||
let now = Instant::now();
|
||||
let mut hosts_to_clear = Vec::new();
|
||||
/// Start command execution with immediate visual feedback
|
||||
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) -> bool {
|
||||
// Get current service status to validate command
|
||||
let current_status = self.get_current_service_status(hostname, &target);
|
||||
|
||||
for (hostname, host_widgets) in &self.host_widgets {
|
||||
if let Some(CommandStatus::InProgress { command_type: _, start_time, .. }) = &host_widgets.command_status {
|
||||
let timeout_duration = Duration::from_secs(30); // 30 seconds for service commands
|
||||
|
||||
if now.duration_since(*start_time) > timeout_duration {
|
||||
hosts_to_clear.push(hostname.clone());
|
||||
// Validate if command makes sense for current state
|
||||
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
|
||||
},
|
||||
(CommandType::ServiceStop, Some("inactive") | Some("failed") | Some("dead")) => {
|
||||
// Already stopped - don't execute
|
||||
false
|
||||
},
|
||||
(_, None) => {
|
||||
// Unknown service state - allow command to proceed
|
||||
true
|
||||
},
|
||||
_ => true, // Default: allow other combinations
|
||||
};
|
||||
|
||||
if should_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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
should_execute
|
||||
}
|
||||
|
||||
/// Clear pending transitions when real status updates arrive
|
||||
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 {
|
||||
// Look for status metric for this service
|
||||
for metric in service_metrics {
|
||||
if metric.name == format!("service_{}_status", service_name) {
|
||||
let new_status = metric.value.as_string();
|
||||
|
||||
// Check if status has changed from original (command completed)
|
||||
if &new_status != original_status {
|
||||
// Verify it changed in the expected direction
|
||||
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,
|
||||
};
|
||||
|
||||
if expected_change {
|
||||
completed_services.push(service_name.clone());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear timed out commands
|
||||
for hostname in hosts_to_clear {
|
||||
if let Some(host_widgets) = self.host_widgets.get_mut(&hostname) {
|
||||
host_widgets.command_status = None;
|
||||
|
||||
// Remove completed transitions
|
||||
for service_name in completed_services {
|
||||
host_widgets.pending_service_transitions.remove(&service_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add output line to terminal popup
|
||||
pub fn add_terminal_output(&mut self, hostname: &str, line: String) {
|
||||
if let Some(ref mut popup) = self.terminal_popup {
|
||||
if popup.hostname == hostname && popup.visible {
|
||||
popup.add_output_line(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close terminal popup for a specific hostname
|
||||
pub fn _close_terminal_popup(&mut self, hostname: &str) {
|
||||
if let Some(ref mut popup) = self.terminal_popup {
|
||||
if popup.hostname == hostname {
|
||||
popup.close();
|
||||
self.terminal_popup = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Scroll the focused panel up or down
|
||||
pub fn scroll_focused_panel(&mut self, direction: i32) {
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
@@ -682,25 +622,19 @@ 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 (scroll_offset, command_status) = {
|
||||
let (scroll_offset, pending_transitions) = {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
(host_widgets.services_scroll_offset, host_widgets.command_status.clone())
|
||||
(host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone())
|
||||
};
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets
|
||||
.services_widget
|
||||
.render_with_command_status(frame, content_chunks[1], is_focused, scroll_offset, command_status.as_ref()); // Services takes full right side
|
||||
.render_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, &pending_transitions); // 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 terminal popup on top of everything else
|
||||
if let Some(ref popup) = self.terminal_popup {
|
||||
if popup.visible {
|
||||
self.render_terminal_popup(frame, size, popup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render btop-style minimal title with host status colors
|
||||
@@ -724,24 +658,9 @@ impl TuiApp {
|
||||
spans.push(Span::styled(" ", Typography::title()));
|
||||
}
|
||||
|
||||
// Check if this host has a command status that affects the icon
|
||||
let (status_icon, status_color) = if let Some(host_widgets) = self.host_widgets.get(host) {
|
||||
match &host_widgets.command_status {
|
||||
Some(CommandStatus::InProgress { .. }) => {
|
||||
// Show working indicator for in-progress commands
|
||||
("⏳", Theme::highlight())
|
||||
}
|
||||
_ => {
|
||||
// Normal status icon based on metrics
|
||||
let host_status = self.calculate_host_status(host, metric_store);
|
||||
(StatusIcons::get_icon(host_status), Theme::status_color(host_status))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No host widgets yet, use normal status
|
||||
let host_status = self.calculate_host_status(host, metric_store);
|
||||
(StatusIcons::get_icon(host_status), Theme::status_color(host_status))
|
||||
};
|
||||
// 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));
|
||||
|
||||
// Add status icon
|
||||
spans.push(Span::styled(
|
||||
@@ -896,112 +815,5 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render terminal popup with streaming output
|
||||
fn render_terminal_popup(&self, frame: &mut Frame, area: Rect, popup: &TerminalPopup) {
|
||||
use ratatui::{
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
// Calculate popup size (80% of screen, centered)
|
||||
let popup_width = area.width * 80 / 100;
|
||||
let popup_height = area.height * 80 / 100;
|
||||
let popup_x = (area.width - popup_width) / 2;
|
||||
let popup_y = (area.height - popup_height) / 2;
|
||||
|
||||
let popup_area = Rect {
|
||||
x: popup_x,
|
||||
y: popup_y,
|
||||
width: popup_width,
|
||||
height: popup_height,
|
||||
};
|
||||
|
||||
// Clear background
|
||||
frame.render_widget(Clear, popup_area);
|
||||
|
||||
// Create terminal-style block
|
||||
let title = format!(" {} → {} ({:.1}s) ",
|
||||
popup.hostname,
|
||||
popup.target,
|
||||
popup.start_time.elapsed().as_secs_f32()
|
||||
);
|
||||
|
||||
let block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.style(Style::default().bg(Color::Black));
|
||||
|
||||
let inner_area = block.inner(popup_area);
|
||||
frame.render_widget(block, popup_area);
|
||||
|
||||
// Render output content
|
||||
let available_height = inner_area.height as usize;
|
||||
let total_lines = popup.output_lines.len();
|
||||
|
||||
// Calculate which lines to show based on scroll offset
|
||||
let start_line = popup.scroll_offset;
|
||||
let end_line = (start_line + available_height).min(total_lines);
|
||||
|
||||
let visible_lines: Vec<Line> = popup.output_lines[start_line..end_line]
|
||||
.iter()
|
||||
.map(|line| {
|
||||
// Style output lines with terminal colors
|
||||
if line.contains("error") || line.contains("Error") || line.contains("failed") {
|
||||
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Red)))
|
||||
} else if line.contains("warning") || line.contains("Warning") {
|
||||
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Yellow)))
|
||||
} else if line.contains("building") || line.contains("Building") {
|
||||
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Blue)))
|
||||
} else if line.contains("✓") || line.contains("success") || line.contains("completed") {
|
||||
Line::from(Span::styled(line.clone(), Style::default().fg(Color::Green)))
|
||||
} else {
|
||||
Line::from(Span::styled(line.clone(), Style::default().fg(Color::White)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content = Paragraph::new(visible_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.style(Style::default().bg(Color::Black));
|
||||
|
||||
frame.render_widget(content, inner_area);
|
||||
|
||||
// Render scroll indicator if needed
|
||||
if total_lines > available_height {
|
||||
let scroll_info = format!(" {}% ",
|
||||
if total_lines > 0 {
|
||||
(end_line * 100) / total_lines
|
||||
} else {
|
||||
100
|
||||
}
|
||||
);
|
||||
|
||||
let scroll_area = Rect {
|
||||
x: popup_area.x + popup_area.width - scroll_info.len() as u16 - 1,
|
||||
y: popup_area.y + popup_area.height - 1,
|
||||
width: scroll_info.len() as u16,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let scroll_widget = Paragraph::new(scroll_info)
|
||||
.style(Style::default().fg(Color::Cyan).bg(Color::Black));
|
||||
frame.render_widget(scroll_widget, scroll_area);
|
||||
}
|
||||
|
||||
// Instructions at bottom
|
||||
let instructions = " ESC/Q: Close • ↑↓: Scroll ";
|
||||
let instructions_area = Rect {
|
||||
x: popup_area.x + 1,
|
||||
y: popup_area.y + popup_area.height - 1,
|
||||
width: instructions.len() as u16,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let instructions_widget = Paragraph::new(instructions)
|
||||
.style(Style::default().fg(Color::Gray).bg(Color::Black));
|
||||
frame.render_widget(instructions_widget, instructions_area);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
||||
use crate::ui::{CommandStatus, CommandType};
|
||||
use crate::ui::CommandType;
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Services widget displaying hierarchical systemd service statuses
|
||||
@@ -128,25 +128,18 @@ impl ServicesWidget {
|
||||
)
|
||||
}
|
||||
|
||||
/// Get status icon for service, considering command status for visual feedback
|
||||
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, command_status: Option<&CommandStatus>) -> (String, String, ratatui::prelude::Color) {
|
||||
// Check if this service is currently being operated on
|
||||
if let Some(status) = command_status {
|
||||
match status {
|
||||
CommandStatus::InProgress { command_type, target, .. } => {
|
||||
if target == service_name {
|
||||
// Only show special icons for service commands
|
||||
if let Some((icon, status_text)) = match command_type {
|
||||
CommandType::ServiceRestart => Some(("↻", "restarting")),
|
||||
CommandType::ServiceStart => Some(("↑", "starting")),
|
||||
CommandType::ServiceStop => Some(("↓", "stopping")),
|
||||
_ => None, // Don't handle non-service commands here
|
||||
} {
|
||||
return (icon.to_string(), status_text.to_string(), Theme::highlight());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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) {
|
||||
// Check if this service has a pending transition
|
||||
if let Some((command_type, _original_status)) = 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
|
||||
};
|
||||
return (icon.to_string(), status_text.to_string(), Theme::highlight());
|
||||
}
|
||||
|
||||
// Normal status display
|
||||
@@ -163,13 +156,13 @@ impl ServicesWidget {
|
||||
}
|
||||
|
||||
|
||||
/// Create spans for sub-service with icon next to name, considering command status
|
||||
fn create_sub_service_spans_with_status(
|
||||
/// Create spans for sub-service with icon next to name, considering pending transitions
|
||||
fn create_sub_service_spans_with_transitions(
|
||||
&self,
|
||||
name: &str,
|
||||
info: &ServiceInfo,
|
||||
is_last: bool,
|
||||
command_status: Option<&CommandStatus>,
|
||||
pending_transitions: &HashMap<String, (CommandType, String)>,
|
||||
) -> Vec<ratatui::text::Span<'static>> {
|
||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||
let short_name = if name.len() > 18 {
|
||||
@@ -178,11 +171,11 @@ impl ServicesWidget {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Get status icon and text, considering command status
|
||||
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, command_status);
|
||||
// Get status icon and text, considering pending transitions
|
||||
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, pending_transitions);
|
||||
|
||||
// For sub-services, prefer latency if available (unless command is in progress)
|
||||
if command_status.is_none() {
|
||||
// For sub-services, prefer latency if available (unless transition is pending)
|
||||
if !pending_transitions.contains_key(name) {
|
||||
if let Some(latency) = info.latency_ms {
|
||||
status_str = if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
@@ -273,6 +266,26 @@ impl ServicesWidget {
|
||||
self.parent_services.len()
|
||||
}
|
||||
|
||||
/// Get current status of a specific service by name
|
||||
pub fn get_service_status(&self, service_name: &str) -> Option<String> {
|
||||
// Check if it's a parent service
|
||||
if let Some(parent_info) = self.parent_services.get(service_name) {
|
||||
return Some(parent_info.status.clone());
|
||||
}
|
||||
|
||||
// Check sub-services (format: parent_sub)
|
||||
for (parent_name, sub_list) in &self.sub_services {
|
||||
for (sub_name, sub_info) in sub_list {
|
||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
||||
if full_sub_name == service_name {
|
||||
return Some(sub_info.status.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Calculate which parent service index corresponds to a display line index
|
||||
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
||||
// Build the same display list to map line index to parent service index
|
||||
@@ -426,8 +439,8 @@ impl Widget for ServicesWidget {
|
||||
|
||||
impl ServicesWidget {
|
||||
|
||||
/// Render with focus, scroll, and command status for visual feedback
|
||||
pub fn render_with_command_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) {
|
||||
/// 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 {
|
||||
@@ -456,12 +469,12 @@ impl ServicesWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the existing render logic but with command status
|
||||
self.render_services_with_status(frame, content_chunks[1], is_focused, scroll_offset, command_status);
|
||||
// Use the existing render logic but with pending transitions
|
||||
self.render_services_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, pending_transitions);
|
||||
}
|
||||
|
||||
/// Render services list with command status awareness
|
||||
fn render_services_with_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) {
|
||||
/// 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();
|
||||
|
||||
@@ -534,42 +547,34 @@ impl ServicesWidget {
|
||||
};
|
||||
|
||||
let mut spans = if *is_sub && sub_info.is_some() {
|
||||
// Use custom sub-service span creation WITH command status
|
||||
// Use custom sub-service span creation WITH pending transitions
|
||||
let (service_info, is_last) = sub_info.as_ref().unwrap();
|
||||
self.create_sub_service_spans_with_status(line_text, service_info, *is_last, command_status)
|
||||
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 command in progress
|
||||
let service_spans = if let Some(status) = command_status {
|
||||
match status {
|
||||
CommandStatus::InProgress { target, .. } => {
|
||||
if target == line_text {
|
||||
// Create spans with progress status
|
||||
let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo {
|
||||
status: "".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
widget_status: *line_status
|
||||
}, command_status);
|
||||
vec![
|
||||
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_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)),
|
||||
]
|
||||
} else {
|
||||
StatusIcons::create_status_spans(*line_status, line_text)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parent services - check if this parent service has a pending transition
|
||||
if pending_transitions.contains_key(line_text) {
|
||||
// Create spans with transitional status
|
||||
let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo {
|
||||
status: "".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
widget_status: *line_status
|
||||
}, pending_transitions);
|
||||
vec![
|
||||
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_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)),
|
||||
]
|
||||
} else {
|
||||
StatusIcons::create_status_spans(*line_status, line_text)
|
||||
};
|
||||
service_spans
|
||||
}
|
||||
};
|
||||
|
||||
// Apply selection highlighting to parent services only, preserving status icon color
|
||||
// Only show selection when Services panel is focused
|
||||
if is_selected && !*is_sub && 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) {
|
||||
for (i, span) in spans.iter_mut().enumerate() {
|
||||
if i == 0 {
|
||||
// First span is the status icon - preserve its color
|
||||
|
||||
@@ -15,7 +15,6 @@ pub struct SystemWidget {
|
||||
// NixOS information
|
||||
nixos_build: Option<String>,
|
||||
config_hash: Option<String>,
|
||||
active_users: Option<String>,
|
||||
agent_hash: Option<String>,
|
||||
|
||||
// CPU metrics
|
||||
@@ -33,6 +32,7 @@ pub struct SystemWidget {
|
||||
tmp_used_gb: Option<f32>,
|
||||
tmp_total_gb: Option<f32>,
|
||||
memory_status: Status,
|
||||
tmp_status: Status,
|
||||
|
||||
// Storage metrics (collected from disk metrics)
|
||||
storage_pools: Vec<StoragePool>,
|
||||
@@ -66,7 +66,6 @@ impl SystemWidget {
|
||||
Self {
|
||||
nixos_build: None,
|
||||
config_hash: None,
|
||||
active_users: None,
|
||||
agent_hash: None,
|
||||
cpu_load_1min: None,
|
||||
cpu_load_5min: None,
|
||||
@@ -80,6 +79,7 @@ impl SystemWidget {
|
||||
tmp_used_gb: None,
|
||||
tmp_total_gb: None,
|
||||
memory_status: Status::Unknown,
|
||||
tmp_status: Status::Unknown,
|
||||
storage_pools: Vec::new(),
|
||||
has_data: false,
|
||||
}
|
||||
@@ -334,11 +334,6 @@ impl Widget for SystemWidget {
|
||||
self.config_hash = Some(hash.clone());
|
||||
}
|
||||
}
|
||||
"system_active_users" => {
|
||||
if let MetricValue::String(users) = &metric.value {
|
||||
self.active_users = Some(users.clone());
|
||||
}
|
||||
}
|
||||
"agent_version" => {
|
||||
if let MetricValue::String(version) = &metric.value {
|
||||
self.agent_hash = Some(version.clone());
|
||||
@@ -390,6 +385,7 @@ impl Widget for SystemWidget {
|
||||
"memory_tmp_usage_percent" => {
|
||||
if let MetricValue::Float(usage) = metric.value {
|
||||
self.tmp_usage_percent = Some(usage);
|
||||
self.tmp_status = metric.status.clone();
|
||||
}
|
||||
}
|
||||
"memory_tmp_used_gb" => {
|
||||
@@ -432,10 +428,6 @@ impl SystemWidget {
|
||||
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
|
||||
]));
|
||||
|
||||
let users_text = self.active_users.as_deref().unwrap_or("unknown");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("Active users: {}", users_text), Typography::secondary())
|
||||
]));
|
||||
|
||||
// CPU section
|
||||
lines.push(Line::from(vec![
|
||||
@@ -472,7 +464,7 @@ impl SystemWidget {
|
||||
Span::styled(" └─ ", Typography::tree()),
|
||||
];
|
||||
tmp_spans.extend(StatusIcons::create_status_spans(
|
||||
self.memory_status.clone(),
|
||||
self.tmp_status.clone(),
|
||||
&format!("/tmp: {}", tmp_text)
|
||||
));
|
||||
lines.push(Line::from(tmp_spans));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.21"
|
||||
version = "0.1.25"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Reference in New Issue
Block a user