Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b9f93d7c | |||
| ae70946c61 |
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.21"
|
version = "0.1.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -291,7 +291,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.21"
|
version = "0.1.23"
|
||||||
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.21"
|
version = "0.1.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.22"
|
version = "0.1.24"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.22"
|
version = "0.1.24"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -263,12 +263,7 @@ impl Dashboard {
|
|||||||
cmd_output.output_line
|
cmd_output.output_line
|
||||||
);
|
);
|
||||||
|
|
||||||
// Forward to TUI if not headless
|
// Command output (terminal popup removed - output not displayed)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
last_metrics_check = Instant::now();
|
last_metrics_check = Instant::now();
|
||||||
|
|||||||
@@ -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.22"
|
"v0.1.24"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if running inside tmux session
|
/// Check if running inside tmux session
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use ratatui::{
|
|||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
@@ -28,12 +28,6 @@ pub enum UiCommand {
|
|||||||
TriggerBackup { hostname: String },
|
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
|
/// Types of commands for status tracking
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -70,8 +64,8 @@ pub struct HostWidgets {
|
|||||||
pub backup_scroll_offset: usize,
|
pub backup_scroll_offset: usize,
|
||||||
/// Last update time for this host
|
/// Last update time for this host
|
||||||
pub last_update: Option<Instant>,
|
pub last_update: Option<Instant>,
|
||||||
/// Active command status for visual feedback
|
/// Pending service transitions for immediate visual feedback
|
||||||
pub command_status: Option<CommandStatus>,
|
pub pending_service_transitions: HashMap<String, (CommandType, String)>, // service_name -> (command_type, original_status)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostWidgets {
|
impl HostWidgets {
|
||||||
@@ -84,55 +78,11 @@ impl HostWidgets {
|
|||||||
services_scroll_offset: 0,
|
services_scroll_offset: 0,
|
||||||
backup_scroll_offset: 0,
|
backup_scroll_offset: 0,
|
||||||
last_update: None,
|
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
|
/// Main TUI application
|
||||||
pub struct TuiApp {
|
pub struct TuiApp {
|
||||||
@@ -150,8 +100,6 @@ pub struct TuiApp {
|
|||||||
should_quit: bool,
|
should_quit: bool,
|
||||||
/// Track if user manually navigated away from localhost
|
/// Track if user manually navigated away from localhost
|
||||||
user_navigated_away: bool,
|
user_navigated_away: bool,
|
||||||
/// Terminal popup for streaming command output
|
|
||||||
terminal_popup: Option<TerminalPopup>,
|
|
||||||
/// Dashboard configuration
|
/// Dashboard configuration
|
||||||
config: DashboardConfig,
|
config: DashboardConfig,
|
||||||
}
|
}
|
||||||
@@ -166,7 +114,6 @@ impl TuiApp {
|
|||||||
focused_panel: PanelType::System, // Start with System panel focused
|
focused_panel: PanelType::System, // Start with System panel focused
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
user_navigated_away: false,
|
user_navigated_away: false,
|
||||||
terminal_popup: None,
|
|
||||||
config,
|
config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,8 +127,6 @@ impl TuiApp {
|
|||||||
|
|
||||||
/// Update widgets with metrics from store (only for current host)
|
/// Update widgets with metrics from store (only for current host)
|
||||||
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
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
|
// Check for rebuild completion by agent hash change
|
||||||
|
|
||||||
@@ -215,6 +160,9 @@ impl TuiApp {
|
|||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Clear completed transitions first
|
||||||
|
self.clear_completed_transitions(&hostname, &service_metrics);
|
||||||
|
|
||||||
// Now get host widgets and update them
|
// Now get host widgets and update them
|
||||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
|
|
||||||
@@ -256,9 +204,9 @@ impl TuiApp {
|
|||||||
// Sort hosts alphabetically
|
// Sort hosts alphabetically
|
||||||
let mut sorted_hosts = hosts.clone();
|
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 {
|
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) {
|
if !sorted_hosts.contains(hostname) {
|
||||||
sorted_hosts.push(hostname.clone());
|
sorted_hosts.push(hostname.clone());
|
||||||
}
|
}
|
||||||
@@ -297,38 +245,6 @@ impl TuiApp {
|
|||||||
/// Handle keyboard input
|
/// Handle keyboard input
|
||||||
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> {
|
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> {
|
||||||
if let Event::Key(key) = event {
|
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 {
|
match key.code {
|
||||||
KeyCode::Char('q') => {
|
KeyCode::Char('q') => {
|
||||||
self.should_quit = true;
|
self.should_quit = true;
|
||||||
@@ -361,10 +277,11 @@ impl TuiApp {
|
|||||||
PanelType::Services => {
|
PanelType::Services => {
|
||||||
// Service restart command
|
// Service restart 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()) {
|
||||||
self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone());
|
if self.start_command(&hostname, CommandType::ServiceRestart, service_name.clone()) {
|
||||||
return Ok(Some(UiCommand::ServiceRestart { hostname, service_name }));
|
return Ok(Some(UiCommand::ServiceRestart { hostname, service_name }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
info!("Manual refresh requested");
|
info!("Manual refresh requested");
|
||||||
}
|
}
|
||||||
@@ -374,20 +291,22 @@ impl TuiApp {
|
|||||||
if self.focused_panel == PanelType::Services {
|
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()) {
|
||||||
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 {
|
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()) {
|
||||||
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 {
|
if self.focused_panel == PanelType::Backup {
|
||||||
// Trigger backup
|
// Trigger backup
|
||||||
@@ -495,69 +414,90 @@ impl TuiApp {
|
|||||||
self.should_quit
|
self.should_quit
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start command execution and track status for visual feedback
|
/// Get current service status for state-aware command validation
|
||||||
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
|
||||||
|
// 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) {
|
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||||
host_widgets.command_status = Some(CommandStatus::InProgress {
|
// Store the pending transition for immediate visual feedback
|
||||||
command_type,
|
host_widgets.pending_service_transitions.insert(
|
||||||
target,
|
target.clone(),
|
||||||
start_time: Instant::now(),
|
(command_type, current_status.unwrap_or_else(|| "unknown".to_string()))
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark command as completed successfully
|
should_execute
|
||||||
pub fn _complete_command(&mut self, hostname: &str) {
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||||
// Simply clear the command status when completed
|
let mut completed_services = Vec::new();
|
||||||
host_widgets.command_status = None;
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
/// 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();
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear timed out commands
|
// Remove completed transitions
|
||||||
for hostname in hosts_to_clear {
|
for service_name in completed_services {
|
||||||
if let Some(host_widgets) = self.host_widgets.get_mut(&hostname) {
|
host_widgets.pending_service_transitions.remove(&service_name);
|
||||||
host_widgets.command_status = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// Scroll the focused panel up or down
|
||||||
pub fn scroll_focused_panel(&mut self, direction: i32) {
|
pub fn scroll_focused_panel(&mut self, direction: i32) {
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
@@ -682,25 +622,19 @@ 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 = 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);
|
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);
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||||
host_widgets
|
host_widgets
|
||||||
.services_widget
|
.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
|
// Render statusbar at the bottom
|
||||||
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area
|
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
|
/// Render btop-style minimal title with host status colors
|
||||||
@@ -881,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 super::Widget;
|
||||||
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
||||||
use crate::ui::{CommandStatus, CommandType};
|
use crate::ui::CommandType;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
|
|
||||||
/// Services widget displaying hierarchical systemd service statuses
|
/// Services widget displaying hierarchical systemd service statuses
|
||||||
@@ -128,26 +128,19 @@ impl ServicesWidget {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get status icon for service, considering command status for visual feedback
|
/// Get status icon for service, considering pending transitions for visual feedback
|
||||||
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, command_status: Option<&CommandStatus>) -> (String, String, ratatui::prelude::Color) {
|
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 is currently being operated on
|
// Check if this service has a pending transition
|
||||||
if let Some(status) = command_status {
|
if let Some((command_type, _original_status)) = pending_transitions.get(service_name) {
|
||||||
match status {
|
// Show transitional icons for pending commands
|
||||||
CommandStatus::InProgress { command_type, target, .. } => {
|
let (icon, status_text) = match command_type {
|
||||||
if target == service_name {
|
CommandType::ServiceRestart => ("↻", "restarting"),
|
||||||
// Only show special icons for service commands
|
CommandType::ServiceStart => ("↑", "starting"),
|
||||||
if let Some((icon, status_text)) = match command_type {
|
CommandType::ServiceStop => ("↓", "stopping"),
|
||||||
CommandType::ServiceRestart => Some(("↻", "restarting")),
|
_ => return (StatusIcons::get_icon(info.widget_status).to_string(), info.status.clone(), Theme::status_color(info.widget_status)), // Not a service command
|
||||||
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());
|
return (icon.to_string(), status_text.to_string(), Theme::highlight());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal status display
|
// Normal status display
|
||||||
let icon = StatusIcons::get_icon(info.widget_status);
|
let icon = StatusIcons::get_icon(info.widget_status);
|
||||||
@@ -163,13 +156,13 @@ impl ServicesWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Create spans for sub-service with icon next to name, considering command status
|
/// Create spans for sub-service with icon next to name, considering pending transitions
|
||||||
fn create_sub_service_spans_with_status(
|
fn create_sub_service_spans_with_transitions(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
info: &ServiceInfo,
|
info: &ServiceInfo,
|
||||||
is_last: bool,
|
is_last: bool,
|
||||||
command_status: Option<&CommandStatus>,
|
pending_transitions: &HashMap<String, (CommandType, String)>,
|
||||||
) -> Vec<ratatui::text::Span<'static>> {
|
) -> Vec<ratatui::text::Span<'static>> {
|
||||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||||
let short_name = if name.len() > 18 {
|
let short_name = if name.len() > 18 {
|
||||||
@@ -178,11 +171,11 @@ impl ServicesWidget {
|
|||||||
name.to_string()
|
name.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get status icon and text, considering 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, command_status);
|
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)
|
// For sub-services, prefer latency if available (unless transition is pending)
|
||||||
if command_status.is_none() {
|
if !pending_transitions.contains_key(name) {
|
||||||
if let Some(latency) = info.latency_ms {
|
if let Some(latency) = info.latency_ms {
|
||||||
status_str = if latency < 0.0 {
|
status_str = if latency < 0.0 {
|
||||||
"timeout".to_string()
|
"timeout".to_string()
|
||||||
@@ -273,6 +266,26 @@ impl ServicesWidget {
|
|||||||
self.parent_services.len()
|
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
|
/// Calculate which parent service index corresponds to a display line index
|
||||||
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
||||||
// Build the same display list to map line index to parent service index
|
// Build the same display list to map line index to parent service index
|
||||||
@@ -426,8 +439,8 @@ impl Widget for ServicesWidget {
|
|||||||
|
|
||||||
impl ServicesWidget {
|
impl ServicesWidget {
|
||||||
|
|
||||||
/// Render with focus, scroll, and command status for visual feedback
|
/// Render with focus, scroll, and pending transitions 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>) {
|
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 {
|
let services_block = if is_focused {
|
||||||
Components::focused_widget_block("services")
|
Components::focused_widget_block("services")
|
||||||
} else {
|
} else {
|
||||||
@@ -456,12 +469,12 @@ impl ServicesWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the existing render logic but with command status
|
// Use the existing render logic but with pending transitions
|
||||||
self.render_services_with_status(frame, content_chunks[1], is_focused, scroll_offset, command_status);
|
self.render_services_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, pending_transitions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render services list with command status awareness
|
/// Render services list with pending transitions awareness
|
||||||
fn render_services_with_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) {
|
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)
|
// Build hierarchical service list for display (same as existing logic)
|
||||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
||||||
|
|
||||||
@@ -534,23 +547,20 @@ impl ServicesWidget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut spans = if *is_sub && sub_info.is_some() {
|
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();
|
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 {
|
} else {
|
||||||
// Parent services - check if this parent service has a command in progress
|
// Parent services - check if this parent service has a pending transition
|
||||||
let service_spans = if let Some(status) = command_status {
|
if pending_transitions.contains_key(line_text) {
|
||||||
match status {
|
// Create spans with transitional 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 {
|
let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo {
|
||||||
status: "".to_string(),
|
status: "".to_string(),
|
||||||
memory_mb: None,
|
memory_mb: None,
|
||||||
disk_gb: None,
|
disk_gb: None,
|
||||||
latency_ms: None,
|
latency_ms: None,
|
||||||
widget_status: *line_status
|
widget_status: *line_status
|
||||||
}, command_status);
|
}, pending_transitions);
|
||||||
vec![
|
vec![
|
||||||
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_color)),
|
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(line_text.clone(), Style::default().fg(Theme::primary_text())),
|
||||||
@@ -559,12 +569,6 @@ impl ServicesWidget {
|
|||||||
} else {
|
} else {
|
||||||
StatusIcons::create_status_spans(*line_status, line_text)
|
StatusIcons::create_status_spans(*line_status, line_text)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
StatusIcons::create_status_spans(*line_status, line_text)
|
|
||||||
};
|
|
||||||
service_spans
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply selection highlighting to parent services only, preserving status icon color
|
// Apply selection highlighting to parent services only, preserving status icon color
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.22"
|
version = "0.1.24"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user