This commit is contained in:
2025-10-12 14:53:27 +02:00
parent 2581435b10
commit 2239badc8a
16 changed files with 1116 additions and 1414 deletions

View File

@@ -32,6 +32,17 @@ enabled = true
id = "alerts"
enabled = true
[data_source]
kind = "zmq"
[data_source.zmq]
endpoints = [
"tcp://192.168.30.100:6130", # srv01
"tcp://192.168.30.105:6130", # cmbox
"tcp://192.168.30.107:6130", # simonbox
"tcp://192.168.30.101:6130" # steambox
]
[filesystem]
# cache_dir = "/var/lib/cm-dashboard/cache"
# history_dir = "/var/lib/cm-dashboard/history"

View File

@@ -41,7 +41,9 @@ struct HostRuntimeState {
#[derive(Debug)]
pub struct App {
options: AppOptions,
#[allow(dead_code)]
config: Option<AppConfig>,
#[allow(dead_code)]
active_config_path: Option<PathBuf>,
hosts: Vec<HostTarget>,
history: MetricsHistory,
@@ -136,10 +138,12 @@ impl App {
self.should_quit
}
#[allow(dead_code)]
pub fn status_text(&self) -> &str {
&self.status
}
#[allow(dead_code)]
pub fn zmq_connected(&self) -> bool {
self.zmq_connected
}
@@ -148,14 +152,17 @@ impl App {
self.options.tick_rate()
}
#[allow(dead_code)]
pub fn config(&self) -> Option<&AppConfig> {
self.config.as_ref()
}
#[allow(dead_code)]
pub fn active_config_path(&self) -> Option<&PathBuf> {
self.active_config_path.as_ref()
}
#[allow(dead_code)]
pub fn hosts(&self) -> &[HostTarget] {
&self.hosts
}
@@ -171,6 +178,7 @@ impl App {
}
}
#[allow(dead_code)]
pub fn history(&self) -> &MetricsHistory {
&self.history
}

View File

@@ -80,6 +80,8 @@ pub struct ServiceInfo {
pub sandbox_limit: Option<f32>,
#[serde(default)]
pub disk_used_gb: f32,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -1,95 +1,79 @@
use chrono::{DateTime, Utc};
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::ui::memory::{evaluate_performance, PerfSeverity};
use crate::ui::system::{evaluate_performance, PerfSeverity};
use crate::ui::widget::{render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) {
let (severity, ok_count, warn_count, fail_count) = classify_hosts(hosts);
let color = match severity {
let mut color = match severity {
AlertSeverity::Critical => Color::Red,
AlertSeverity::Warning => Color::Yellow,
AlertSeverity::Healthy => Color::Green,
AlertSeverity::Unknown => Color::LightCyan,
AlertSeverity::Unknown => Color::Gray,
};
if hosts.is_empty() {
color = Color::Gray;
}
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() {
frame.render_widget(
Paragraph::new("No hosts configured")
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::White)),
inner,
);
return;
}
let header = Row::new(vec![
Cell::from("Host"),
Cell::from("Status"),
Cell::from("Timestamp"),
])
.style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
let widget_status = match severity {
AlertSeverity::Critical => StatusLevel::Error,
AlertSeverity::Warning => StatusLevel::Warning,
AlertSeverity::Healthy => StatusLevel::Ok,
AlertSeverity::Unknown => StatusLevel::Unknown,
};
let mut data = WidgetData::new(
title,
Some(WidgetStatus::new(widget_status)),
vec!["Host".to_string(), "Status".to_string(), "Timestamp".to_string()]
);
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());
if hosts.is_empty() {
data.add_row(
None,
"",
vec![
WidgetValue::new("No hosts configured"),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
} else {
for host in hosts {
let (status_text, severity, _emphasize) = host_status(host);
let status_level = match severity {
AlertSeverity::Critical => StatusLevel::Error,
AlertSeverity::Warning => StatusLevel::Warning,
AlertSeverity::Healthy => StatusLevel::Ok,
AlertSeverity::Unknown => StatusLevel::Unknown,
};
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())
};
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new(host.name.clone()),
WidgetValue::new(status_text),
WidgetValue::new(update),
],
);
}
}
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);
render_widget_data(frame, area, data);
}
#[derive(Copy, Clone, Eq, PartialEq)]
@@ -262,12 +246,12 @@ fn host_status(host: &HostDisplayData) -> (String, AlertSeverity, bool) {
("ok".to_string(), AlertSeverity::Healthy, false)
}
fn severity_style(severity: AlertSeverity) -> Style {
fn severity_color(severity: AlertSeverity) -> Color {
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),
AlertSeverity::Critical => Color::Red,
AlertSeverity::Warning => Color::Yellow,
AlertSeverity::Healthy => Color::Green,
AlertSeverity::Unknown => Color::Gray,
}
}
@@ -297,3 +281,12 @@ fn latest_timestamp(host: &HostDisplayData) -> Option<DateTime<Utc>> {
latest
}
fn severity_symbol(severity: AlertSeverity) -> &'static str {
match severity {
AlertSeverity::Critical => "",
AlertSeverity::Warning => "!",
AlertSeverity::Healthy => "",
AlertSeverity::Unknown => "?",
}
}

