Files
aoostar-rs/crates/aster-webui/src/system.rs
T
magges a302d3270e
Build And Push Container / build-and-push (push) Successful in 57s
refactor: split aster-webui main into modules
2026-06-30 19:21:44 +02:00

349 lines
10 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 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() -> 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!("{} CPU / {} proc", 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
}
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_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")
})
}