Implement per-service disk usage monitoring
Replaced system-wide disk usage with accurate per-service tracking by scanning service-specific directories. Services like sshd now correctly show minimal disk usage instead of misleading system totals. - Rename storage widget and add drive capacity/usage columns - Move host display to main dashboard title for cleaner layout - Replace separate alert displays with color-coded row highlighting - Add per-service disk usage collection using du command - Update services widget formatting to handle small disk values - Restructure into workspace with dedicated agent and dashboard packages
This commit is contained in:
@@ -18,3 +18,4 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
zmq = "0.10"
|
||||
gethostname = "0.4"
|
||||
|
||||
37
dashboard/config/dashboard.toml
Normal file
37
dashboard/config/dashboard.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
# CM Dashboard configuration
|
||||
|
||||
[hosts]
|
||||
# default_host = "srv01"
|
||||
|
||||
[[hosts.hosts]]
|
||||
name = "srv01"
|
||||
enabled = true
|
||||
# metadata = { rack = "R1" }
|
||||
|
||||
[[hosts.hosts]]
|
||||
name = "labbox"
|
||||
enabled = true
|
||||
|
||||
[dashboard]
|
||||
tick_rate_ms = 250
|
||||
history_duration_minutes = 60
|
||||
|
||||
[[dashboard.widgets]]
|
||||
id = "nvme"
|
||||
enabled = true
|
||||
|
||||
[[dashboard.widgets]]
|
||||
id = "services"
|
||||
enabled = true
|
||||
|
||||
[[dashboard.widgets]]
|
||||
id = "backup"
|
||||
enabled = true
|
||||
|
||||
[[dashboard.widgets]]
|
||||
id = "alerts"
|
||||
enabled = true
|
||||
|
||||
[filesystem]
|
||||
# cache_dir = "/var/lib/cm-dashboard/cache"
|
||||
# history_dir = "/var/lib/cm-dashboard/history"
|
||||
12
dashboard/config/hosts.toml
Normal file
12
dashboard/config/hosts.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
# Optional separate hosts configuration
|
||||
|
||||
[hosts]
|
||||
# default_host = "srv01"
|
||||
|
||||
[[hosts.hosts]]
|
||||
name = "srv01"
|
||||
enabled = true
|
||||
|
||||
[[hosts.hosts]]
|
||||
name = "labbox"
|
||||
enabled = true
|
||||
@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
|
||||
use gethostname::gethostname;
|
||||
|
||||
use crate::config;
|
||||
use crate::data::config::{AppConfig, DataSourceKind, HostTarget, ZmqConfig};
|
||||
@@ -100,8 +101,8 @@ impl App {
|
||||
let host_count = self.hosts.len();
|
||||
let retention = self.history.retention();
|
||||
self.status = format!(
|
||||
"Monitoring • hosts: {} • ticks: {} • refresh: {:?} • retention: {:?}",
|
||||
host_count, self.tick_count, self.options.tick_rate, retention
|
||||
"Monitoring • hosts: {} • refresh: {:?} • retention: {:?}",
|
||||
host_count, self.options.tick_rate, retention
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,49 +322,94 @@ impl App {
|
||||
}
|
||||
|
||||
fn build_initial_status(host: Option<&String>, config_path: Option<&PathBuf>) -> String {
|
||||
match (host, config_path) {
|
||||
(Some(host), Some(path)) => {
|
||||
let detected = Self::local_hostname();
|
||||
match (host, config_path, detected.as_ref()) {
|
||||
(Some(host), Some(path), _) => {
|
||||
format!("Ready • host: {} • config: {}", host, path.display())
|
||||
}
|
||||
(Some(host), None) => format!("Ready • host: {}", host),
|
||||
(None, Some(path)) => format!("Ready • config: {}", path.display()),
|
||||
(None, None) => "Ready • no host selected".to_string(),
|
||||
(Some(host), None, _) => format!("Ready • host: {}", host),
|
||||
(None, Some(path), Some(local)) => format!(
|
||||
"Ready • host: {} (auto) • config: {}",
|
||||
local,
|
||||
path.display()
|
||||
),
|
||||
(None, Some(path), None) => format!("Ready • config: {}", path.display()),
|
||||
(None, None, Some(local)) => format!("Ready • host: {} (auto)", local),
|
||||
(None, None, None) => "Ready • no host selected".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_hosts(host: Option<&String>, config: Option<&AppConfig>) -> Vec<HostTarget> {
|
||||
let mut targets = Vec::new();
|
||||
|
||||
let Some(config) = config else {
|
||||
return targets;
|
||||
};
|
||||
if let Some(filter) = host {
|
||||
let normalized = filter.to_lowercase();
|
||||
|
||||
let host_filter = host.map(|value| value.to_lowercase());
|
||||
|
||||
for entry in &config.hosts.hosts {
|
||||
if !entry.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(filter) = &host_filter {
|
||||
if entry.name.to_lowercase() != *filter {
|
||||
continue;
|
||||
if let Some(config) = config {
|
||||
if let Some(entry) = config.hosts.hosts.iter().find(|candidate| {
|
||||
candidate.enabled && candidate.name.to_lowercase() == normalized
|
||||
}) {
|
||||
return vec![entry.clone()];
|
||||
}
|
||||
}
|
||||
|
||||
targets.push(entry.clone());
|
||||
return vec![HostTarget::from_name(filter.clone())];
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
if let Some(default_host) = &config.hosts.default_host {
|
||||
if host_filter.is_none() {
|
||||
if let Some(entry) = config.hosts.hosts.iter().find(|candidate| {
|
||||
candidate.enabled && candidate.name.eq_ignore_ascii_case(default_host)
|
||||
}) {
|
||||
targets.push(entry.clone());
|
||||
let local_host = Self::local_hostname();
|
||||
|
||||
if let Some(config) = config {
|
||||
if let Some(local) = local_host.as_ref() {
|
||||
if let Some(entry) = config.hosts.hosts.iter().find(|candidate| {
|
||||
candidate.enabled && candidate.name.eq_ignore_ascii_case(local)
|
||||
}) {
|
||||
targets.push(entry.clone());
|
||||
} else {
|
||||
targets.push(HostTarget::from_name(local.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
for entry in &config.hosts.hosts {
|
||||
if !entry.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
if targets
|
||||
.iter()
|
||||
.any(|existing| existing.name.eq_ignore_ascii_case(&entry.name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.push(entry.clone());
|
||||
}
|
||||
|
||||
if targets.len() <= 1 {
|
||||
if let Some(default_host) = &config.hosts.default_host {
|
||||
if !targets
|
||||
.iter()
|
||||
.any(|existing| existing.name.eq_ignore_ascii_case(default_host))
|
||||
{
|
||||
if let Some(entry) = config.hosts.hosts.iter().find(|candidate| {
|
||||
candidate.enabled && candidate.name.eq_ignore_ascii_case(default_host)
|
||||
}) {
|
||||
targets.push(entry.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
if let Some(local) = local_host {
|
||||
targets.push(HostTarget::from_name(local));
|
||||
}
|
||||
}
|
||||
} else if let Some(local) = local_host {
|
||||
targets.push(HostTarget::from_name(local));
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
targets.push(HostTarget::from_name("localhost".to_string()));
|
||||
}
|
||||
|
||||
targets
|
||||
@@ -437,6 +483,18 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn local_hostname() -> Option<String> {
|
||||
let raw = gethostname();
|
||||
let value = raw.to_string_lossy().trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostDisplayData {
|
||||
pub name: String,
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct DriveInfo {
|
||||
pub wear_level: f32,
|
||||
pub power_on_hours: u64,
|
||||
pub available_spare: f32,
|
||||
pub capacity_gb: Option<f32>,
|
||||
pub used_gb: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -44,6 +46,28 @@ pub struct ServiceSummary {
|
||||
pub failed: usize,
|
||||
pub memory_used_mb: f32,
|
||||
pub memory_quota_mb: f32,
|
||||
#[serde(default)]
|
||||
pub system_memory_used_mb: f32,
|
||||
#[serde(default)]
|
||||
pub system_memory_total_mb: f32,
|
||||
#[serde(default)]
|
||||
pub disk_used_gb: f32,
|
||||
#[serde(default)]
|
||||
pub disk_total_gb: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_load_1: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_load_5: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_load_15: f32,
|
||||
#[serde(default)]
|
||||
pub cpu_freq_mhz: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub gpu_load_percent: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub gpu_temp_c: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -54,6 +78,8 @@ pub struct ServiceInfo {
|
||||
pub memory_quota_mb: f32,
|
||||
pub cpu_percent: f32,
|
||||
pub sandbox_limit: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub disk_used_gb: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -6,7 +6,10 @@ mod ui;
|
||||
use std::fs;
|
||||
use std::io::{self, Stdout};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, OnceLock,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::data::metrics::{BackupMetrics, ServiceMetrics, SmartMetrics};
|
||||
@@ -100,8 +103,14 @@ async fn main() -> Result<()> {
|
||||
let mut app = App::new(options)?;
|
||||
let (event_tx, mut event_rx) = unbounded_channel();
|
||||
|
||||
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let zmq_task = if let Some(context) = app.zmq_context() {
|
||||
Some(spawn_metrics_task(context, event_tx.clone()))
|
||||
Some(spawn_metrics_task(
|
||||
context,
|
||||
event_tx.clone(),
|
||||
shutdown_flag.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -109,9 +118,12 @@ async fn main() -> Result<()> {
|
||||
let mut terminal = setup_terminal()?;
|
||||
let result = run_app(&mut terminal, &mut app, &mut event_rx);
|
||||
teardown_terminal(terminal)?;
|
||||
shutdown_flag.store(true, Ordering::Relaxed);
|
||||
let _ = event_tx.send(AppEvent::Shutdown);
|
||||
if let Some(handle) = zmq_task {
|
||||
handle.abort();
|
||||
if let Err(join_error) = handle.await {
|
||||
warn!(%join_error, "ZMQ metrics task ended unexpectedly");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -206,9 +218,13 @@ fn prepare_log_writer() -> Result<tracing_appender::non_blocking::NonBlocking> {
|
||||
Ok(non_blocking)
|
||||
}
|
||||
|
||||
fn spawn_metrics_task(context: ZmqContext, sender: UnboundedSender<AppEvent>) -> JoinHandle<()> {
|
||||
fn spawn_metrics_task(
|
||||
context: ZmqContext,
|
||||
sender: UnboundedSender<AppEvent>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
match spawn_blocking(move || metrics_blocking_loop(context, sender)).await {
|
||||
match spawn_blocking(move || metrics_blocking_loop(context, sender, shutdown)).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => warn!(%error, "ZMQ metrics worker exited with error"),
|
||||
Err(join_error) => warn!(%join_error, "ZMQ metrics worker panicked"),
|
||||
@@ -216,12 +232,23 @@ fn spawn_metrics_task(context: ZmqContext, sender: UnboundedSender<AppEvent>) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics_blocking_loop(context: ZmqContext, sender: UnboundedSender<AppEvent>) -> Result<()> {
|
||||
fn metrics_blocking_loop(
|
||||
context: ZmqContext,
|
||||
sender: UnboundedSender<AppEvent>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let zmq_context = NativeZmqContext::new();
|
||||
let socket = zmq_context
|
||||
.socket(zmq::SUB)
|
||||
.context("failed to create ZMQ SUB socket")?;
|
||||
|
||||
socket
|
||||
.set_linger(0)
|
||||
.context("failed to configure ZMQ linger")?;
|
||||
socket
|
||||
.set_rcvtimeo(1_000)
|
||||
.context("failed to configure ZMQ receive timeout")?;
|
||||
|
||||
for endpoint in context.endpoints() {
|
||||
debug!(%endpoint, "connecting to ZMQ endpoint");
|
||||
socket
|
||||
@@ -239,7 +266,7 @@ fn metrics_blocking_loop(context: ZmqContext, sender: UnboundedSender<AppEvent>)
|
||||
.context("failed to subscribe to all ZMQ topics")?;
|
||||
}
|
||||
|
||||
loop {
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match socket.recv_msg(0) {
|
||||
Ok(message) => {
|
||||
if let Err(error) = handle_zmq_message(&message, &sender) {
|
||||
@@ -247,11 +274,18 @@ fn metrics_blocking_loop(context: ZmqContext, sender: UnboundedSender<AppEvent>)
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if error == zmq::Error::EAGAIN {
|
||||
continue;
|
||||
}
|
||||
warn!(%error, "ZMQ receive error");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("ZMQ metrics worker shutting down");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_zmq_message(
|
||||
@@ -442,7 +476,7 @@ tick_rate_ms = 250
|
||||
history_duration_minutes = 60
|
||||
|
||||
[[dashboard.widgets]]
|
||||
id = "nvme"
|
||||
id = "storage"
|
||||
enabled = true
|
||||
|
||||
[[dashboard.widgets]]
|
||||
|
||||
@@ -1,51 +1,299 @@
|
||||
use ratatui::layout::Rect;
|
||||
use chrono::{DateTime, Utc};
|
||||
use ratatui::layout::{Constraint, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::ui::memory::{evaluate_performance, PerfSeverity};
|
||||
|
||||
pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) {
|
||||
let block = Block::default()
|
||||
.title("Alerts")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::LightRed));
|
||||
let (severity, ok_count, warn_count, fail_count) = classify_hosts(hosts);
|
||||
let color = match severity {
|
||||
AlertSeverity::Critical => Color::Red,
|
||||
AlertSeverity::Warning => Color::Yellow,
|
||||
AlertSeverity::Healthy => Color::Green,
|
||||
AlertSeverity::Unknown => Color::LightCyan,
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let title = format!(
|
||||
"Alerts • ok:{} warn:{} fail:{}",
|
||||
ok_count, warn_count, fail_count
|
||||
);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(color))
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if hosts.is_empty() {
|
||||
lines.push(Line::from("No hosts configured"));
|
||||
} else {
|
||||
for host in hosts {
|
||||
if let Some(error) = &host.last_error {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(&host.name, Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(": "),
|
||||
Span::styled(error, Style::default().fg(Color::Red)),
|
||||
]));
|
||||
continue;
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new("No hosts configured")
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(smart) = host.smart.as_ref() {
|
||||
if let Some(issue) = smart.issues.first() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(&host.name, Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(": "),
|
||||
Span::styled(issue, Style::default().fg(Color::Yellow)),
|
||||
]));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Host"),
|
||||
Cell::from("Status"),
|
||||
Cell::from("Timestamp"),
|
||||
])
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(&host.name, Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(": OK"),
|
||||
]));
|
||||
let rows = hosts.iter().map(|host| {
|
||||
let (status, severity, emphasize) = host_status(host);
|
||||
let row_style = severity_style(severity);
|
||||
let update = latest_timestamp(host)
|
||||
.map(|ts| ts.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
let status_cell = if emphasize {
|
||||
Cell::from(Span::styled(
|
||||
status.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))
|
||||
} else {
|
||||
Cell::from(status.clone())
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(host.name.clone()),
|
||||
status_cell,
|
||||
Cell::from(update),
|
||||
])
|
||||
.style(row_style)
|
||||
});
|
||||
|
||||
let table = Table::new(rows)
|
||||
.header(header)
|
||||
.style(Style::default().fg(Color::White))
|
||||
.widths(&[
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Length(20),
|
||||
Constraint::Min(24),
|
||||
])
|
||||
.column_spacing(2);
|
||||
|
||||
frame.render_widget(table, inner);
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
enum AlertSeverity {
|
||||
Healthy,
|
||||
Warning,
|
||||
Critical,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn classify_hosts(hosts: &[HostDisplayData]) -> (AlertSeverity, usize, usize, usize) {
|
||||
let mut ok = 0;
|
||||
let mut warn = 0;
|
||||
let mut fail = 0;
|
||||
|
||||
for host in hosts {
|
||||
let severity = host_severity(host);
|
||||
match severity {
|
||||
AlertSeverity::Healthy => ok += 1,
|
||||
AlertSeverity::Warning => warn += 1,
|
||||
AlertSeverity::Critical => fail += 1,
|
||||
AlertSeverity::Unknown => warn += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block);
|
||||
let highest = if fail > 0 {
|
||||
AlertSeverity::Critical
|
||||
} else if warn > 0 {
|
||||
AlertSeverity::Warning
|
||||
} else if ok > 0 {
|
||||
AlertSeverity::Healthy
|
||||
} else {
|
||||
AlertSeverity::Unknown
|
||||
};
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
(highest, ok, warn, fail)
|
||||
}
|
||||
|
||||
fn host_severity(host: &HostDisplayData) -> AlertSeverity {
|
||||
if host.last_error.is_some() {
|
||||
return AlertSeverity::Critical;
|
||||
}
|
||||
|
||||
if let Some(smart) = host.smart.as_ref() {
|
||||
if smart.summary.critical > 0 {
|
||||
return AlertSeverity::Critical;
|
||||
}
|
||||
if smart.summary.warning > 0 || !smart.issues.is_empty() {
|
||||
return AlertSeverity::Warning;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(services) = host.services.as_ref() {
|
||||
if services.summary.failed > 0 {
|
||||
return AlertSeverity::Critical;
|
||||
}
|
||||
if services.summary.degraded > 0 {
|
||||
return AlertSeverity::Warning;
|
||||
}
|
||||
|
||||
let (perf_severity, _) = evaluate_performance(&services.summary);
|
||||
match perf_severity {
|
||||
PerfSeverity::Critical => return AlertSeverity::Critical,
|
||||
PerfSeverity::Warning => return AlertSeverity::Warning,
|
||||
PerfSeverity::Ok => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(backup) = host.backup.as_ref() {
|
||||
match backup.overall_status {
|
||||
crate::data::metrics::BackupStatus::Failed => return AlertSeverity::Critical,
|
||||
crate::data::metrics::BackupStatus::Warning => return AlertSeverity::Warning,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if host.smart.is_none() && host.services.is_none() && host.backup.is_none() {
|
||||
AlertSeverity::Unknown
|
||||
} else {
|
||||
AlertSeverity::Healthy
|
||||
}
|
||||
}
|
||||
|
||||
fn host_status(host: &HostDisplayData) -> (String, AlertSeverity, bool) {
|
||||
if let Some(error) = &host.last_error {
|
||||
return (format!("error: {}", error), AlertSeverity::Critical, true);
|
||||
}
|
||||
|
||||
if let Some(smart) = host.smart.as_ref() {
|
||||
if smart.summary.critical > 0 {
|
||||
return (
|
||||
"critical: SMART critical".to_string(),
|
||||
AlertSeverity::Critical,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if let Some(issue) = smart.issues.first() {
|
||||
return (format!("warning: {}", issue), AlertSeverity::Warning, true);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(services) = host.services.as_ref() {
|
||||
if services.summary.failed > 0 {
|
||||
return (
|
||||
format!("critical: {} failed svc", services.summary.failed),
|
||||
AlertSeverity::Critical,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if services.summary.degraded > 0 {
|
||||
return (
|
||||
format!("warning: {} degraded svc", services.summary.degraded),
|
||||
AlertSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
let (perf_severity, reason) = evaluate_performance(&services.summary);
|
||||
if let Some(reason_text) = reason {
|
||||
match perf_severity {
|
||||
PerfSeverity::Critical => {
|
||||
return (
|
||||
format!("critical: {}", reason_text),
|
||||
AlertSeverity::Critical,
|
||||
true,
|
||||
);
|
||||
}
|
||||
PerfSeverity::Warning => {
|
||||
return (
|
||||
format!("warning: {}", reason_text),
|
||||
AlertSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
}
|
||||
PerfSeverity::Ok => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(backup) = host.backup.as_ref() {
|
||||
match backup.overall_status {
|
||||
crate::data::metrics::BackupStatus::Failed => {
|
||||
return (
|
||||
"critical: backup failed".to_string(),
|
||||
AlertSeverity::Critical,
|
||||
true,
|
||||
);
|
||||
}
|
||||
crate::data::metrics::BackupStatus::Warning => {
|
||||
return (
|
||||
"warning: backup warning".to_string(),
|
||||
AlertSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if host.smart.is_none() && host.services.is_none() && host.backup.is_none() {
|
||||
let status = if host.last_success.is_none() {
|
||||
"pending: awaiting metrics"
|
||||
} else {
|
||||
"pending: no recent data"
|
||||
};
|
||||
|
||||
return (status.to_string(), AlertSeverity::Warning, false);
|
||||
}
|
||||
|
||||
("ok".to_string(), AlertSeverity::Healthy, false)
|
||||
}
|
||||
|
||||
fn severity_style(severity: AlertSeverity) -> Style {
|
||||
match severity {
|
||||
AlertSeverity::Critical => Style::default().fg(Color::Red),
|
||||
AlertSeverity::Warning => Style::default().fg(Color::Yellow),
|
||||
AlertSeverity::Healthy => Style::default().fg(Color::White),
|
||||
AlertSeverity::Unknown => Style::default().fg(Color::LightCyan),
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_timestamp(host: &HostDisplayData) -> Option<DateTime<Utc>> {
|
||||
let mut latest = host.last_success;
|
||||
|
||||
if let Some(smart) = host.smart.as_ref() {
|
||||
latest = Some(match latest {
|
||||
Some(current) => current.max(smart.timestamp),
|
||||
None => smart.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(services) = host.services.as_ref() {
|
||||
latest = Some(match latest {
|
||||
Some(current) => current.max(services.timestamp),
|
||||
None => services.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(backup) = host.backup.as_ref() {
|
||||
latest = Some(match latest {
|
||||
Some(current) => current.max(backup.timestamp),
|
||||
None => backup.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
latest
|
||||
}
|
||||
|
||||
@@ -1,62 +1,166 @@
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::{BackupMetrics, BackupStatus};
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title("Backups")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::LightGreen));
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
match host {
|
||||
Some(data) => {
|
||||
if let Some(metrics) = data.backup.as_ref() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(data.name.clone()),
|
||||
]));
|
||||
lines.push(Line::from(format!("Status: {:?}", metrics.overall_status)));
|
||||
|
||||
if let Some(last_success) = metrics.backup.last_success.as_ref() {
|
||||
lines.push(Line::from(format!(
|
||||
"Last success: {}",
|
||||
last_success.format("%Y-%m-%d %H:%M:%S")
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(last_failure) = metrics.backup.last_failure.as_ref() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Last failure: ", Style::default().fg(Color::Red)),
|
||||
Span::raw(last_failure.format("%Y-%m-%d %H:%M:%S").to_string()),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(format!(
|
||||
"Snapshots: {} • Size: {:.1} GiB",
|
||||
metrics.backup.snapshot_count, metrics.backup.size_gb
|
||||
)));
|
||||
|
||||
lines.push(Line::from(format!(
|
||||
"Pending jobs: {} (enabled: {})",
|
||||
metrics.service.pending_jobs, metrics.service.enabled
|
||||
)));
|
||||
render_metrics(frame, data, metrics, area);
|
||||
} else {
|
||||
lines.push(Line::from(format!(
|
||||
"Host {} awaiting backup metrics",
|
||||
data.name
|
||||
)));
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
&format!("Host {} awaiting backup metrics", data.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => lines.push(Line::from("No hosts configured")),
|
||||
None => render_placeholder(frame, area, "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &BackupMetrics, area: Rect) {
|
||||
let color = backup_status_color(&metrics.overall_status);
|
||||
let title = format!("Backups • status: {:?}", metrics.overall_status);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(color))
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(1)])
|
||||
.split(inner);
|
||||
|
||||
let summary_line = Line::from(vec![
|
||||
Span::styled("Snapshots: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(metrics.backup.snapshot_count.to_string()),
|
||||
Span::raw(" • Size: "),
|
||||
Span::raw(format!("{:.1} GiB", metrics.backup.size_gb)),
|
||||
Span::raw(" • Last success: "),
|
||||
Span::raw(format_timestamp(metrics.backup.last_success.as_ref())),
|
||||
]);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(summary_line)
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let header = Row::new(vec![Cell::from("Aspect"), Cell::from("Details")]).style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let mut rows = Vec::new();
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Repo"),
|
||||
Cell::from(format!(
|
||||
"Snapshots: {} • Size: {:.1} GiB",
|
||||
metrics.backup.snapshot_count, metrics.backup.size_gb
|
||||
)),
|
||||
])
|
||||
.style(Style::default().fg(Color::White)),
|
||||
);
|
||||
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Service"),
|
||||
Cell::from(format!(
|
||||
"Enabled: {} • Pending jobs: {}",
|
||||
metrics.service.enabled, metrics.service.pending_jobs
|
||||
)),
|
||||
])
|
||||
.style(backup_severity_style(&metrics.overall_status)),
|
||||
);
|
||||
|
||||
if let Some(last_failure) = metrics.backup.last_failure.as_ref() {
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Last failure"),
|
||||
Cell::from(last_failure.format("%Y-%m-%d %H:%M:%S").to_string()),
|
||||
])
|
||||
.style(Style::default().fg(Color::Red)),
|
||||
);
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block);
|
||||
if let Some(message) = metrics.service.last_message.as_ref() {
|
||||
let message_style = match metrics.overall_status {
|
||||
BackupStatus::Failed => Style::default().fg(Color::Red),
|
||||
BackupStatus::Warning => Style::default().fg(Color::Yellow),
|
||||
_ => Style::default().fg(Color::White),
|
||||
};
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Last message"),
|
||||
Cell::from(message.clone()),
|
||||
])
|
||||
.style(message_style),
|
||||
);
|
||||
}
|
||||
|
||||
let table = Table::new(rows)
|
||||
.header(header)
|
||||
.style(Style::default().fg(Color::White))
|
||||
.widths(&[Constraint::Length(13), Constraint::Min(10)])
|
||||
.column_spacing(2);
|
||||
|
||||
frame.render_widget(table, chunks[1]);
|
||||
}
|
||||
|
||||
fn backup_status_color(status: &BackupStatus) -> Color {
|
||||
match status {
|
||||
BackupStatus::Failed => Color::Red,
|
||||
BackupStatus::Warning => Color::Yellow,
|
||||
BackupStatus::Unknown => Color::LightYellow,
|
||||
BackupStatus::Healthy => Color::Green,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(timestamp: Option<&chrono::DateTime<chrono::Utc>>) -> String {
|
||||
timestamp
|
||||
.map(|ts| ts.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
|
||||
let block = Block::default()
|
||||
.title("Backups")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::LightGreen))
|
||||
.style(Style::default().fg(Color::White));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(message))
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn backup_severity_style(status: &BackupStatus) -> Style {
|
||||
match status {
|
||||
BackupStatus::Failed => Style::default().fg(Color::Red),
|
||||
BackupStatus::Warning => Style::default().fg(Color::Yellow),
|
||||
BackupStatus::Unknown => Style::default().fg(Color::LightCyan),
|
||||
BackupStatus::Healthy => Style::default().fg(Color::White),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::{Block, Cell, Row, Table};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
use super::{alerts, backup, memory, nvme, services};
|
||||
use super::{alerts, backup, memory, storage, services};
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
let host_summaries = app.host_display_data();
|
||||
let primary_host = app.active_host_display();
|
||||
|
||||
let title = if let Some(host) = primary_host.as_ref() {
|
||||
format!("CM Dashboard • {}", host.name)
|
||||
} else {
|
||||
"CM Dashboard".to_string()
|
||||
};
|
||||
|
||||
let root_block = Block::default().title(Span::styled(
|
||||
"CM Dashboard",
|
||||
title,
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -48,7 +54,7 @@ pub fn render(frame: &mut Frame, app: &App) {
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(vertical_chunks[2]);
|
||||
|
||||
nvme::render(frame, primary_host.as_ref(), top[0]);
|
||||
storage::render(frame, primary_host.as_ref(), top[0]);
|
||||
services::render(frame, primary_host.as_ref(), top[1]);
|
||||
memory::render(frame, primary_host.as_ref(), middle[0]);
|
||||
backup::render(frame, primary_host.as_ref(), middle[1]);
|
||||
@@ -61,72 +67,125 @@ pub fn render(frame: &mut Frame, app: &App) {
|
||||
}
|
||||
|
||||
fn render_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Paragraph, Wrap};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::from(app.status_text().to_string()));
|
||||
|
||||
if app.zmq_connected() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Data source: ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("ZMQ", Style::default().fg(Color::Green)),
|
||||
]));
|
||||
let connected = app.zmq_connected();
|
||||
let title_color = if connected { Color::Green } else { Color::Red };
|
||||
let title_suffix = if connected {
|
||||
"connected"
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Data source: ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("ZMQ (disconnected)", Style::default().fg(Color::Red)),
|
||||
]));
|
||||
}
|
||||
"disconnected"
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
format!("Status • ZMQ {title_suffix}"),
|
||||
Style::default()
|
||||
.fg(title_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.border_style(Style::default().fg(title_color))
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
|
||||
let status_style = if connected {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let default_style = Style::default().fg(Color::White);
|
||||
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Status"),
|
||||
Cell::from(app.status_text().to_string()),
|
||||
])
|
||||
.style(status_style),
|
||||
);
|
||||
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Data source"),
|
||||
Cell::from(if connected {
|
||||
"ZMQ – connected"
|
||||
} else {
|
||||
"ZMQ – disconnected"
|
||||
}),
|
||||
])
|
||||
.style(status_style),
|
||||
);
|
||||
|
||||
if let Some((index, host)) = app.active_host_info() {
|
||||
lines.push(Line::from(format!(
|
||||
"Active host: {} ({}/{})",
|
||||
host.name,
|
||||
index + 1,
|
||||
app.hosts().len()
|
||||
)));
|
||||
let mut detail = format!("{} ({}/{})", host.name, index + 1, app.hosts().len());
|
||||
if let Some(state) = app
|
||||
.host_display_data()
|
||||
.into_iter()
|
||||
.find(|entry| entry.name == host.name)
|
||||
{
|
||||
if let Some(last_success) = state.last_success {
|
||||
detail = format!(
|
||||
"{} • last success {}",
|
||||
detail,
|
||||
last_success.format("%H:%M:%S")
|
||||
);
|
||||
}
|
||||
}
|
||||
rows.push(
|
||||
Row::new(vec![Cell::from("Active host"), Cell::from(detail)]).style(default_style),
|
||||
);
|
||||
} else {
|
||||
lines.push(Line::from("Active host: —"));
|
||||
rows.push(Row::new(vec![Cell::from("Active host"), Cell::from("—")]).style(default_style));
|
||||
}
|
||||
|
||||
if let Some(path) = app.active_config_path() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Config: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(path.display().to_string()),
|
||||
]));
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Config"),
|
||||
Cell::from(path.display().to_string()),
|
||||
])
|
||||
.style(default_style),
|
||||
);
|
||||
}
|
||||
|
||||
let retention = app.history().retention();
|
||||
lines.push(Line::from(format!(
|
||||
"History retention ≈ {}s",
|
||||
retention.as_secs()
|
||||
)));
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("History"),
|
||||
Cell::from(format!("{} seconds", retention.as_secs())),
|
||||
])
|
||||
.style(default_style),
|
||||
);
|
||||
|
||||
if let Some(config) = app.config() {
|
||||
if let Some(default_host) = &config.hosts.default_host {
|
||||
lines.push(Line::from(format!("Default host: {}", default_host)));
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Default host"),
|
||||
Cell::from(default_host.clone()),
|
||||
])
|
||||
.style(default_style),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(
|
||||
Block::default()
|
||||
.title(Span::styled(
|
||||
"Status",
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(ratatui::widgets::Borders::ALL),
|
||||
rows.push(
|
||||
Row::new(vec![
|
||||
Cell::from("Monitored hosts"),
|
||||
Cell::from(app.hosts().len().to_string()),
|
||||
])
|
||||
.style(default_style),
|
||||
);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
let table = Table::new(rows)
|
||||
.widths(&[Constraint::Length(18), Constraint::Min(24)])
|
||||
.column_spacing(2)
|
||||
.style(default_style);
|
||||
|
||||
frame.render_widget(table, inner);
|
||||
}
|
||||
|
||||
fn inner_rect(area: Rect) -> Rect {
|
||||
|
||||
@@ -5,52 +5,277 @@ use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::{ServiceMetrics, ServiceSummary};
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title("Memory Optimization")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::LightMagenta));
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
match host {
|
||||
Some(data) => {
|
||||
if let Some(metrics) = data.services.as_ref() {
|
||||
let summary = &metrics.summary;
|
||||
let usage_ratio = if summary.memory_quota_mb > 0.0 {
|
||||
(summary.memory_used_mb / summary.memory_quota_mb) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(data.name.clone()),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(format!(
|
||||
"Memory used: {:.1} / {:.1} MiB ({:.1}%)",
|
||||
summary.memory_used_mb, summary.memory_quota_mb, usage_ratio
|
||||
)));
|
||||
|
||||
if let Some(last_success) = data.last_success.as_ref() {
|
||||
lines.push(Line::from(format!(
|
||||
"Last update: {}",
|
||||
last_success.format("%H:%M:%S")
|
||||
)));
|
||||
}
|
||||
render_metrics(frame, data, metrics, area);
|
||||
} else {
|
||||
lines.push(Line::from(format!(
|
||||
"Host {} awaiting service metrics",
|
||||
data.name
|
||||
)));
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
&format!("Host {} awaiting service metrics", data.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => lines.push(Line::from("No hosts configured")),
|
||||
None => render_placeholder(frame, area, "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &ServiceMetrics, area: Rect) {
|
||||
let summary = &metrics.summary;
|
||||
let system_total = if summary.system_memory_total_mb > 0.0 {
|
||||
summary.system_memory_total_mb
|
||||
} else {
|
||||
summary.memory_quota_mb
|
||||
};
|
||||
|
||||
let system_used = if summary.system_memory_used_mb > 0.0 {
|
||||
summary.system_memory_used_mb
|
||||
} else {
|
||||
summary.memory_used_mb
|
||||
};
|
||||
|
||||
let usage_ratio = if system_total > 0.0 {
|
||||
(system_used / system_total) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let (perf_severity, _reason) = evaluate_performance(summary);
|
||||
let (color, severity_label) = match perf_severity {
|
||||
PerfSeverity::Critical => (Color::Red, "crit"),
|
||||
PerfSeverity::Warning => (Color::Yellow, "warn"),
|
||||
PerfSeverity::Ok => (Color::Green, "ok"),
|
||||
};
|
||||
|
||||
let title = format!("CPU / Memory • {}", severity_label);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(color))
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Check if memory should be highlighted due to alert
|
||||
let memory_color = if usage_ratio >= 95.0 {
|
||||
Color::Red // Critical
|
||||
} else if usage_ratio >= 80.0 {
|
||||
Color::Yellow // Warning
|
||||
} else {
|
||||
Color::White // Normal
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("System memory: {:.1} / {:.1} MiB ({:.1}%)",
|
||||
system_used, system_total, usage_ratio),
|
||||
Style::default().fg(memory_color)
|
||||
)
|
||||
]));
|
||||
|
||||
// Check if CPU load should be highlighted due to alert
|
||||
let cpu_load_color = if summary.cpu_load_5 >= 4.0 {
|
||||
Color::Red // Critical
|
||||
} else if summary.cpu_load_5 >= 2.0 {
|
||||
Color::Yellow // Warning
|
||||
} else {
|
||||
Color::White // Normal
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("CPU load (1/5/15): {:.2} {:.2} {:.2}",
|
||||
summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15),
|
||||
Style::default().fg(cpu_load_color)
|
||||
)
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw("CPU freq: "),
|
||||
Span::raw(format_optional_metric(summary.cpu_freq_mhz, " MHz")),
|
||||
]));
|
||||
|
||||
// Check if CPU temp should be highlighted due to alert
|
||||
let cpu_temp_color = if let Some(temp) = summary.cpu_temp_c {
|
||||
if temp >= 90.0 {
|
||||
Color::Red // Critical
|
||||
} else if temp >= 80.0 {
|
||||
Color::Yellow // Warning
|
||||
} else {
|
||||
Color::White // Normal
|
||||
}
|
||||
} else {
|
||||
Color::White // Normal
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw("CPU temp: "),
|
||||
Span::styled(
|
||||
format_optional_metric(summary.cpu_temp_c, "°C"),
|
||||
Style::default().fg(cpu_temp_color)
|
||||
),
|
||||
]));
|
||||
|
||||
if summary.gpu_load_percent.is_some() || summary.gpu_temp_c.is_some() {
|
||||
// Check if GPU load should be highlighted due to alert
|
||||
let gpu_load_color = if let Some(load) = summary.gpu_load_percent {
|
||||
if load >= 95.0 {
|
||||
Color::Red // Critical
|
||||
} else if load >= 85.0 {
|
||||
Color::Yellow // Warning
|
||||
} else {
|
||||
Color::White // Normal
|
||||
}
|
||||
} else {
|
||||
Color::White // Normal
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("GPU load: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format_optional_percent(summary.gpu_load_percent),
|
||||
Style::default().fg(gpu_load_color)
|
||||
),
|
||||
]));
|
||||
|
||||
// Check if GPU temp should be highlighted due to alert
|
||||
let gpu_temp_color = if let Some(temp) = summary.gpu_temp_c {
|
||||
if temp >= 85.0 {
|
||||
Color::Red // Critical
|
||||
} else if temp >= 75.0 {
|
||||
Color::Yellow // Warning
|
||||
} else {
|
||||
Color::White // Normal
|
||||
}
|
||||
} else {
|
||||
Color::White // Normal
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("GPU temp: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format_optional_metric(summary.gpu_temp_c, "°C"),
|
||||
Style::default().fg(gpu_temp_color)
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum PerfSeverity {
|
||||
Ok,
|
||||
Warning,
|
||||
Critical,
|
||||
}
|
||||
|
||||
fn format_optional_metric(value: Option<f32>, unit: &str) -> String {
|
||||
match value {
|
||||
Some(number) => format!("{:.1}{}", number, unit),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_optional_percent(value: Option<f32>) -> String {
|
||||
match value {
|
||||
Some(number) => format!("{:.0}%", number),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
|
||||
let block = Block::default()
|
||||
.title("CPU / Memory")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::LightMagenta))
|
||||
.style(Style::default().fg(Color::White));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(message))
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn evaluate_performance(summary: &ServiceSummary) -> (PerfSeverity, Option<String>) {
|
||||
let mem_percent = if summary.system_memory_total_mb > 0.0 {
|
||||
(summary.system_memory_used_mb / summary.system_memory_total_mb) * 100.0
|
||||
} else if summary.memory_quota_mb > 0.0 {
|
||||
(summary.memory_used_mb / summary.memory_quota_mb) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let mut severity = PerfSeverity::Ok;
|
||||
let mut reason: Option<String> = None;
|
||||
|
||||
let mut consider = |level: PerfSeverity, message: String| {
|
||||
if level > severity {
|
||||
severity = level;
|
||||
reason = Some(message);
|
||||
}
|
||||
};
|
||||
|
||||
if mem_percent >= 95.0 {
|
||||
consider(PerfSeverity::Critical, format!("RAM {:.0}%", mem_percent));
|
||||
} else if mem_percent >= 80.0 {
|
||||
consider(PerfSeverity::Warning, format!("RAM {:.0}%", mem_percent));
|
||||
}
|
||||
|
||||
let load = summary.cpu_load_5;
|
||||
if load >= 4.0 {
|
||||
consider(PerfSeverity::Critical, format!("CPU load {:.2}", load));
|
||||
} else if load >= 2.0 {
|
||||
consider(PerfSeverity::Warning, format!("CPU load {:.2}", load));
|
||||
}
|
||||
|
||||
if let Some(temp) = summary.cpu_temp_c {
|
||||
if temp >= 90.0 {
|
||||
consider(PerfSeverity::Critical, format!("CPU temp {:.0}°C", temp));
|
||||
} else if temp >= 80.0 {
|
||||
consider(PerfSeverity::Warning, format!("CPU temp {:.0}°C", temp));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(load) = summary.gpu_load_percent {
|
||||
if load >= 95.0 {
|
||||
consider(PerfSeverity::Critical, format!("GPU load {:.0}%", load));
|
||||
} else if load >= 85.0 {
|
||||
consider(PerfSeverity::Warning, format!("GPU load {:.0}%", load));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(temp) = summary.gpu_temp_c {
|
||||
if temp >= 85.0 {
|
||||
consider(PerfSeverity::Critical, format!("GPU temp {:.0}°C", temp));
|
||||
} else if temp >= 75.0 {
|
||||
consider(PerfSeverity::Warning, format!("GPU temp {:.0}°C", temp));
|
||||
}
|
||||
}
|
||||
|
||||
if severity == PerfSeverity::Ok {
|
||||
(PerfSeverity::Ok, None)
|
||||
} else {
|
||||
(severity, reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ pub mod alerts;
|
||||
pub mod backup;
|
||||
pub mod dashboard;
|
||||
pub mod memory;
|
||||
pub mod nvme;
|
||||
pub mod storage;
|
||||
pub mod services;
|
||||
|
||||
pub use dashboard::render;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title("NVMe Health")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::LightCyan));
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
match host {
|
||||
Some(data) => {
|
||||
if let Some(metrics) = data.smart.as_ref() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(data.name.clone()),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(metrics.status.clone()),
|
||||
]));
|
||||
lines.push(Line::from(format!(
|
||||
"Drives healthy/warn/crit: {}/{}/{}",
|
||||
metrics.summary.healthy, metrics.summary.warning, metrics.summary.critical
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
"Capacity used: {:.1} / {:.1} GiB",
|
||||
metrics.summary.capacity_used_gb, metrics.summary.capacity_total_gb
|
||||
)));
|
||||
|
||||
if let Some(issue) = metrics.issues.first() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Issue: ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(issue.clone()),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
lines.push(Line::from(format!(
|
||||
"Host {} has no SMART data yet",
|
||||
data.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
lines.push(Line::from("No hosts configured"));
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -1,54 +1,257 @@
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::{ServiceStatus, ServiceSummary};
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title("Services")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
match host {
|
||||
Some(data) => {
|
||||
if let Some(metrics) = data.services.as_ref() {
|
||||
let summary = &metrics.summary;
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Host: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(data.name.clone()),
|
||||
]));
|
||||
lines.push(Line::from(format!(
|
||||
"Services healthy/degraded/failed: {}/{}/{}",
|
||||
summary.healthy, summary.degraded, summary.failed
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
"CPU top service: {:.1}%",
|
||||
metrics
|
||||
.services
|
||||
.iter()
|
||||
.map(|svc| svc.cpu_percent)
|
||||
.fold(0.0_f32, f32::max)
|
||||
)));
|
||||
lines.push(Line::from(format!(
|
||||
"Total memory: {:.1} / {:.1} MiB",
|
||||
summary.memory_used_mb, summary.memory_quota_mb
|
||||
)));
|
||||
render_metrics(frame, data, metrics, area);
|
||||
} else {
|
||||
lines.push(Line::from(format!(
|
||||
"Host {} has no service metrics yet",
|
||||
data.name
|
||||
)));
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
&format!("Host {} has no service metrics yet", data.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => lines.push(Line::from("No hosts configured")),
|
||||
None => render_placeholder(frame, area, "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(
|
||||
frame: &mut Frame,
|
||||
_host: &HostDisplayData,
|
||||
metrics: &crate::data::metrics::ServiceMetrics,
|
||||
area: Rect,
|
||||
) {
|
||||
let summary = &metrics.summary;
|
||||
let color = summary_color(summary);
|
||||
let disk_summary = format_disk_summary(summary.disk_used_gb, summary.disk_total_gb);
|
||||
let title = format!(
|
||||
"Services • ok:{} warn:{} fail:{} • Disk: {}",
|
||||
summary.healthy, summary.degraded, summary.failed, disk_summary
|
||||
);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(color))
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(1)])
|
||||
.split(inner);
|
||||
|
||||
let mut summary_lines = Vec::new();
|
||||
summary_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Service memory: ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format_memory(summary)),
|
||||
]));
|
||||
|
||||
let disk_text = if summary.disk_total_gb > 0.0 {
|
||||
format!(
|
||||
"{:.1} / {:.1} GiB",
|
||||
summary.disk_used_gb, summary.disk_total_gb
|
||||
)
|
||||
} else {
|
||||
"—".to_string()
|
||||
};
|
||||
|
||||
summary_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Disk usage: ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(disk_text),
|
||||
]));
|
||||
|
||||
summary_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Services tracked: ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(metrics.services.len().to_string()),
|
||||
]));
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(summary_lines)
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if metrics.services.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new("No services reported")
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
chunks[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }).block(block);
|
||||
let mut services = metrics.services.clone();
|
||||
services.sort_by(|a, b| {
|
||||
status_weight(&a.status)
|
||||
.cmp(&status_weight(&b.status))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
let header = Row::new(vec![
|
||||
Cell::from(""),
|
||||
Cell::from("Service"),
|
||||
Cell::from("Memory"),
|
||||
Cell::from("Disk"),
|
||||
])
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows = services.into_iter().map(|svc| {
|
||||
let row_style = status_style(&svc.status);
|
||||
Row::new(vec![
|
||||
Cell::from(status_symbol(&svc.status)),
|
||||
Cell::from(format_service_name(&svc.name)),
|
||||
Cell::from(format_memory_value(svc.memory_used_mb, svc.memory_quota_mb)),
|
||||
Cell::from(format_disk_value(svc.disk_used_gb)),
|
||||
])
|
||||
.style(row_style)
|
||||
});
|
||||
|
||||
let table = Table::new(rows)
|
||||
.header(header)
|
||||
.style(Style::default().fg(Color::White))
|
||||
.widths(&[
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(8),
|
||||
])
|
||||
.column_spacing(2);
|
||||
|
||||
frame.render_widget(table, chunks[1]);
|
||||
}
|
||||
|
||||
fn status_weight(status: &ServiceStatus) -> i32 {
|
||||
match status {
|
||||
ServiceStatus::Stopped => 0,
|
||||
ServiceStatus::Degraded => 1,
|
||||
ServiceStatus::Restarting => 2,
|
||||
ServiceStatus::Running => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_symbol(status: &ServiceStatus) -> &'static str {
|
||||
match status {
|
||||
ServiceStatus::Running => "✔",
|
||||
ServiceStatus::Degraded => "!",
|
||||
ServiceStatus::Restarting => "↻",
|
||||
ServiceStatus::Stopped => "✖",
|
||||
}
|
||||
}
|
||||
|
||||
fn status_style(status: &ServiceStatus) -> Style {
|
||||
match status {
|
||||
ServiceStatus::Running => Style::default().fg(Color::White),
|
||||
ServiceStatus::Degraded => Style::default().fg(Color::Yellow),
|
||||
ServiceStatus::Restarting => Style::default().fg(Color::Yellow),
|
||||
ServiceStatus::Stopped => Style::default().fg(Color::Red),
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_color(summary: &ServiceSummary) -> Color {
|
||||
if summary.failed > 0 {
|
||||
Color::Red
|
||||
} else if summary.degraded > 0 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Green
|
||||
}
|
||||
}
|
||||
|
||||
fn format_memory(summary: &ServiceSummary) -> String {
|
||||
if summary.memory_quota_mb > 0.0 {
|
||||
format!(
|
||||
"{:.1}/{:.1} MiB",
|
||||
summary.memory_used_mb, summary.memory_quota_mb
|
||||
)
|
||||
} else {
|
||||
format!("{:.1} MiB used", summary.memory_used_mb)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_memory_value(used: f32, quota: f32) -> String {
|
||||
if quota > 0.05 {
|
||||
format!("{:.1}/{:.1} MiB", used, quota)
|
||||
} else if used > 0.05 {
|
||||
format!("{:.1} MiB", used)
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_disk_summary(used: f32, total: f32) -> String {
|
||||
if total > 0.05 {
|
||||
format!("{:.1}/{:.1} GiB", used, total)
|
||||
} else if used > 0.05 {
|
||||
format!("{:.1} GiB", used)
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_disk_value(used: f32) -> String {
|
||||
if used >= 1.0 {
|
||||
format!("{:.1} GiB", used)
|
||||
} else if used >= 0.001 { // 1 MB or more
|
||||
format!("{:.0} MiB", used * 1024.0)
|
||||
} else if used > 0.0 {
|
||||
format!("<1 MiB")
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_service_name(name: &str) -> String {
|
||||
let mut truncated = String::with_capacity(10);
|
||||
for ch in name.chars().take(10) {
|
||||
truncated.push(ch);
|
||||
}
|
||||
truncated
|
||||
}
|
||||
|
||||
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
|
||||
let block = Block::default()
|
||||
.title("Services")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
.style(Style::default().fg(Color::White));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(message))
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
196
dashboard/src/ui/storage.rs
Normal file
196
dashboard/src/ui/storage.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::HostDisplayData;
|
||||
use crate::data::metrics::SmartMetrics;
|
||||
|
||||
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
|
||||
match host {
|
||||
Some(data) => {
|
||||
if let Some(metrics) = data.smart.as_ref() {
|
||||
render_metrics(frame, data, metrics, area);
|
||||
} else {
|
||||
render_placeholder(
|
||||
frame,
|
||||
area,
|
||||
&format!("Host {} has no SMART data yet", data.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => render_placeholder(frame, area, "No hosts configured"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMetrics, area: Rect) {
|
||||
let color = smart_status_color(&metrics.status);
|
||||
let title = format!(
|
||||
"Storage • ok:{} warn:{} crit:{}",
|
||||
metrics.summary.healthy, metrics.summary.warning, metrics.summary.critical
|
||||
);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(color))
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
|
||||
let issue_count = metrics.issues.len();
|
||||
let body_constraints = if issue_count > 0 {
|
||||
vec![Constraint::Min(1), Constraint::Length(2)]
|
||||
} else {
|
||||
vec![Constraint::Min(1)]
|
||||
};
|
||||
|
||||
let body_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(body_constraints)
|
||||
.split(inner);
|
||||
|
||||
if metrics.drives.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new("No drives reported")
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
body_chunks[0],
|
||||
);
|
||||
} else {
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Drive"),
|
||||
Cell::from("Temp"),
|
||||
Cell::from("Wear"),
|
||||
Cell::from("Spare"),
|
||||
Cell::from("Hours"),
|
||||
Cell::from("Capacity"),
|
||||
Cell::from("Usage"),
|
||||
])
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows = metrics.drives.iter().map(|drive| {
|
||||
Row::new(vec![
|
||||
Cell::from(format_drive_name(&drive.name)),
|
||||
Cell::from(format_temperature(drive.temperature_c)),
|
||||
Cell::from(format_percent(drive.wear_level)),
|
||||
Cell::from(format_percent(drive.available_spare)),
|
||||
Cell::from(drive.power_on_hours.to_string()),
|
||||
Cell::from(format_capacity(drive.capacity_gb)),
|
||||
Cell::from(format_usage(drive.used_gb, drive.capacity_gb)),
|
||||
])
|
||||
});
|
||||
|
||||
let table = Table::new(rows)
|
||||
.header(header)
|
||||
.style(Style::default().fg(Color::White))
|
||||
.widths(&[
|
||||
Constraint::Length(10), // Drive name
|
||||
Constraint::Length(8), // Temp
|
||||
Constraint::Length(8), // Wear
|
||||
Constraint::Length(8), // Spare
|
||||
Constraint::Length(10), // Hours
|
||||
Constraint::Length(10), // Capacity
|
||||
Constraint::Min(8), // Usage
|
||||
])
|
||||
.column_spacing(2);
|
||||
|
||||
frame.render_widget(table, body_chunks[0]);
|
||||
}
|
||||
|
||||
if issue_count > 0 {
|
||||
let issue_line = Line::from(vec![
|
||||
Span::styled("Issue: ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(
|
||||
metrics.issues[0].clone(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
]);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(issue_line)
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
body_chunks[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn smart_status_color(status: &str) -> Color {
|
||||
match status.to_uppercase().as_str() {
|
||||
"CRITICAL" => Color::Red,
|
||||
"WARNING" => Color::Yellow,
|
||||
_ => Color::Green,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_temperature(value: f32) -> String {
|
||||
if value.abs() < f32::EPSILON {
|
||||
"—".to_string()
|
||||
} else {
|
||||
format!("{:.0}°C", value)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_percent(value: f32) -> String {
|
||||
if value.abs() < f32::EPSILON {
|
||||
"—".to_string()
|
||||
} else {
|
||||
format!("{:.0}%", value)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_drive_name(name: &str) -> String {
|
||||
let mut truncated = String::with_capacity(10);
|
||||
for ch in name.chars().take(10) {
|
||||
truncated.push(ch);
|
||||
}
|
||||
truncated
|
||||
}
|
||||
|
||||
fn format_capacity(value: Option<f32>) -> String {
|
||||
match value {
|
||||
Some(gb) if gb > 0.0 => format!("{:.0}G", gb),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_usage(used: Option<f32>, capacity: Option<f32>) -> String {
|
||||
match (used, capacity) {
|
||||
(Some(used_gb), Some(total_gb)) if used_gb > 0.0 && total_gb > 0.0 => {
|
||||
let percent = (used_gb / total_gb) * 100.0;
|
||||
format!("{:.0}G ({:.0}%)", used_gb, percent)
|
||||
}
|
||||
(Some(used_gb), None) if used_gb > 0.0 => {
|
||||
format!("{:.0}G", used_gb)
|
||||
}
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn render_placeholder(frame: &mut Frame, area: Rect, message: &str) {
|
||||
let block = Block::default()
|
||||
.title("Storage")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::LightCyan))
|
||||
.style(Style::default().fg(Color::White));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(message))
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user