View File

@@ -1,11 +1,10 @@
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::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::{BackupMetrics, BackupStatus};
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@@ -16,113 +15,85 @@ pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
render_placeholder(
frame,
area,
"Backups",
&format!("Host {} awaiting backup metrics", data.name),
);
}
}
None => render_placeholder(frame, area, "No hosts configured"),
None => render_placeholder(frame, area, "Backups", "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 widget_status = match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
BackupStatus::Unknown => StatusLevel::Unknown,
BackupStatus::Healthy => StatusLevel::Ok,
};
let mut data = WidgetData::new(
"Backups",
Some(WidgetStatus::new(widget_status)),
vec!["Aspect".to_string(), "Details".to_string()]
);
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!(
let repo_status = repo_status_level(metrics);
data.add_row(
Some(WidgetStatus::new(repo_status)),
"",
vec![
WidgetValue::new("Repo"),
WidgetValue::new(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!(
let service_status = service_status_level(metrics);
data.add_row(
Some(WidgetStatus::new(service_status)),
"",
vec![
WidgetValue::new("Service"),
WidgetValue::new(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)),
data.add_row(
Some(WidgetStatus::new(StatusLevel::Error)),
"",
vec![
WidgetValue::new("Last failure"),
WidgetValue::new(format_timestamp(Some(last_failure))),
],
);
}
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),
let status_level = match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
BackupStatus::Unknown => StatusLevel::Unknown,
BackupStatus::Healthy => StatusLevel::Ok,
};
rows.push(
Row::new(vec![
Cell::from("Last message"),
Cell::from(message.clone()),
])
.style(message_style),
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new("Last message"),
WidgetValue::new(message.clone()),
],
);
}
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]);
render_widget_data(frame, area, data);
}
fn backup_status_color(status: &BackupStatus) -> Color {
@@ -140,27 +111,31 @@ fn format_timestamp(timestamp: Option<&chrono::DateTime<chrono::Utc>>) -> 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),
fn repo_status_level(metrics: &BackupMetrics) -> StatusLevel {
match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
_ => {
if metrics.backup.snapshot_count > 0 {
StatusLevel::Ok
} else {
StatusLevel::Warning
}
}
}
}
fn service_status_level(metrics: &BackupMetrics) -> StatusLevel {
match metrics.overall_status {
BackupStatus::Failed => StatusLevel::Error,
BackupStatus::Warning => StatusLevel::Warning,
BackupStatus::Unknown => StatusLevel::Unknown,
BackupStatus::Healthy => {
if metrics.service.enabled {
StatusLevel::Ok
} else {
StatusLevel::Warning
}
}
}
}

View File

