This commit is contained in:
2025-10-12 16:01:56 +02:00
parent c3dbaeead2
commit bd6c14c8c1
9 changed files with 216 additions and 116 deletions

View File

@@ -35,6 +35,7 @@ struct HostRuntimeState {
smart: Option<SmartMetrics>,
services: Option<ServiceMetrics>,
backup: Option<BackupMetrics>,
service_description_cache: HashMap<String, Vec<String>>, // service_name -> last_known_descriptions
}
/// Top-level application state container.
@@ -259,7 +260,25 @@ impl App {
if service_metrics.timestamp != timestamp {
service_metrics.timestamp = timestamp;
}
let snapshot = service_metrics.clone();
let mut snapshot = service_metrics.clone();
// Update description cache and fill in missing descriptions
for service in &mut snapshot.services {
// If service has a new description, cache it
if let Some(ref description) = service.description {
if !description.is_empty() {
state.service_description_cache.insert(service.name.clone(), description.clone());
}
}
// If service has no description but we have a cached one, use it
if service.description.is_none() || service.description.as_ref().map_or(true, |d| d.is_empty()) {
if let Some(cached_description) = state.service_description_cache.get(&service.name) {
service.description = Some(cached_description.clone());
}
}
}
self.history.record_services(service_metrics);
state.services = Some(snapshot);
}

View File

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

View File

@@ -5,7 +5,7 @@ use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::ui::system::{evaluate_performance, PerfSeverity};
use crate::ui::widget::{render_widget_data, WidgetData, WidgetStatus, WidgetValue, StatusLevel};
use crate::ui::widget::{render_widget_data, WidgetData, WidgetStatus, StatusLevel};
pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) {
let (severity, ok_count, warn_count, fail_count) = classify_hosts(hosts);
@@ -41,11 +41,11 @@ pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) {
if hosts.is_empty() {
data.add_row(
None,
"",
vec![],
vec![
WidgetValue::new("No hosts configured"),
WidgetValue::new(""),
WidgetValue::new(""),
"No hosts configured".to_string(),
"".to_string(),
"".to_string(),
],
);
} else {
@@ -63,11 +63,11 @@ pub fn render(frame: &mut Frame, hosts: &[HostDisplayData], area: Rect) {
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![],
vec![
WidgetValue::new(host.name.clone()),
WidgetValue::new(status_text),
WidgetValue::new(update),
host.name.clone(),
status_text,
update,
],
);
}

View File

@@ -4,7 +4,7 @@ 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};
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@@ -41,36 +41,36 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &BackupMe
let repo_status = repo_status_level(metrics);
data.add_row(
Some(WidgetStatus::new(repo_status)),
"",
vec![],
vec![
WidgetValue::new("Repo"),
WidgetValue::new(format!(
"Repo".to_string(),
format!(
"Snapshots: {} • Size: {:.1} GiB",
metrics.backup.snapshot_count, metrics.backup.size_gb
)),
),
],
);
let service_status = service_status_level(metrics);
data.add_row(
Some(WidgetStatus::new(service_status)),
"",
vec![],
vec![
WidgetValue::new("Service"),
WidgetValue::new(format!(
"Service".to_string(),
format!(
"Enabled: {} • Pending jobs: {}",
metrics.service.enabled, metrics.service.pending_jobs
)),
),
],
);
if let Some(last_failure) = metrics.backup.last_failure.as_ref() {
data.add_row(
Some(WidgetStatus::new(StatusLevel::Error)),
"",
vec![],
vec![
WidgetValue::new("Last failure"),
WidgetValue::new(format_timestamp(Some(last_failure))),
"Last failure".to_string(),
format_timestamp(Some(last_failure)),
],
);
}
@@ -85,10 +85,10 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &BackupMe
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![],
vec![
WidgetValue::new("Last message"),
WidgetValue::new(message.clone()),
"Last message".to_string(),
message.clone(),
],
);
}

View File

@@ -4,7 +4,7 @@ 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};
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@@ -55,11 +55,11 @@ fn render_metrics(
if metrics.services.is_empty() {
data.add_row(
None,
"",
vec![],
vec![
WidgetValue::new("No services reported"),
WidgetValue::new(""),
WidgetValue::new(""),
"No services reported".to_string(),
"".to_string(),
"".to_string(),
],
);
render_widget_data(frame, area, data);
@@ -81,31 +81,22 @@ fn render_metrics(
ServiceStatus::Stopped => StatusLevel::Error,
};
// Main service row
// Service row with optional description(s)
let description = if let Some(desc_vec) = &svc.description {
desc_vec.clone()
} else {
vec![]
};
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
description,
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)),
svc.name.clone(),
format_memory_value(svc.memory_used_mb, svc.memory_quota_mb),
format_disk_value(svc.disk_used_gb),
],
);
// Description row (indented) if description exists
if let Some(description) = &svc.description {
if !description.trim().is_empty() {
data.add_row(
None,
"",
vec![
WidgetValue::new(format!(" {}", description)),
WidgetValue::new(""),
WidgetValue::new(""),
],
);
}
}
}
render_widget_data(frame, area, data);

View File

