Files
aoostar-rs/crates/aster-webui/src/system.rs
T
magges 382e82601a
Build And Push Container / build-and-push (push) Successful in 56s
feat: refine system specs screen layout
2026-06-30 23:35:45 +02:00

532 lines
15 KiB
Rust

use std::sync::OnceLock;
use ab_glyph::{FontArc, PxScale};
use chrono::Local;
use image::{Rgb, RgbImage};
use imageproc::{
drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
rect::Rect,
};
use sysinfo::{Components, Disks, System};
use crate::types::SystemView;
pub(crate) const DISPLAY_WIDTH: u32 = 960;
pub(crate) const DISPLAY_HEIGHT: u32 = 376;
pub(crate) fn normalize_specs_mode(mode: &str) -> &'static str {
match mode {
"meters" => "meters",
_ => "cards",
}
}
pub(crate) fn collect_system_view() -> SystemView {
fn mount_usage(disks: &Disks, mount_point: &str) -> (String, String, String) {
disks
.iter()
.find(|disk| disk.mount_point() == std::path::Path::new(mount_point))
.map(|disk| {
let total = disk.total_space();
let used = total.saturating_sub(disk.available_space());
let usage_percent = if total == 0 {
0.0
} else {
used as f64 / total as f64 * 100.0
};
(
format!("{usage_percent:.0}"),
format_bytes(used),
format_bytes(total),
)
})
.unwrap_or_else(|| ("n/a".into(), "n/a".into(), "n/a".into()))
}
let mut sys = System::new_all();
sys.refresh_all();
let load_avg = System::load_average();
let total_memory = sys.total_memory();
let used_memory = sys.used_memory();
let total_swap = sys.total_swap();
let used_swap = sys.used_swap();
let mut disks = Disks::new();
disks.refresh(false);
let disk_total: u64 = disks.iter().map(|disk| disk.total_space()).sum();
let disk_used: u64 = disks
.iter()
.map(|disk| disk.total_space().saturating_sub(disk.available_space()))
.sum();
let mut components = Components::new();
components.refresh(false);
let (cache_usage_percent, cache_used, cache_total) = mount_usage(&disks, "/mnt/cache");
let (user_usage_percent, user_used, user_total) = mount_usage(&disks, "/mnt/user");
SystemView {
cache_usage_percent,
cache_used,
cache_total,
user_usage_percent,
user_used,
user_total,
cpu_usage_percent: format!("{:.1}", sys.global_cpu_usage()),
load_avg_one: format!("{:.2}", load_avg.one),
load_avg_five: format!("{:.2}", load_avg.five),
load_avg_fifteen: format!("{:.2}", load_avg.fifteen),
mem_usage_percent: format!("{:.1}", percentage(used_memory, total_memory)),
mem_used: format_bytes(used_memory),
mem_total: format_bytes(total_memory),
swap_usage_percent: format!("{:.1}", percentage(used_swap, total_swap)),
swap_used: format_bytes(used_swap),
swap_total: format_bytes(total_swap),
disk_usage_percent: format!("{:.1}", percentage(disk_used, disk_total)),
disk_used: format_bytes(disk_used),
disk_total: format_bytes(disk_total),
cpu_count: sys.cpus().len(),
process_count: sys.processes().len(),
uptime: format_uptime(System::uptime()),
temperature_cpu: component_temperature(&components, "Tctl"),
temperature_gpu: component_temperature(&components, "amdgpu"),
}
}
fn component_temperature(components: &Components, needle: &str) -> Option<String> {
components
.iter()
.find(|component| component.label().contains(needle))
.and_then(|component| component.temperature())
.map(|value| format!("{value:.1} °C"))
}
fn percentage(used: u64, total: u64) -> f64 {
if total == 0 {
0.0
} else {
used as f64 * 100.0 / total as f64
}
}
fn format_uptime(seconds: u64) -> String {
let days = seconds / 86_400;
let hours = (seconds % 86_400) / 3_600;
let minutes = (seconds % 3_600) / 60;
if days == 0 {
format!("{hours:02}:{minutes:02}")
} else if days == 1 {
format!("1 day, {hours:02}:{minutes:02}")
} else {
format!("{days} days, {hours:02}:{minutes:02}")
}
}
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
const THRESHOLD: f64 = 1024.0;
if bytes == 0 {
return "0 B".to_string();
}
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
size /= THRESHOLD;
unit_index += 1;
}
if unit_index > 0 {
format!("{size:.2} {}", UNITS[unit_index])
} else {
format!("{} {}", size, UNITS[unit_index])
}
}
pub(crate) fn render_system_panel(mode: &str) -> RgbImage {
match normalize_specs_mode(mode) {
"meters" => render_system_panel_meters(),
_ => render_system_panel_cards(),
}
}
fn render_system_panel_cards() -> RgbImage {
let system = collect_system_view();
let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, Rgb([10, 14, 18]));
let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
let timestamp = Local::now().format("%d.%m.%Y %H:%M:%S").to_string();
draw_filled_rect_mut(
&mut canvas,
Rect::at(0, 0).of_size(DISPLAY_WIDTH, 84),
Rgb([18, 27, 36]),
);
draw_filled_rect_mut(
&mut canvas,
Rect::at(0, 84).of_size(DISPLAY_WIDTH, DISPLAY_HEIGHT - 84),
Rgb([8, 11, 15]),
);
draw_text_line(
&mut canvas,
Rgb([216, 253, 114]),
28,
20,
36.0,
"AOOSTAR Native Specs",
);
draw_text_line(
&mut canvas,
Rgb([146, 163, 181]),
30,
58,
20.0,
&format!("{host_name} | {timestamp}"),
);
let cards = [
(
24,
106,
"CPU",
format!("{} %", system.cpu_usage_percent),
format!(
"Load {} / {} / {}",
system.load_avg_one, system.load_avg_five, system.load_avg_fifteen
),
Rgb([124, 231, 191]),
),
(
332,
106,
"Memory",
format!("{} %", system.mem_usage_percent),
format!("{} / {}", system.mem_used, system.mem_total),
Rgb([125, 196, 255]),
),
(
640,
106,
"Storage",
format!(
"C {}% U {}%",
system.cache_usage_percent, system.user_usage_percent
),
format!(
"/mnt/cache {} / {} | /mnt/user {} / {}",
system.cache_used, system.cache_total, system.user_used, system.user_total
),
Rgb([255, 199, 115]),
),
(
24,
236,
"Swap",
format!("{} %", system.swap_usage_percent),
format!("{} / {}", system.swap_used, system.swap_total),
Rgb([194, 167, 255]),
),
(
332,
236,
"Temperatures",
format!("CPU {}", system.temperature_cpu.as_deref().unwrap_or("-")),
format!("GPU {}", system.temperature_gpu.as_deref().unwrap_or("-")),
Rgb([255, 127, 115]),
),
(
640,
236,
"System",
system.uptime.clone(),
format!(
"{} cores | {} procs",
system.cpu_count, system.process_count
),
Rgb([216, 253, 114]),
),
];
for (x, y, title, value, meta, accent) in cards {
draw_metric_card(&mut canvas, x, y, title, &value, &meta, accent);
}
canvas
}
fn render_system_panel_meters() -> RgbImage {
let system = collect_system_view();
let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, Rgb([9, 13, 17]));
let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
let timestamp = Local::now().format("%d.%m.%Y %H:%M:%S").to_string();
draw_filled_rect_mut(
&mut canvas,
Rect::at(0, 0).of_size(DISPLAY_WIDTH, 72),
Rgb([15, 23, 30]),
);
draw_text_line(
&mut canvas,
Rgb([216, 253, 114]),
26,
18,
32.0,
"AOOSTAR System Monitor",
);
draw_text_line(
&mut canvas,
Rgb([146, 163, 181]),
28,
48,
18.0,
&format!("{host_name} | {timestamp}"),
);
draw_meter_row(
&mut canvas,
26,
102,
"CPU",
&format!("{}%", system.cpu_usage_percent),
parse_metric_percent(&system.cpu_usage_percent),
Rgb([110, 231, 183]),
&format!(
"Load {} / {} / {} {} cores {} procs",
system.load_avg_one,
system.load_avg_five,
system.load_avg_fifteen,
system.cpu_count,
system.process_count
),
);
draw_meter_row(
&mut canvas,
26,
170,
"RAM",
&format!("{}%", system.mem_usage_percent),
parse_metric_percent(&system.mem_usage_percent),
Rgb([120, 189, 247]),
&format!("{} / {}", system.mem_used, system.mem_total),
);
draw_meter_row(
&mut canvas,
26,
238,
"GPU",
system.temperature_gpu.as_deref().unwrap_or("-"),
normalize_temperature_bar(system.temperature_gpu.as_deref()),
Rgb([241, 132, 121]),
&format!(
"Temp meter CPU {} Cache {}% User {}%",
system.temperature_cpu.as_deref().unwrap_or("-"),
system.cache_usage_percent,
system.user_usage_percent
),
);
draw_filled_rect_mut(
&mut canvas,
Rect::at(26, 308).of_size(908, 42),
Rgb([12, 18, 24]),
);
draw_hollow_rect_mut(
&mut canvas,
Rect::at(26, 308).of_size(908, 42),
Rgb([31, 44, 58]),
);
draw_text_line(
&mut canvas,
Rgb([146, 163, 181]),
42,
321,
18.0,
&format!(
"Swap {}% {} / {} Cache {} / {} User {} / {} Uptime {}",
system.swap_usage_percent,
system.swap_used,
system.swap_total,
system.cache_used,
system.cache_total,
system.user_used,
system.user_total,
system.uptime
),
);
canvas
}
pub(crate) fn overlay_system_specs(canvas: &mut RgbImage) {
let system = collect_system_view();
let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
let timestamp = Local::now().format("%H:%M:%S").to_string();
draw_filled_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([12, 18, 24]));
draw_hollow_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([36, 50, 67]));
draw_text_line(
canvas,
Rgb([216, 253, 114]),
34,
28,
22.0,
&format!(
"{host_name} CPU {}% RAM {}% C {}% U {}%",
system.cpu_usage_percent,
system.mem_usage_percent,
system.cache_usage_percent,
system.user_usage_percent
),
);
draw_text_line(
canvas,
Rgb([146, 163, 181]),
34,
52,
16.0,
&format!(
"Load {} / {} / {} Temp {} / {} Uptime {} {}",
system.load_avg_one,
system.load_avg_five,
system.load_avg_fifteen,
system.temperature_cpu.as_deref().unwrap_or("-"),
system.temperature_gpu.as_deref().unwrap_or("-"),
system.uptime,
timestamp
),
);
draw_filled_rect_mut(
canvas,
Rect::at(18, 306).of_size(924, 52),
Rgb([12, 18, 24]),
);
draw_hollow_rect_mut(
canvas,
Rect::at(18, 306).of_size(924, 52),
Rgb([36, 50, 67]),
);
draw_text_line(
canvas,
Rgb([237, 242, 247]),
34,
320,
18.0,
&format!(
"Cache {} / {} User {} / {} Proc {} CPU {} GPU {}",
system.cache_used,
system.cache_total,
system.user_used,
system.user_total,
system.process_count,
system.temperature_cpu.as_deref().unwrap_or("-"),
system.temperature_gpu.as_deref().unwrap_or("-"),
),
);
}
fn draw_meter_row(
canvas: &mut RgbImage,
x: i32,
y: i32,
label: &str,
value: &str,
percent: f32,
accent: Rgb<u8>,
meta: &str,
) {
draw_text_line(canvas, accent, x, y, 19.0, label);
draw_text_line(canvas, Rgb([237, 242, 247]), x + 112, y, 19.0, value);
draw_meter_bar(canvas, x, y + 28, 840, 20, percent, accent);
draw_text_line(canvas, Rgb([146, 163, 181]), x, y + 56, 17.0, meta);
}
fn draw_meter_bar(
canvas: &mut RgbImage,
x: i32,
y: i32,
width: u32,
height: u32,
percent: f32,
accent: Rgb<u8>,
) {
draw_filled_rect_mut(
canvas,
Rect::at(x, y).of_size(width, height),
Rgb([19, 27, 35]),
);
draw_hollow_rect_mut(
canvas,
Rect::at(x, y).of_size(width, height),
Rgb([36, 50, 67]),
);
let fill_width =
((width.saturating_sub(2)) as f32 * (percent.clamp(0.0, 100.0) / 100.0)).round() as u32;
if fill_width > 0 {
draw_filled_rect_mut(
canvas,
Rect::at(x + 1, y + 1).of_size(fill_width, height.saturating_sub(2)),
accent,
);
}
}
fn parse_metric_percent(value: &str) -> f32 {
value.parse::<f32>().unwrap_or(0.0)
}
fn normalize_temperature_bar(value: Option<&str>) -> f32 {
let Some(raw) = value else {
return 0.0;
};
let numeric = raw
.replace(" °C", "")
.replace('C', "")
.trim()
.parse::<f32>()
.unwrap_or(0.0);
(numeric / 100.0 * 100.0).clamp(0.0, 100.0)
}
fn draw_metric_card(
canvas: &mut RgbImage,
x: i32,
y: i32,
title: &str,
value: &str,
meta: &str,
accent: Rgb<u8>,
) {
draw_filled_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([16, 22, 30]));
draw_hollow_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([36, 50, 67]));
draw_filled_rect_mut(canvas, Rect::at(x + 1, y + 1).of_size(6, 110), accent);
draw_text_line(canvas, accent, x + 20, y + 16, 19.0, title);
draw_text_line(canvas, Rgb([237, 242, 247]), x + 20, y + 44, 28.0, value);
draw_text_line(canvas, Rgb([146, 163, 181]), x + 20, y + 82, 18.0, meta);
}
fn draw_text_line(canvas: &mut RgbImage, color: Rgb<u8>, x: i32, y: i32, size: f32, text: &str) {
draw_text_mut(
canvas,
color,
x,
y,
PxScale { x: size, y: size },
display_font(),
text,
);
}
fn display_font() -> &'static FontArc {
static FONT: OnceLock<FontArc> = OnceLock::new();
FONT.get_or_init(|| {
FontArc::try_from_slice(include_bytes!("../../../fonts/DejaVuSans.ttf"))
.expect("embedded display font must be valid")
})
}