@@ -1,12 +1,12 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Cell, Row, Table};
use ratatui::widgets::Block;
use ratatui::Frame;
use crate::app::App;
use super::{alerts, backup, memory, storage, services};
use super::{alerts, backup, services, storage, system};
pub fn render(frame: &mut Frame, app: &App) {
let host_summaries = app.host_display_data();
@@ -17,7 +17,7 @@ pub fn render(frame: &mut Frame, app: &App) {
} else {
"CM Dashboard".to_string()
};
let root_block = Block::default().title(Span::styled(
title,
Style::default()
@@ -30,164 +30,39 @@ pub fn render(frame: &mut Frame, app: &App) {
let outer = inner_rect(size);
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(35),
Constraint::Percentage(35),
Constraint::Percentage(30),
])
let main_columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(outer);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical_chunks[0]);
let left_side = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(75), Constraint::Percentage(25)])
.split(main_columns[0]);
let middle = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical_chunks[1]);
let left_widgets = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(left_side[0]);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical_chunks[2]);
let services_area = main_columns[1];
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]);
alerts::render(frame, &host_summaries, bottom[0]);
render_status(frame, app, bottom[1]);
system::render(frame, primary_host.as_ref(), left_widgets[0]);
storage::render(frame, primary_host.as_ref(), left_widgets[1]);
backup::render(frame, primary_host.as_ref(), left_widgets[2]);
services::render(frame, primary_host.as_ref(), services_area);
alerts::render(frame, &host_summaries, left_side[1]);
if app.help_visible() {
render_help(frame, size);
}
}
fn render_status(frame: &mut Frame, app: &App, area: Rect) {
let connected = app.zmq_connected();
let title_color = if connected { Color::Green } else { Color::Red };
let title_suffix = if connected {
"connected"
} else {
"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() {
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 {
rows.push(Row::new(vec![Cell::from("Active host"), Cell::from("")]).style(default_style));
}
if let Some(path) = app.active_config_path() {
rows.push(
Row::new(vec![
Cell::from("Config"),
Cell::from(path.display().to_string()),
])
.style(default_style),
);
}
let retention = app.history().retention();
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 {
rows.push(
Row::new(vec![
Cell::from("Default host"),
Cell::from(default_host.clone()),
])
.style(default_style),
);
}
}
rows.push(
Row::new(vec![
Cell::from("Monitored hosts"),
Cell::from(app.hosts().len().to_string()),
])
.style(default_style),
);
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 {
Rect {
x: area.x + 1,

View File

@@ -1,281 +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;
use crate::data::metrics::{ServiceMetrics, ServiceSummary};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
Some(data) => {
if let Some(metrics) = data.services.as_ref() {
render_metrics(frame, data, metrics, area);
} else {
render_placeholder(
frame,
area,
&format!("Host {} awaiting service metrics", data.name),
);
}
}
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)
),
]));
}
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)
}
}

View File

@@ -1,8 +1,9 @@
pub mod alerts;
pub mod backup;
pub mod dashboard;
pub mod memory;
pub mod storage;
pub mod services;
pub mod storage;
pub mod system;
pub mod widget;
pub use dashboard::render;

View File

