From 0304f1428fd3b930c3660e8d408058ebd16cd5bf Mon Sep 17 00:00:00 2001 From: Max P Date: Tue, 9 Jun 2026 20:55:14 +0200 Subject: [PATCH] Add system utilization metrics to aster web UI --- Cargo.lock | 1 + crates/aster-webui/Cargo.toml | 1 + crates/aster-webui/src/main.rs | 159 ++++++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 1e2bcfd..ae40a9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,7 @@ dependencies = [ "mime_guess", "serde", "serde_json", + "sysinfo", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/aster-webui/Cargo.toml b/crates/aster-webui/Cargo.toml index 479f7cb..6c38c1d 100644 --- a/crates/aster-webui/Cargo.toml +++ b/crates/aster-webui/Cargo.toml @@ -20,6 +20,7 @@ image = "0.25.6" mime_guess = "2.0.5" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" +sysinfo = "0.37.0" tokio = { version = "1.47.1", features = ["fs", "macros", "net", "rt-multi-thread"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } diff --git a/crates/aster-webui/src/main.rs b/crates/aster-webui/src/main.rs index bacc79e..ee093e9 100644 --- a/crates/aster-webui/src/main.rs +++ b/crates/aster-webui/src/main.rs @@ -22,6 +22,7 @@ use clap::Parser; use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use sysinfo::{Components, Disks, System}; use tokio::fs; use tracing::{error, info, warn}; @@ -84,6 +85,7 @@ struct StateResponse { active_images: Vec, images: Vec, display: DisplayStatus, + system: SystemView, } #[derive(Clone, Serialize)] @@ -114,6 +116,28 @@ struct DisplayStatus { updated_at: Option, } +#[derive(Clone, Debug, Serialize)] +struct SystemView { + cpu_usage_percent: String, + load_avg_one: String, + load_avg_five: String, + load_avg_fifteen: String, + mem_usage_percent: String, + mem_used: String, + mem_total: String, + swap_usage_percent: String, + swap_used: String, + swap_total: String, + disk_usage_percent: String, + disk_used: String, + disk_total: String, + cpu_count: usize, + process_count: usize, + uptime: String, + temperature_cpu: Option, + temperature_gpu: Option, +} + #[derive(Deserialize)] struct ActivateRequest { images: Vec, @@ -475,9 +499,106 @@ async fn build_state_response(state: &AppState) -> Result { active_images: current_active_images(&monitor), images: list_images(state).await?, display: read_display_status(&state.display_status), + system: collect_system_view(), }) } +fn collect_system_view() -> SystemView { + 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); + + SystemView { + 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 { + 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]) + } +} + async fn list_images(state: &AppState) -> Result> { let mut out = Vec::new(); let mut dir = fs::read_dir(&state.image_dir).await?; @@ -889,6 +1010,7 @@ const INDEX_HTML: &str = r##" .hero h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; } .hero p, .muted { color: var(--muted); } .status strong { display: block; margin-top: 8px; font-size: 1.3rem; } + .status small { display: block; margin-top: 6px; color: var(--muted); } .controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; } label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; } input, button { @@ -904,6 +1026,7 @@ const INDEX_HTML: &str = r##" .ghost { background: rgba(9,14,20,.7); color: var(--text); } .danger { background: rgba(255,127,115,.14); color: #ffc7c1; } .content { grid-template-columns: 1.45fr 1fr; } + .metrics-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-bottom: 18px; } .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } .tile { border: 1px solid var(--line); @@ -929,7 +1052,7 @@ const INDEX_HTML: &str = r##" background: rgba(10,14,20,.94); display: none; } @media (max-width: 1024px) { - .hero, .content, .controls, .status-grid { grid-template-columns: 1fr; } + .hero, .content, .controls, .status-grid, .metrics-grid { grid-template-columns: 1fr; } } @@ -965,6 +1088,14 @@ const INDEX_HTML: &str = r##"
Config Path-
+
+
CPU--
+
Memory--
+
Storage--
+
Swap--
+
Load Avg--
+
Temperatures--
+

Available Images

@@ -984,6 +1115,7 @@ const INDEX_HTML: &str = r##" activeImages: [], setup: { switch_time: "10", custom_panel: false }, display: {}, + system: {}, }; const el = { gallery: document.getElementById("gallery"), @@ -996,6 +1128,18 @@ const INDEX_HTML: &str = r##" displayTargetValue: document.getElementById("displayTargetValue"), displayErrorValue: document.getElementById("displayErrorValue"), configPathValue: document.getElementById("configPathValue"), + cpuUsageValue: document.getElementById("cpuUsageValue"), + cpuMetaValue: document.getElementById("cpuMetaValue"), + memUsageValue: document.getElementById("memUsageValue"), + memMetaValue: document.getElementById("memMetaValue"), + diskUsageValue: document.getElementById("diskUsageValue"), + diskMetaValue: document.getElementById("diskMetaValue"), + swapUsageValue: document.getElementById("swapUsageValue"), + swapMetaValue: document.getElementById("swapMetaValue"), + loadValue: document.getElementById("loadValue"), + uptimeValue: document.getElementById("uptimeValue"), + tempCpuValue: document.getElementById("tempCpuValue"), + tempGpuValue: document.getElementById("tempGpuValue"), upload: document.getElementById("upload"), apply: document.getElementById("apply"), disable: document.getElementById("disable"), @@ -1048,6 +1192,7 @@ const INDEX_HTML: &str = r##" state.activeImages = data.active_images; state.setup = data.setup; state.display = data.display || {}; + state.system = data.system || {}; el.switchTime.value = data.setup.switch_time || "10"; el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled"; el.imageCountValue.textContent = String(data.active_images.length); @@ -1058,6 +1203,18 @@ const INDEX_HTML: &str = r##" el.displayTargetValue.textContent = state.display.device || "-"; el.displayErrorValue.textContent = state.display.last_error || ""; el.configPathValue.textContent = data.monitor_path; + el.cpuUsageValue.textContent = `${state.system.cpu_usage_percent || "-"} %`; + el.cpuMetaValue.textContent = `Load ${state.system.load_avg_one || "-"} / ${state.system.load_avg_five || "-"} / ${state.system.load_avg_fifteen || "-"}`; + el.memUsageValue.textContent = `${state.system.mem_usage_percent || "-"} %`; + el.memMetaValue.textContent = `${state.system.mem_used || "-"} / ${state.system.mem_total || "-"}`; + el.diskUsageValue.textContent = `${state.system.disk_usage_percent || "-"} %`; + el.diskMetaValue.textContent = `${state.system.disk_used || "-"} / ${state.system.disk_total || "-"}`; + el.swapUsageValue.textContent = `${state.system.swap_usage_percent || "-"} %`; + el.swapMetaValue.textContent = `${state.system.swap_used || "-"} / ${state.system.swap_total || "-"}`; + el.loadValue.textContent = `${state.system.cpu_count || "-"} CPU / ${state.system.process_count || "-"} proc`; + el.uptimeValue.textContent = `Uptime ${state.system.uptime || "-"}`; + el.tempCpuValue.textContent = `CPU ${state.system.temperature_cpu || "-"}`; + el.tempGpuValue.textContent = `GPU ${state.system.temperature_gpu || "-"}`; render(); }