Christoffer Martinsson 1ee398e648 Improve widget formatting and add logged-in users support
Services widget:
- Fix disk quota formatting with proper rounding instead of truncation
- Remove decimals from RAM quotas and use GB instead of G
- Change quota display to use GB consistently

Backups widget:
- Change GiB to GB for consistency
- Remove spaces between numbers and units
- Update disk usage format to match other widgets: used (totalGB)
- Remove percentage display for cleaner format

System widget:
- Add support for logged-in users in description lines
- Format C-states with "C-State:" prefix on first line, indent subsequent lines
- Add logged_in_users field to SystemSummary data structure

Documentation:
- Add example hash error output to NixOS update instructions
2025-10-14 18:59:31 +02:00

184 lines
5.9 KiB
Rust

use ratatui::layout::Rect;
use ratatui::Frame;
use crate::app::HostDisplayData;
use crate::data::metrics::ServiceStatus;
use crate::ui::widget::{render_placeholder, render_widget_data, status_level_from_agent_status, connection_status_message, WidgetData, WidgetStatus, StatusLevel};
use crate::app::ConnectionStatus;
pub fn render(frame: &mut Frame, host: Option<&HostDisplayData>, area: Rect) {
match host {
Some(data) => {
match (&data.connection_status, data.services.as_ref()) {
(ConnectionStatus::Connected, Some(metrics)) => {
render_metrics(frame, data, metrics, area);
}
(ConnectionStatus::Connected, None) => {
render_placeholder(
frame,
area,
"Services",
&format!("Host {} has no service metrics yet", data.name),
);
}
(status, _) => {
render_placeholder(
frame,
area,
"Services",
&format!("Host {}: {}", data.name, connection_status_message(status, &data.last_error)),
);
}
}
}
None => render_placeholder(frame, area, "Services", "No hosts configured"),
}
}
fn render_metrics(
frame: &mut Frame,
_host: &HostDisplayData,
metrics: &crate::data::metrics::ServiceMetrics,
area: Rect,
) {
let summary = &metrics.summary;
let title = "Services".to_string();
// Use agent-calculated services status
let widget_status = status_level_from_agent_status(summary.services_status.as_ref());
let mut data = WidgetData::new(
title,
Some(WidgetStatus::new(widget_status)),
vec!["Service".to_string(), "RAM".to_string(), "CPU".to_string(), "Disk".to_string()]
);
if metrics.services.is_empty() {
data.add_row(
None,
vec![],
vec![
"No services reported".to_string(),
"".to_string(),
"".to_string(),
"".to_string(),
],
);
render_widget_data(frame, area, data);
return;
}
let mut services = metrics.services.clone();
services.sort_by(|a, b| {
// First, determine the primary service name for grouping
let primary_a = a.sub_service.as_ref().unwrap_or(&a.name);
let primary_b = b.sub_service.as_ref().unwrap_or(&b.name);
// Sort by primary service name first
match primary_a.cmp(primary_b) {
std::cmp::Ordering::Equal => {
// Same primary service, put parent service first, then sub-services alphabetically
match (a.sub_service.as_ref(), b.sub_service.as_ref()) {
(None, Some(_)) => std::cmp::Ordering::Less, // Parent comes before sub-services
(Some(_), None) => std::cmp::Ordering::Greater, // Sub-services come after parent
_ => a.name.cmp(&b.name), // Both same type, sort by name
}
}
other => other, // Different primary services, sort alphabetically
}
});
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,
};
// Service row with optional description(s)
let description = if let Some(desc_vec) = &svc.description {
desc_vec.clone()
} else {
vec![]
};
if svc.sub_service.is_some() {
// Sub-services only show name and status, no memory/CPU/disk/sandbox data
data.add_row_with_sub_service(
Some(WidgetStatus::new(status_level)),
description,
vec![
svc.name.clone(),
"".to_string(),
"".to_string(),
"".to_string(),
],
svc.sub_service.clone(),
);
} else {
// Regular services show all columns
data.add_row(
Some(WidgetStatus::new(status_level)),
description,
vec![
svc.name.clone(),
format_memory_value(svc.memory_used_mb, svc.memory_quota_mb),
format_cpu_value(svc.cpu_percent),
format_disk_value(svc.disk_used_gb, svc.disk_quota_gb),
],
);
}
}
render_widget_data(frame, area, data);
}
fn format_bytes(mb: f32) -> String {
if mb < 0.1 {
"0".to_string()
} else if mb < 1.0 {
format!("{:.0}kB", mb * 1000.0)
} else if mb < 1000.0 {
format!("{:.0}MB", mb)
} else {
format!("{:.1}GB", mb / 1000.0)
}
}
fn format_memory_value(used: f32, quota: f32) -> String {
let used_value = format_bytes(used);
if quota > 0.05 {
let quota_gb = quota / 1000.0;
// Format quota without decimals and use GB
format!("{} ({}GB)", used_value, quota_gb as u32)
} else {
used_value
}
}
fn format_cpu_value(cpu_percent: f32) -> String {
if cpu_percent >= 0.1 {
format!("{:.1}", cpu_percent)
} else {
"0.0".to_string()
}
}
fn format_disk_value(used: f32, quota: f32) -> String {
let used_value = format_bytes(used * 1000.0); // Convert GB to MB for format_bytes
if quota > 0.05 {
// Format quota without decimals and use GB (round to nearest GB)
format!("{} ({}GB)", used_value, quota.round() as u32)
} else {
used_value
}
}