@@ -1,11 +1,10 @@
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::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::{ServiceStatus, ServiceSummary};
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@@ -16,11 +15,12 @@ pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
render_placeholder(
frame,
area,
"Services",
&format!("Host {} has no service metrics yet", data.name),
);
}
}
None => render_placeholder(frame, area, "No hosts configured"),
None => render_placeholder(frame, area, "Services", "No hosts configured"),
}
}
@@ -32,77 +32,38 @@ fn render_metrics(
) {
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
"Services • ok:{} warn:{} fail:{}",
summary.healthy, summary.degraded, summary.failed
);
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
)
let widget_status = if summary.failed > 0 {
StatusLevel::Error
} else if summary.degraded > 0 {
StatusLevel::Warning
} else {
"".to_string()
StatusLevel::Ok
};
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],
let mut data = WidgetData::new(
title,
Some(WidgetStatus::new(widget_status)),
vec!["Service".to_string(), "Memory".to_string(), "Disk".to_string(), "Description".to_string()]
);
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],
data.add_row(
None,
"",
vec![
WidgetValue::new("No services reported"),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
render_widget_data(frame, area, data);
return;
}
@@ -113,41 +74,27 @@ fn render_metrics(
.then_with(|| a.name.cmp(&b.name))
});
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),
);
for svc in services {
let status_level = match svc.status {
ServiceStatus::Running => StatusLevel::Ok,
ServiceStatus::Degraded => StatusLevel::Warning,
ServiceStatus::Restarting => StatusLevel::Warning,
ServiceStatus::Stopped => StatusLevel::Error,
};
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new(svc.name.clone()),
WidgetValue::new(format_memory_value(svc.memory_used_mb, svc.memory_quota_mb)),
WidgetValue::new(format_disk_value(svc.disk_used_gb)),
WidgetValue::new(svc.description.as_deref().unwrap_or("")),
],
);
}
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]);
render_widget_data(frame, area, data);
}
fn status_weight(status: &ServiceStatus) -> i32 {
@@ -159,21 +106,12 @@ fn status_weight(status: &ServiceStatus) -> i32 {
}
}
fn status_symbol(status: &ServiceStatus) -> &'static str {
fn status_symbol(status: &ServiceStatus) -> (&'static str, Color) {
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),
ServiceStatus::Running => ("", Color::Green),
ServiceStatus::Degraded => ("!", Color::Yellow),
ServiceStatus::Restarting => ("", Color::Yellow),
ServiceStatus::Stopped => ("", Color::Red),
}
}
@@ -187,17 +125,6 @@ fn summary_color(summary: &ServiceSummary) -> Color {
}
}
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)
@@ -208,20 +135,11 @@ fn format_memory_value(used: f32, quota: f32) -> 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
} else if used >= 0.001 {
// 1 MB or more
format!("{:.0} MiB", used * 1024.0)
} else if used > 0.0 {
format!("<1 MiB")
@@ -230,28 +148,3 @@ fn format_disk_value(used: f32) -> 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,
);
}

View File

@@ -1,11 +1,10 @@
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::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::SmartMetrics;
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@@ -16,11 +15,12 @@ pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
render_placeholder(
frame,
area,
"Storage",
&format!("Host {} has no SMART data yet", data.name),
);
}
}
None => render_placeholder(frame, area, "No hosts configured"),
None => render_placeholder(frame, area, "Storage", "No hosts configured"),
}
}
@@ -31,99 +31,71 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet
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)]
let widget_status = if metrics.summary.critical > 0 {
StatusLevel::Error
} else if metrics.summary.warning > 0 {
StatusLevel::Warning
} else {
vec![Constraint::Min(1)]
StatusLevel::Ok
};
let mut data = WidgetData::new(
title,
Some(WidgetStatus::new(widget_status)),
vec!["Drive".to_string(), "Temp".to_string(), "Wear".to_string(), "Spare".to_string(), "Hours".to_string(), "Capacity".to_string(), "Usage".to_string()]
);
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],
data.add_row(
None,
"",
vec![
WidgetValue::new("No drives reported"),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
} 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),
);
for drive in &metrics.drives {
let status_level = drive_status_level(metrics, &drive.name);
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![
WidgetValue::new(drive.name.clone()),
WidgetValue::new(format_temperature(drive.temperature_c)),
WidgetValue::new(format_percent(drive.wear_level)),
WidgetValue::new(format_percent(drive.available_spare)),
WidgetValue::new(drive.power_on_hours.to_string()),
WidgetValue::new(format_capacity(drive.capacity_gb)),
WidgetValue::new(format_usage(drive.used_gb, drive.capacity_gb)),
],
);
}
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 let Some(issue) = metrics.issues.first() {
data.add_row(
Some(WidgetStatus::new(StatusLevel::Warning)),
"",
vec![
WidgetValue::new(format!("Issue: {}", issue)),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
}
}
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],
);
}
render_widget_data(frame, area, data);
}
fn smart_status_color(status: &str) -> Color {
@@ -150,13 +122,6 @@ fn format_percent(value: f32) -> String {
}
}
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 {
@@ -178,19 +143,22 @@ fn format_usage(used: Option<f32>, capacity: Option<f32>) -> 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,
);
fn drive_status_level(metrics: &SmartMetrics, drive_name: &str) -> StatusLevel {
if metrics.summary.critical > 0
|| metrics.issues.iter().any(|issue| {
issue.to_lowercase().contains(&drive_name.to_lowercase())
&& issue.to_lowercase().contains("fail")
})
{
StatusLevel::Error
} else if metrics.summary.warning > 0
|| metrics
.issues
.iter()
.any(|issue| issue.to_lowercase().contains(&drive_name.to_lowercase()))
{
StatusLevel::Warning
} else {
StatusLevel::Ok
}
}

