All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
Bump version across all workspace crates for next release including agent, dashboard, and shared components.
781 lines
32 KiB
Rust
781 lines
32 KiB
Rust
use anyhow::Result;
|
|
use crossterm::event::{Event, KeyCode};
|
|
use ratatui::{
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::Style,
|
|
widgets::{Block, Paragraph},
|
|
Frame,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::time::Instant;
|
|
use tracing::info;
|
|
use wake_on_lan::MagicPacket;
|
|
|
|
pub mod theme;
|
|
pub mod widgets;
|
|
|
|
use crate::config::DashboardConfig;
|
|
use crate::metrics::MetricStore;
|
|
use cm_dashboard_shared::Status;
|
|
use theme::{Components, Layout as ThemeLayout, Theme, Typography};
|
|
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
|
|
|
|
|
|
|
|
|
|
/// Panel types for focus management
|
|
|
|
/// Widget states for a specific host
|
|
#[derive(Clone)]
|
|
pub struct HostWidgets {
|
|
/// System widget state (includes CPU, Memory, NixOS info, Storage)
|
|
pub system_widget: SystemWidget,
|
|
/// Services widget state
|
|
pub services_widget: ServicesWidget,
|
|
/// Backup widget state
|
|
pub backup_widget: BackupWidget,
|
|
/// Last update time for this host
|
|
pub last_update: Option<Instant>,
|
|
}
|
|
|
|
impl HostWidgets {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
system_widget: SystemWidget::new(),
|
|
services_widget: ServicesWidget::new(),
|
|
backup_widget: BackupWidget::new(),
|
|
last_update: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Main TUI application
|
|
pub struct TuiApp {
|
|
/// Widget states per host (hostname -> HostWidgets)
|
|
host_widgets: HashMap<String, HostWidgets>,
|
|
/// Current active host
|
|
current_host: Option<String>,
|
|
/// Available hosts
|
|
available_hosts: Vec<String>,
|
|
/// Host index for navigation
|
|
host_index: usize,
|
|
/// Should quit application
|
|
should_quit: bool,
|
|
/// Track if user manually navigated away from localhost
|
|
user_navigated_away: bool,
|
|
/// Dashboard configuration
|
|
config: DashboardConfig,
|
|
/// Cached localhost hostname to avoid repeated system calls
|
|
localhost: String,
|
|
}
|
|
|
|
impl TuiApp {
|
|
pub fn new(config: DashboardConfig) -> Self {
|
|
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
|
let mut app = Self {
|
|
host_widgets: HashMap::new(),
|
|
current_host: None,
|
|
available_hosts: config.hosts.keys().cloned().collect(),
|
|
host_index: 0,
|
|
should_quit: false,
|
|
user_navigated_away: false,
|
|
config,
|
|
localhost,
|
|
};
|
|
|
|
// Sort predefined hosts
|
|
app.available_hosts.sort();
|
|
|
|
// Initialize with first host if available
|
|
if !app.available_hosts.is_empty() {
|
|
app.current_host = Some(app.available_hosts[0].clone());
|
|
}
|
|
|
|
app
|
|
}
|
|
|
|
/// Get or create host widgets for the given hostname
|
|
fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets {
|
|
self.host_widgets
|
|
.entry(hostname.to_string())
|
|
.or_insert_with(HostWidgets::new)
|
|
}
|
|
|
|
/// Update widgets with structured data from store (only for current host)
|
|
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Get structured data for this host
|
|
if let Some(agent_data) = metric_store.get_agent_data(&hostname) {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
|
|
// Update all widgets with structured data directly
|
|
host_widgets.system_widget.update_from_agent_data(agent_data);
|
|
host_widgets.services_widget.update_from_agent_data(agent_data);
|
|
host_widgets.backup_widget.update_from_agent_data(agent_data);
|
|
|
|
host_widgets.last_update = Some(Instant::now());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update available hosts with localhost prioritization
|
|
pub fn update_hosts(&mut self, discovered_hosts: Vec<String>) {
|
|
// Start with configured hosts (always visible)
|
|
let mut all_hosts: Vec<String> = self.config.hosts.keys().cloned().collect();
|
|
|
|
// Add any discovered hosts that aren't already configured
|
|
for host in discovered_hosts {
|
|
if !all_hosts.contains(&host) {
|
|
all_hosts.push(host);
|
|
}
|
|
}
|
|
|
|
|
|
all_hosts.sort();
|
|
self.available_hosts = all_hosts;
|
|
|
|
// Get the current hostname (localhost) for auto-selection
|
|
if !self.available_hosts.is_empty() {
|
|
if self.available_hosts.contains(&self.localhost) && !self.user_navigated_away {
|
|
// Localhost is available and user hasn't navigated away - switch to it
|
|
self.current_host = Some(self.localhost.clone());
|
|
// Find the actual index of localhost in the sorted list
|
|
self.host_index = self.available_hosts.iter().position(|h| h == &self.localhost).unwrap_or(0);
|
|
} else if self.current_host.is_none() {
|
|
// No current host - select first available (which is localhost if available)
|
|
self.current_host = Some(self.available_hosts[0].clone());
|
|
self.host_index = 0;
|
|
} else if let Some(ref current) = self.current_host {
|
|
if !self.available_hosts.contains(current) {
|
|
// Current host disconnected - select first available and reset navigation flag
|
|
self.current_host = Some(self.available_hosts[0].clone());
|
|
self.host_index = 0;
|
|
self.user_navigated_away = false; // Reset since we're forced to switch
|
|
} else if let Some(index) = self.available_hosts.iter().position(|h| h == current) {
|
|
// Update index for current host
|
|
self.host_index = index;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle keyboard input
|
|
pub fn handle_input(&mut self, event: Event) -> Result<()> {
|
|
if let Event::Key(key) = event {
|
|
match key.code {
|
|
KeyCode::Char('q') => {
|
|
self.should_quit = true;
|
|
}
|
|
KeyCode::Left => {
|
|
self.navigate_host(-1);
|
|
}
|
|
KeyCode::Right => {
|
|
self.navigate_host(1);
|
|
}
|
|
KeyCode::Char('r') => {
|
|
// System rebuild command - works on any panel for current host
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
// Create command that shows logo, rebuilds, and waits for user input
|
|
let logo_and_rebuild = format!(
|
|
"echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
|
hostname,
|
|
connection_ip,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip,
|
|
self.config.ssh.rebuild_cmd
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30")
|
|
.arg(&logo_and_rebuild)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('B') => {
|
|
// Backup command - works on any panel for current host
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
// Create command that shows logo, runs backup, and waits for user input
|
|
let logo_and_backup = format!(
|
|
"echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
|
hostname,
|
|
connection_ip,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip,
|
|
format!("{} start borgbackup", self.config.ssh.service_manage_cmd)
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30")
|
|
.arg(&logo_and_backup)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('s') => {
|
|
// Service start command via SSH with progress display
|
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
let service_start_command = format!(
|
|
"echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"",
|
|
service_name,
|
|
hostname,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip,
|
|
self.config.ssh.service_manage_cmd,
|
|
service_name
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30")
|
|
.arg(&service_start_command)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('S') => {
|
|
// Service stop command via SSH with progress display
|
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
let service_stop_command = format!(
|
|
"echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"",
|
|
service_name,
|
|
hostname,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip,
|
|
self.config.ssh.service_manage_cmd,
|
|
service_name
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30")
|
|
.arg(&service_stop_command)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('L') => {
|
|
// Show service logs via service-manage script in tmux split window
|
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
let logs_command = format!(
|
|
"ssh -tt {}@{} '{} logs {}'",
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip,
|
|
self.config.ssh.service_manage_cmd,
|
|
service_name
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30")
|
|
.arg(&logs_command)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Char('w') => {
|
|
// Wake on LAN for offline hosts
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Check if host has MAC address configured
|
|
if let Some(host_details) = self.config.hosts.get(&hostname) {
|
|
if let Some(mac_address) = &host_details.mac_address {
|
|
// Parse MAC address and send WoL packet
|
|
let mac_bytes = Self::parse_mac_address(mac_address);
|
|
match mac_bytes {
|
|
Ok(mac) => {
|
|
match MagicPacket::new(&mac).send() {
|
|
Ok(_) => {
|
|
info!("WakeOnLAN packet sent successfully to {} ({})", hostname, mac_address);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to send WakeOnLAN packet to {}: {}", hostname, e);
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
tracing::error!("Invalid MAC address format for {}: {}", hostname, mac_address);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('t') => {
|
|
// Open SSH terminal session in tmux window
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let connection_ip = self.get_connection_ip(&hostname);
|
|
let ssh_command = format!(
|
|
"echo 'Opening SSH terminal to: {}' && ssh -tt {}@{}",
|
|
hostname,
|
|
self.config.ssh.rebuild_user,
|
|
connection_ip
|
|
);
|
|
|
|
std::process::Command::new("tmux")
|
|
.arg("split-window")
|
|
.arg("-v")
|
|
.arg("-p")
|
|
.arg("30") // Use 30% like other commands
|
|
.arg(&ssh_command)
|
|
.spawn()
|
|
.ok(); // Ignore errors, tmux will handle them
|
|
}
|
|
}
|
|
KeyCode::Tab => {
|
|
// 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::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);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Navigate between hosts
|
|
fn navigate_host(&mut self, direction: i32) {
|
|
if self.available_hosts.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let len = self.available_hosts.len();
|
|
if direction > 0 {
|
|
self.host_index = (self.host_index + 1) % len;
|
|
} else {
|
|
self.host_index = if self.host_index == 0 {
|
|
len - 1
|
|
} else {
|
|
self.host_index - 1
|
|
};
|
|
}
|
|
|
|
self.current_host = Some(self.available_hosts[self.host_index].clone());
|
|
|
|
// Check if user navigated away from localhost
|
|
if let Some(ref current) = self.current_host {
|
|
if current != &self.localhost {
|
|
self.user_navigated_away = true;
|
|
} else {
|
|
self.user_navigated_away = false; // User navigated back to localhost
|
|
}
|
|
}
|
|
|
|
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the currently selected service name from the services widget
|
|
fn get_selected_service(&self) -> Option<String> {
|
|
if let Some(hostname) = &self.current_host {
|
|
if let Some(host_widgets) = self.host_widgets.get(hostname) {
|
|
return host_widgets.services_widget.get_selected_service();
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
|
|
/// Should quit application
|
|
pub fn should_quit(&self) -> bool {
|
|
self.should_quit
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Render the dashboard (real btop-style multi-panel layout)
|
|
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
|
|
let size = frame.size();
|
|
|
|
// Clear background to true black like btop
|
|
frame.render_widget(
|
|
Block::default().style(Style::default().bg(Theme::background())),
|
|
size,
|
|
);
|
|
|
|
// Create real btop-style layout: multi-panel with borders
|
|
// Three-section layout: title bar, main content, statusbar
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1), // Title bar
|
|
Constraint::Min(0), // Main content area
|
|
Constraint::Length(1), // Statusbar
|
|
])
|
|
.split(size);
|
|
|
|
// New layout: left panels | right services (100% height)
|
|
let content_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup
|
|
Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height)
|
|
])
|
|
.split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar)
|
|
|
|
// Check if current host is offline
|
|
let current_host_offline = if let Some(hostname) = self.current_host.clone() {
|
|
self.calculate_host_status(&hostname, metric_store) == Status::Offline
|
|
} else {
|
|
true // No host selected is considered offline
|
|
};
|
|
|
|
// If host is offline, render wake-up message instead of panels
|
|
if current_host_offline {
|
|
self.render_offline_host_message(frame, main_chunks[1]);
|
|
self.render_btop_title(frame, main_chunks[0], metric_store);
|
|
self.render_statusbar(frame, main_chunks[2]);
|
|
return;
|
|
}
|
|
|
|
// Check if backup panel should be shown
|
|
let show_backup = if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.backup_widget.has_data()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Left side: dynamic layout based on backup data availability
|
|
let left_chunks = if show_backup {
|
|
// Show both system and backup panels
|
|
ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section
|
|
Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section
|
|
])
|
|
.split(content_chunks[0])
|
|
} else {
|
|
// Show only system panel (full height)
|
|
ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Percentage(100)]) // System section takes full height
|
|
.split(content_chunks[0])
|
|
};
|
|
|
|
// Render title bar
|
|
self.render_btop_title(frame, main_chunks[0], metric_store);
|
|
|
|
// Render new panel layout
|
|
self.render_system_panel(frame, left_chunks[0], metric_store);
|
|
if show_backup && left_chunks.len() > 1 {
|
|
self.render_backup_panel(frame, left_chunks[1]);
|
|
}
|
|
|
|
// Render services widget for current host
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let is_focused = true; // Always show service selection
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets
|
|
.services_widget
|
|
.render(frame, content_chunks[1], is_focused); // Services takes full right side
|
|
}
|
|
|
|
// Render statusbar at the bottom
|
|
self.render_statusbar(frame, main_chunks[2]); // main_chunks[2] is the statusbar area
|
|
|
|
}
|
|
|
|
/// Render btop-style minimal title with host status colors
|
|
fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
use theme::StatusIcons;
|
|
|
|
if self.available_hosts.is_empty() {
|
|
let title_text = "cm-dashboard • no hosts discovered";
|
|
let title = Paragraph::new(title_text)
|
|
.style(Style::default().fg(Theme::background()).bg(Theme::status_color(Status::Unknown)));
|
|
frame.render_widget(title, area);
|
|
return;
|
|
}
|
|
|
|
// Calculate worst-case status across all hosts (excluding offline)
|
|
let mut worst_status = Status::Ok;
|
|
for host in &self.available_hosts {
|
|
let host_status = self.calculate_host_status(host, metric_store);
|
|
// Don't include offline hosts in status aggregation
|
|
if host_status != Status::Offline {
|
|
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(22), Constraint::Min(0)])
|
|
.split(area);
|
|
|
|
// Left side: "cm-dashboard" text with version
|
|
let title_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION"));
|
|
let left_span = Span::styled(
|
|
&title_text,
|
|
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
|
);
|
|
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
|
.style(Style::default().bg(background_color));
|
|
frame.render_widget(left_title, chunks[0]);
|
|
|
|
// Right side: hosts with status indicators
|
|
let mut host_spans = Vec::new();
|
|
|
|
for (i, host) in self.available_hosts.iter().enumerate() {
|
|
if i > 0 {
|
|
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 = StatusIcons::get_icon(host_status);
|
|
|
|
// Add status icon with background color as foreground against status background
|
|
host_spans.push(Span::styled(
|
|
format!("{} ", status_icon),
|
|
Style::default().fg(Theme::background()).bg(background_color),
|
|
));
|
|
|
|
if Some(host) == self.current_host.as_ref() {
|
|
// Selected host in bold background color against status background
|
|
host_spans.push(Span::styled(
|
|
host.clone(),
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
} else {
|
|
// Other hosts in normal background color against status background
|
|
host_spans.push(Span::styled(
|
|
host.clone(),
|
|
Style::default().fg(Theme::background()).bg(background_color),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Add right padding
|
|
host_spans.push(Span::styled(
|
|
" ",
|
|
Style::default().fg(Theme::background()).bg(background_color)
|
|
));
|
|
|
|
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 structured data
|
|
fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status {
|
|
// Check if we have structured data for this host
|
|
if let Some(_agent_data) = metric_store.get_agent_data(hostname) {
|
|
// Return OK since we have data
|
|
Status::Ok
|
|
} else {
|
|
Status::Offline
|
|
}
|
|
}
|
|
|
|
/// Render dynamic statusbar with context-aware shortcuts
|
|
fn render_statusbar(&self, frame: &mut Frame, area: Rect) {
|
|
let shortcuts = self.get_context_shortcuts();
|
|
let statusbar_text = shortcuts.join(" • ");
|
|
|
|
let statusbar = Paragraph::new(statusbar_text)
|
|
.style(Typography::secondary())
|
|
.alignment(ratatui::layout::Alignment::Center);
|
|
|
|
frame.render_widget(statusbar, area);
|
|
}
|
|
|
|
/// Get context-aware shortcuts based on focused panel
|
|
fn get_context_shortcuts(&self) -> Vec<String> {
|
|
let mut shortcuts = Vec::new();
|
|
|
|
// Global shortcuts
|
|
shortcuts.push("Tab: Host".to_string());
|
|
shortcuts.push("↑↓/jk: Select".to_string());
|
|
shortcuts.push("r: Rebuild".to_string());
|
|
shortcuts.push("B: Backup".to_string());
|
|
shortcuts.push("s/S: Start/Stop".to_string());
|
|
shortcuts.push("L: Logs".to_string());
|
|
shortcuts.push("t: Terminal".to_string());
|
|
shortcuts.push("w: Wake".to_string());
|
|
|
|
// Always show quit
|
|
shortcuts.push("q: Quit".to_string());
|
|
|
|
shortcuts
|
|
}
|
|
|
|
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) {
|
|
let system_block = Components::widget_block("system");
|
|
let inner_area = system_block.inner(area);
|
|
frame.render_widget(system_block, area);
|
|
// Get current host widgets, create if none exist
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Clone the config to avoid borrowing issues
|
|
let config = self.config.clone();
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.system_widget.render(frame, inner_area, &hostname, Some(&config));
|
|
}
|
|
}
|
|
|
|
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
|
|
let backup_block = Components::widget_block("backup");
|
|
let inner_area = backup_block.inner(area);
|
|
frame.render_widget(backup_block, area);
|
|
|
|
// Get current host widgets for backup widget
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.backup_widget.render(frame, inner_area);
|
|
}
|
|
}
|
|
|
|
/// Render offline host message with wake-up option
|
|
fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) {
|
|
use ratatui::layout::Alignment;
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
|
|
|
// Get hostname for message
|
|
let hostname = self.current_host.as_ref()
|
|
.map(|h| h.as_str())
|
|
.unwrap_or("Unknown");
|
|
|
|
// Check if host has MAC address for wake-on-LAN
|
|
let has_mac = self.current_host.as_ref()
|
|
.and_then(|hostname| self.config.hosts.get(hostname))
|
|
.and_then(|details| details.mac_address.as_ref())
|
|
.is_some();
|
|
|
|
// Create message content
|
|
let mut lines = vec![
|
|
Line::from(Span::styled(
|
|
format!("Host '{}' is offline", hostname),
|
|
Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(""),
|
|
];
|
|
|
|
if has_mac {
|
|
lines.push(Line::from(Span::styled(
|
|
"Press 'w' to wake up host",
|
|
Style::default().fg(Theme::primary_text()).add_modifier(Modifier::BOLD),
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(Span::styled(
|
|
"No MAC address configured - cannot wake up",
|
|
Style::default().fg(Theme::muted_text()),
|
|
)));
|
|
}
|
|
|
|
// Create centered message
|
|
let message = Paragraph::new(lines)
|
|
.block(Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Theme::muted_text()))
|
|
.title(" Offline Host ")
|
|
.title_style(Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD)))
|
|
.style(Style::default().bg(Theme::background()).fg(Theme::primary_text()))
|
|
.alignment(Alignment::Center);
|
|
|
|
// Center the message in the available area
|
|
let popup_area = ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(40),
|
|
Constraint::Length(6),
|
|
Constraint::Percentage(40),
|
|
])
|
|
.split(area)[1];
|
|
|
|
let popup_area = ratatui::layout::Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(25),
|
|
Constraint::Percentage(50),
|
|
Constraint::Percentage(25),
|
|
])
|
|
.split(popup_area)[1];
|
|
|
|
frame.render_widget(message, popup_area);
|
|
}
|
|
|
|
/// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6]
|
|
/// Get the connection IP for a hostname based on host configuration
|
|
fn get_connection_ip(&self, hostname: &str) -> String {
|
|
if let Some(host_details) = self.config.hosts.get(hostname) {
|
|
host_details.get_connection_ip(hostname)
|
|
} else {
|
|
hostname.to_string()
|
|
}
|
|
}
|
|
|
|
fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> {
|
|
let parts: Vec<&str> = mac_str.split(':').collect();
|
|
if parts.len() != 6 {
|
|
return Err("MAC address must have 6 parts separated by colons");
|
|
}
|
|
|
|
let mut mac = [0u8; 6];
|
|
for (i, part) in parts.iter().enumerate() {
|
|
match u8::from_str_radix(part, 16) {
|
|
Ok(byte) => mac[i] = byte,
|
|
Err(_) => return Err("Invalid hexadecimal byte in MAC address"),
|
|
}
|
|
}
|
|
Ok(mac)
|
|
}
|
|
}
|