@@ -4,7 +4,7 @@ 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};
use crate::ui::widget::{render_placeholder, render_widget_data, WidgetData, WidgetStatus, StatusLevel};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
@@ -49,15 +49,15 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet
if metrics.drives.is_empty() {
data.add_row(
None,
"",
vec![],
vec![
WidgetValue::new("No drives reported"),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
"No drives reported".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
],
);
} else {
@@ -65,15 +65,15 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet
let status_level = drive_status_level(metrics, &drive.name);
data.add_row(
Some(WidgetStatus::new(status_level)),
"",
vec![],
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)),
drive.name.clone(),
format_temperature(drive.temperature_c),
format_percent(drive.wear_level),
format_percent(drive.available_spare),
drive.power_on_hours.to_string(),
format_capacity(drive.capacity_gb),
format_usage(drive.used_gb, drive.capacity_gb),
],
);
}
@@ -81,15 +81,15 @@ fn render_metrics(frame: &mut Frame, _host: &HostDisplayData, metrics: &SmartMet
if let Some(issue) = metrics.issues.first() {
data.add_row(
Some(WidgetStatus::new(StatusLevel::Warning)),
"",
vec![],
vec![
WidgetValue::new(format!("Issue: {}", issue)),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
WidgetValue::new(""),
format!("Issue: {}", issue),
"".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
],
);
}

View File

@@ -6,7 +6,7 @@ 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,
status_color_from_percentage, WidgetDataSet, WidgetStatus, StatusLevel,
};
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
@@ -77,8 +77,8 @@ fn render_metrics(
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))],
vec![],
vec![format!("{:.1} / {:.1}", system_used, system_total)],
);
// CPU dataset
@@ -86,11 +86,11 @@ fn render_metrics(
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![],
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")),
format!("{:.2}{:.2}{:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15),
format_optional_metric(summary.cpu_temp_c, "°C"),
format_optional_metric(summary.cpu_freq_mhz, " MHz"),
],
);
@@ -99,16 +99,16 @@ fn render_metrics(
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![],
vec![
WidgetValue::new(summary
summary
.gpu_load_percent
.map(|value| format_optional_percent(Some(value)))
.unwrap_or_else(|| "".to_string())),
WidgetValue::new(summary
.unwrap_or_else(|| "".to_string()),
summary
.gpu_temp_c
.map(|value| format_optional_metric(Some(value), "°C"))
.unwrap_or_else(|| "".to_string())),
.unwrap_or_else(|| "".to_string()),
],
);

View File

@@ -152,7 +152,7 @@ fn dataset_needs_wrapping_with_width(dataset: &WidgetDataSet, available_width: u
// 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;
let data_width = widget_value.chars().count() as u16;
max_width = max_width.max(data_width);
}
}
@@ -186,7 +186,7 @@ fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inne
// 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;
let data_width = widget_value.chars().count() as u16;
max_width = max_width.max(data_width);
}
}
@@ -293,8 +293,7 @@ fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inne
// 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 let Some(content) = row.values.get(col_idx) {
if content.is_empty() {
cells.push(Cell::from(""));
} else {
@@ -321,6 +320,33 @@ fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inne
height: 1,
});
current_y += 1;
// Render description rows if any exist
for description in &row.description {
if current_y >= inner.y + inner.height {
break;
}
// Render description as a single cell spanning the entire width
let desc_cell = Cell::from(Line::from(vec![Span::styled(
format!(" {}", description),
Style::default().fg(Color::Blue),
)]));
let desc_row = Row::new(vec![desc_cell]);
let desc_constraints = vec![Constraint::Length(inner.width)];
let desc_table = Table::new(vec![desc_row])
.widths(&desc_constraints)
.style(neutral_text_style());
frame.render_widget(desc_table, Rect {
x: inner.x,
y: current_y,
width: inner.width,
height: 1,
});
current_y += 1;
}
}
col_start = col_end;
@@ -349,12 +375,8 @@ pub struct WidgetDataSet {
#[derive(Clone)]
pub struct WidgetRow {
pub status: Option<WidgetStatus>,
pub values: Vec<WidgetValue>,
}
#[derive(Clone)]
pub struct WidgetValue {
pub data: String,
pub values: Vec<String>,
pub description: Vec<String>,
}
#[derive(Clone, Copy, Debug)]
@@ -383,10 +405,11 @@ impl WidgetData {
}
}
pub fn add_row(&mut self, status: Option<WidgetStatus>, _description: impl Into<String>, values: Vec<WidgetValue>) -> &mut Self {
pub fn add_row(&mut self, status: Option<WidgetStatus>, description: Vec<String>, values: Vec<String>) -> &mut Self {
self.dataset.rows.push(WidgetRow {
status,
values,
description,
});
self
}
@@ -401,22 +424,16 @@ impl WidgetDataSet {
}
}
pub fn add_row(&mut self, status: Option<WidgetStatus>, _description: impl Into<String>, values: Vec<WidgetValue>) -> &mut Self {
pub fn add_row(&mut self, status: Option<WidgetStatus>, description: Vec<String>, values: Vec<String>) -> &mut Self {
self.rows.push(WidgetRow {
status,
values,
description,
});
self
}
}
impl WidgetValue {
pub fn new(data: impl Into<String>) -> Self {
Self {
data: data.into(),
}
}
}
impl WidgetStatus {
pub fn new(status: StatusLevel) -> Self {