217
dashboard/src/ui/system.rs Normal file
View File

@@ -0,0 +1,217 @@
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::{ServiceMetrics, ServiceSummary};
use crate::ui::widget::{
combined_color, render_placeholder, render_combined_widget_data, status_color_for_cpu_load, status_color_from_metric,
status_color_from_percentage, WidgetDataSet, WidgetStatus, WidgetValue, StatusLevel,
};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
Some(data) => {
if let Some(metrics) = data.services.as_ref() {
render_metrics(frame, data, metrics, area);
} else {
render_placeholder(
frame,
area,
"System",
&format!("Host {} awaiting service metrics", data.name),
);
}
}
None => render_placeholder(frame, area, "System", "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 border_color = match perf_severity {
PerfSeverity::Critical => Color::Red,
PerfSeverity::Warning => Color::Yellow,
PerfSeverity::Ok => Color::Green,
};
let memory_color = status_color_from_percentage(usage_ratio, 80.0, 95.0);
let cpu_load_color = status_color_for_cpu_load(summary.cpu_load_5);
let cpu_temp_color = status_color_from_metric(summary.cpu_temp_c, 80.0, 90.0);
let gpu_load_color = summary
.gpu_load_percent
.map(|value| status_color_from_percentage(value, 85.0, 95.0))
.unwrap_or(Color::Green);
let gpu_temp_color = summary
.gpu_temp_c
.map(|value| status_color_from_metric(Some(value), 75.0, 85.0))
.unwrap_or(Color::Green);
let cpu_icon_color = combined_color(&[cpu_load_color, cpu_temp_color]);
let gpu_icon_color = combined_color(&[gpu_load_color, gpu_temp_color]);
// Memory dataset
let memory_status = status_level_from_color(memory_color);
let mut memory_dataset = WidgetDataSet::new(vec!["Memory usage".to_string()], Some(WidgetStatus::new(memory_status)));
memory_dataset.add_row(
Some(WidgetStatus::new(memory_status)),
"",
vec![WidgetValue::new(format!("{:.1} / {:.1}", system_used, system_total))],
);
// CPU dataset
let cpu_status = status_level_from_color(cpu_icon_color);
let mut cpu_dataset = WidgetDataSet::new(vec!["CPU load".to_string(), "CPU temp".to_string(), "CPU freq".to_string()], Some(WidgetStatus::new(cpu_status)));
cpu_dataset.add_row(
Some(WidgetStatus::new(cpu_status)),
"",
vec![
WidgetValue::new(format!("{:.2}{:.2}{:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15)),
WidgetValue::new(format_optional_metric(summary.cpu_temp_c, "°C")),
WidgetValue::new(format_optional_metric(summary.cpu_freq_mhz, " MHz")),
],
);
// GPU dataset
let gpu_status = status_level_from_color(gpu_icon_color);
let mut gpu_dataset = WidgetDataSet::new(vec!["GPU load".to_string(), "GPU temp".to_string()], Some(WidgetStatus::new(gpu_status)));
gpu_dataset.add_row(
Some(WidgetStatus::new(gpu_status)),
"",
vec![
WidgetValue::new(summary
.gpu_load_percent
.map(|value| format_optional_percent(Some(value)))
.unwrap_or_else(|| "".to_string())),
WidgetValue::new(summary
.gpu_temp_c
.map(|value| format_optional_metric(Some(value), "°C"))
.unwrap_or_else(|| "".to_string())),
],
);
// Determine overall widget status based on worst case
let overall_status_level = match perf_severity {
PerfSeverity::Critical => StatusLevel::Error,
PerfSeverity::Warning => StatusLevel::Warning,
PerfSeverity::Ok => StatusLevel::Ok,
};
let overall_status = Some(WidgetStatus::new(overall_status_level));
// Render all three datasets in a single combined widget
render_combined_widget_data(frame, area, "CPU / Memory".to_string(), overall_status, vec![memory_dataset, cpu_dataset, gpu_dataset]);
}
#[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 status_level_from_color(color: Color) -> StatusLevel {
match color {
Color::Red => StatusLevel::Error,
Color::Yellow => StatusLevel::Warning,
_ => StatusLevel::Ok,
}
}
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)
}
}

447
dashboard/src/ui/widget.rs Normal file
View File

@@ -0,0 +1,447 @@
use ratatui::layout::{Constraint, 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;
pub fn heading_row_style() -> Style {
neutral_text_style().add_modifier(Modifier::BOLD)
}
fn neutral_text_style() -> Style {
Style::default()
}
fn neutral_title_span(title: &str) -> Span<'static> {
Span::styled(
title.to_string(),
neutral_text_style().add_modifier(Modifier::BOLD),
)
}
fn neutral_border_style(color: Color) -> Style {
Style::default().fg(color)
}
pub fn status_color_from_percentage(value: f32, warn: f32, crit: f32) -> Color {
if value >= crit {
Color::Red
} else if value >= warn {
Color::Yellow
} else {
Color::Green
}
}
pub fn status_color_from_metric(value: Option<f32>, warn: f32, crit: f32) -> Color {
match value {
Some(v) if v >= crit => Color::Red,
Some(v) if v >= warn => Color::Yellow,
_ => Color::Green,
}
}
pub fn status_color_for_cpu_load(load: f32) -> Color {
if load >= 4.0 {
Color::Red
} else if load >= 2.0 {
Color::Yellow
} else {
Color::Green
}
}
pub fn combined_color(colors: &[Color]) -> Color {
if colors.iter().any(|&c| c == Color::Red) {
Color::Red
} else if colors.iter().any(|&c| c == Color::Yellow) {
Color::Yellow
} else {
Color::Green
}
}
pub fn render_placeholder(frame: &mut Frame, area: Rect, title: &str, message: &str) {
let block = Block::default()
.title(neutral_title_span(title))
.borders(Borders::ALL)
.border_style(neutral_border_style(Color::Gray));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(message))
.wrap(Wrap { trim: true })
.style(neutral_text_style()),
inner,
);
}
pub fn render_widget_data(frame: &mut Frame, area: Rect, data: WidgetData) {
render_combined_widget_data(frame, area, data.title, data.status, vec![data.dataset]);
}
pub fn render_combined_widget_data(frame: &mut Frame, area: Rect, title: String, status: Option<WidgetStatus>, datasets: Vec<WidgetDataSet>) {
if datasets.is_empty() {
return;
}
// Create border and title - determine color from widget status
let border_color = status.as_ref()
.map(|s| s.status.to_color())
.unwrap_or(Color::Reset);
let block = Block::default()
.title(neutral_title_span(&title))
.borders(Borders::ALL)
.border_style(neutral_border_style(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
// Split multi-row datasets into single-row datasets when wrapping is needed
let split_datasets = split_multirow_datasets_with_area(datasets, inner);
let mut current_y = inner.y;
for dataset in split_datasets.iter() {
if current_y >= inner.y + inner.height {
break; // No more space
}
current_y += render_dataset_with_wrapping(frame, dataset, inner, current_y);
}
}
fn split_multirow_datasets_with_area(datasets: Vec<WidgetDataSet>, inner: Rect) -> Vec<WidgetDataSet> {
let mut result = Vec::new();
for dataset in datasets {
if dataset.rows.len() <= 1 {
// Single row or empty - keep as is
result.push(dataset);
} else {
// Multiple rows - check if wrapping is needed using actual available width
if dataset_needs_wrapping_with_width(&dataset, inner.width) {
// Split into separate datasets for individual wrapping
for row in dataset.rows {
let single_row_dataset = WidgetDataSet {
colnames: dataset.colnames.clone(),
status: dataset.status.clone(),
rows: vec![row],
};
result.push(single_row_dataset);
}
} else {
// No wrapping needed - keep as single dataset
result.push(dataset);
}
}
}
result
}
fn dataset_needs_wrapping_with_width(dataset: &WidgetDataSet, available_width: u16) -> bool {
// Calculate column widths
let mut column_widths = Vec::new();
for (col_index, colname) in dataset.colnames.iter().enumerate() {
let mut max_width = colname.chars().count() as u16;
// Check data rows for this column width
for row in &dataset.rows {
if let Some(widget_value) = row.values.get(col_index) {
let data_width = widget_value.data.chars().count() as u16;
max_width = max_width.max(data_width);
}
}
let column_width = (max_width + 1).min(25).max(6);
column_widths.push(column_width);
}
// Calculate total width needed
let status_col_width = 1u16;
let col_spacing = 1u16;
let mut total_width = status_col_width + col_spacing;
for &col_width in &column_widths {
total_width += col_width + col_spacing;
}
total_width > available_width
}
fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inner: Rect, start_y: u16) -> u16 {
if dataset.colnames.is_empty() || dataset.rows.is_empty() {
return 0;
}
// Calculate column widths
let mut column_widths = Vec::new();
for (col_index, colname) in dataset.colnames.iter().enumerate() {
let mut max_width = colname.chars().count() as u16;
// Check data rows for this column width
for row in &dataset.rows {
if let Some(widget_value) = row.values.get(col_index) {
let data_width = widget_value.data.chars().count() as u16;
max_width = max_width.max(data_width);
}
}
let column_width = (max_width + 1).min(25).max(6);
column_widths.push(column_width);
}
let status_col_width = 1u16;
let col_spacing = 1u16;
let available_width = inner.width;
// Determine how many columns fit
let mut total_width = status_col_width + col_spacing;
let mut cols_that_fit = 0;
for &col_width in &column_widths {
let new_total = total_width + col_width + col_spacing;
if new_total <= available_width {
total_width = new_total;
cols_that_fit += 1;
} else {
break;
}
}
if cols_that_fit == 0 {
cols_that_fit = 1; // Always show at least one column
}
let mut current_y = start_y;
let mut col_start = 0;
let mut is_continuation = false;
// Render wrapped sections
while col_start < dataset.colnames.len() {
let col_end = (col_start + cols_that_fit).min(dataset.colnames.len());
let section_colnames = &dataset.colnames[col_start..col_end];
let section_widths = &column_widths[col_start..col_end];
// Render header for this section
let mut header_cells = vec![];
// Status cell
if is_continuation {
header_cells.push(Cell::from(""));
} else {
header_cells.push(Cell::from(""));
}
// Column headers
for colname in section_colnames {
header_cells.push(Cell::from(Line::from(vec![Span::styled(
colname.clone(),
heading_row_style(),
)])));
}
let header_row = Row::new(header_cells).style(heading_row_style());
// Build constraint widths for this section
let mut constraints = vec![Constraint::Length(status_col_width)];
for &width in section_widths {
constraints.push(Constraint::Length(width));
}
let header_table = Table::new(vec![header_row])
.widths(&constraints)
.column_spacing(col_spacing)
.style(neutral_text_style());
frame.render_widget(header_table, Rect {
x: inner.x,
y: current_y,
width: inner.width,
height: 1,
});
current_y += 1;
// Render data rows for this section
for row in &dataset.rows {
if current_y >= inner.y + inner.height {
break;
}
let mut cells = vec![];
// Status cell (only show on first section)
if col_start == 0 {
match &row.status {
Some(s) => {
let color = s.status.to_color();
let icon = s.status.to_icon();
cells.push(Cell::from(Line::from(vec![Span::styled(
icon.to_string(),
Style::default().fg(color),
)])));
},
None => cells.push(Cell::from("")),
}
} else {
cells.push(Cell::from(""));
}
// Data cells for this section
for col_idx in col_start..col_end {
if let Some(widget_value) = row.values.get(col_idx) {
let content = &widget_value.data;
if content.is_empty() {
cells.push(Cell::from(""));
} else {
cells.push(Cell::from(Line::from(vec![Span::styled(
content.to_string(),
neutral_text_style(),
)])));
}
} else {
cells.push(Cell::from(""));
}
}
let data_row = Row::new(cells);
let data_table = Table::new(vec![data_row])
.widths(&constraints)
.column_spacing(col_spacing)
.style(neutral_text_style());
frame.render_widget(data_table, Rect {
x: inner.x,
y: current_y,
width: inner.width,
height: 1,
});
current_y += 1;
}
col_start = col_end;
is_continuation = true;
}
current_y - start_y
}
#[derive(Clone)]
pub struct WidgetData {
pub title: String,
pub status: Option<WidgetStatus>,
pub dataset: WidgetDataSet,
}
#[derive(Clone)]
pub struct WidgetDataSet {
pub colnames: Vec<String>,
pub status: Option<WidgetStatus>,
pub rows: Vec<WidgetRow>,
}
#[derive(Clone)]
pub struct WidgetRow {
pub status: Option<WidgetStatus>,
pub values: Vec<WidgetValue>,
}
#[derive(Clone)]
pub struct WidgetValue {
pub data: String,
}
#[derive(Clone, Copy, Debug)]
pub enum StatusLevel {
Ok,
Warning,
Error,
Unknown,
}
#[derive(Clone)]
pub struct WidgetStatus {
pub status: StatusLevel,
}
impl WidgetData {
pub fn new(title: impl Into<String>, status: Option<WidgetStatus>, colnames: Vec<String>) -> Self {
Self {
title: title.into(),
status: status.clone(),
dataset: WidgetDataSet {
colnames,
status,
rows: Vec::new(),
},
}
}
pub fn add_row(&mut self, status: Option<WidgetStatus>, _description: impl Into<String>, values: Vec<WidgetValue>) -> &mut Self {
self.dataset.rows.push(WidgetRow {
status,
values,
});
self
}
}
impl WidgetDataSet {
pub fn new(colnames: Vec<String>, status: Option<WidgetStatus>) -> Self {
Self {
colnames,
status,
rows: Vec::new(),
}
}
pub fn add_row(&mut self, status: Option<WidgetStatus>, _description: impl Into<String>, values: Vec<WidgetValue>) -> &mut Self {
self.rows.push(WidgetRow {
status,
values,
});
self
}
}
impl WidgetValue {
pub fn new(data: impl Into<String>) -> Self {
Self {
data: data.into(),
}
}
}
impl WidgetStatus {
pub fn new(status: StatusLevel) -> Self {
Self {
status,
}
}
}
impl StatusLevel {
pub fn to_color(self) -> Color {
match self {
StatusLevel::Ok => Color::Green,
StatusLevel::Warning => Color::Yellow,
StatusLevel::Error => Color::Red,
StatusLevel::Unknown => Color::Reset, // Terminal default
}
}
pub fn to_icon(self) -> &'static str {
match self {
StatusLevel::Ok => "",
StatusLevel::Warning => "!",
StatusLevel::Error => "",
StatusLevel::Unknown => "?",
}
}
}