From 382e82601a3b30abf3136a7147062223a66c5071 Mon Sep 17 00:00:00 2001 From: magges Date: Tue, 30 Jun 2026 23:35:45 +0200 Subject: [PATCH] feat: refine system specs screen layout --- crates/aster-webui/src/display.rs | 4 +- crates/aster-webui/src/monitor.rs | 26 +++++ crates/aster-webui/src/routes.rs | 21 +++- crates/aster-webui/src/system.rs | 187 +++++++++++++++++++++++++++++- crates/aster-webui/src/types.rs | 3 + crates/aster-webui/src/ui.rs | 85 +++++++++++--- 6 files changed, 307 insertions(+), 19 deletions(-) diff --git a/crates/aster-webui/src/display.rs b/crates/aster-webui/src/display.rs index 88c05a2..75ee94b 100644 --- a/crates/aster-webui/src/display.rs +++ b/crates/aster-webui/src/display.rs @@ -177,7 +177,7 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { if send_due { if active_frame.image_name == SYSTEM_FRAME_NAME { let panel_name = SYSTEM_FRAME_NAME.to_string(); - let rgb_img = render_system_panel(); + let rgb_img = render_system_panel(&snapshot.specs_mode); prepare_screen_for_frame(screen, &active_frame); screen .send_image(&rgb_img) @@ -660,6 +660,7 @@ mod tests { custom_panel: true, switch_time: 7, specs_enabled: false, + specs_mode: "cards".into(), memes_enabled: false, gifs_enabled: true, active_images: vec![manifest_name.into()], @@ -702,6 +703,7 @@ mod tests { custom_panel: true, switch_time: 7, specs_enabled: false, + specs_mode: "cards".into(), memes_enabled: false, gifs_enabled: false, active_images: vec![manifest_name.into()], diff --git a/crates/aster-webui/src/monitor.rs b/crates/aster-webui/src/monitor.rs index 11a2632..8f26b8e 100644 --- a/crates/aster-webui/src/monitor.rs +++ b/crates/aster-webui/src/monitor.rs @@ -32,6 +32,7 @@ pub(crate) async fn ensure_layout(state: &AppState) -> Result<()> { "language": 1, "switchTime": "10", "nativeSpecs": false, + "nativeSpecsMode": "cards", "nativeMemes": true, "nativeGifs": true, "operationMode": 0, @@ -93,6 +94,7 @@ pub(crate) async fn build_state_response(state: &AppState) -> Result Result bool { .unwrap_or_else(|| !current_active_images(monitor).is_empty()) } +pub(crate) fn current_specs_mode(monitor: &Value) -> String { + monitor + .get("setup") + .and_then(Value::as_object) + .and_then(|setup| setup.get("nativeSpecsMode")) + .and_then(Value::as_str) + .map(normalize_specs_mode) + .unwrap_or_else(|| "cards".into()) +} + pub(crate) fn current_gifs_enabled(monitor: &Value) -> bool { monitor .get("setup") @@ -176,6 +189,7 @@ pub(crate) fn rotation_snapshot(monitor: &Value) -> RotationSnapshot { custom_panel: current_custom_panel_enabled(monitor), switch_time: current_switch_time(monitor), specs_enabled: current_specs_enabled(monitor), + specs_mode: current_specs_mode(monitor), memes_enabled: current_memes_enabled(monitor), gifs_enabled: current_gifs_enabled(monitor), active_images: current_active_images(monitor), @@ -186,6 +200,7 @@ pub(crate) fn set_custom_panels( monitor: &mut Value, switch_time: u32, specs_enabled: bool, + specs_mode: &str, memes_enabled: bool, gifs_enabled: bool, images: &[String], @@ -201,6 +216,10 @@ pub(crate) fn set_custom_panels( setup.insert("customPanel".into(), Value::Bool(enabled)); setup.insert("switchTime".into(), Value::String(switch_time.to_string())); setup.insert("nativeSpecs".into(), Value::Bool(specs_enabled)); + setup.insert( + "nativeSpecsMode".into(), + Value::String(normalize_specs_mode(specs_mode)), + ); setup.insert("nativeMemes".into(), Value::Bool(memes_enabled)); setup.insert("nativeGifs".into(), Value::Bool(gifs_enabled)); } @@ -218,6 +237,13 @@ pub(crate) fn set_custom_panels( ); } +fn normalize_specs_mode(value: &str) -> String { + match value { + "meters" => "meters".into(), + _ => "cards".into(), + } +} + pub(crate) fn load_monitor_json_sync(path: &PathBuf) -> Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; diff --git a/crates/aster-webui/src/routes.rs b/crates/aster-webui/src/routes.rs index a487782..a3ceb8b 100644 --- a/crates/aster-webui/src/routes.rs +++ b/crates/aster-webui/src/routes.rs @@ -18,8 +18,8 @@ use crate::{ }, monitor::{ build_state_response, current_active_images, current_gifs_enabled, current_memes_enabled, - current_specs_enabled, current_switch_time, load_monitor_json, save_monitor_json, - set_custom_panels, + current_specs_enabled, current_specs_mode, current_switch_time, load_monitor_json, + save_monitor_json, set_custom_panels, }, types::{ActivateRequest, AppState, DeleteRequest, ErrorResponse}, ui::index_response, @@ -103,6 +103,17 @@ async fn api_activate( ) -> Response { let switch_time = payload.switch_time.unwrap_or(10).clamp(1, 600); let specs_enabled = payload.specs_enabled.unwrap_or(false); + let specs_mode = payload + .specs_mode + .as_deref() + .map(|value| { + if value == "meters" { + "meters".to_string() + } else { + "cards".to_string() + } + }) + .unwrap_or_else(|| "cards".to_string()); let available = match list_images(&state).await { Ok(items) => items, Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), @@ -140,6 +151,7 @@ async fn api_activate( &mut monitor, switch_time, specs_enabled, + &specs_mode, memes_enabled, gifs_enabled, &valid, @@ -153,6 +165,7 @@ async fn api_activate( "ok": true, "switchTime": switch_time, "specsEnabled": specs_enabled, + "specsMode": specs_mode, "memesEnabled": memes_enabled, "gifsEnabled": gifs_enabled, "activeImages": valid, @@ -167,11 +180,13 @@ async fn api_disable(State(state): State>) -> Response { }; let switch_time = current_switch_time(&monitor); let active_images = current_active_images(&monitor); + let specs_mode = current_specs_mode(&monitor); set_custom_panels( &mut monitor, switch_time, false, + &specs_mode, false, false, &active_images, @@ -209,6 +224,7 @@ async fn api_delete( let mut active_images = current_active_images(&monitor); let switch_time = current_switch_time(&monitor); let specs_enabled = current_specs_enabled(&monitor); + let specs_mode = current_specs_mode(&monitor); let memes_enabled = current_memes_enabled(&monitor); let gifs_enabled = current_gifs_enabled(&monitor); let before = active_images.len(); @@ -218,6 +234,7 @@ async fn api_delete( &mut monitor, switch_time, specs_enabled, + &specs_mode, memes_enabled, gifs_enabled, &active_images, diff --git a/crates/aster-webui/src/system.rs b/crates/aster-webui/src/system.rs index c552e18..dc0b0f1 100644 --- a/crates/aster-webui/src/system.rs +++ b/crates/aster-webui/src/system.rs @@ -14,6 +14,13 @@ 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 @@ -140,7 +147,14 @@ fn format_bytes(bytes: u64) -> String { } } -pub(crate) fn render_system_panel() -> RgbImage { +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()); @@ -229,7 +243,10 @@ pub(crate) fn render_system_panel() -> RgbImage { 236, "System", system.uptime.clone(), - format!("{} CPU / {} proc", system.cpu_count, system.process_count), + format!( + "{} cores | {} procs", + system.cpu_count, system.process_count + ), Rgb([216, 253, 114]), ), ]; @@ -241,6 +258,109 @@ pub(crate) fn render_system_panel() -> RgbImage { 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()); @@ -309,6 +429,69 @@ pub(crate) fn overlay_system_specs(canvas: &mut RgbImage) { ); } +fn draw_meter_row( + canvas: &mut RgbImage, + x: i32, + y: i32, + label: &str, + value: &str, + percent: f32, + accent: Rgb, + 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, +) { + 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::().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::() + .unwrap_or(0.0); + (numeric / 100.0 * 100.0).clamp(0.0, 100.0) +} + fn draw_metric_card( canvas: &mut RgbImage, x: i32, diff --git a/crates/aster-webui/src/types.rs b/crates/aster-webui/src/types.rs index 79324da..ba48e1b 100644 --- a/crates/aster-webui/src/types.rs +++ b/crates/aster-webui/src/types.rs @@ -47,6 +47,7 @@ pub(crate) struct RotationSnapshot { pub(crate) custom_panel: bool, pub(crate) switch_time: u32, pub(crate) specs_enabled: bool, + pub(crate) specs_mode: String, pub(crate) memes_enabled: bool, pub(crate) gifs_enabled: bool, pub(crate) active_images: Vec, @@ -107,6 +108,7 @@ pub(crate) struct SetupView { pub(crate) custom_panel: bool, pub(crate) switch_time: String, pub(crate) specs_enabled: bool, + pub(crate) specs_mode: String, pub(crate) memes_enabled: bool, pub(crate) gifs_enabled: bool, } @@ -173,6 +175,7 @@ pub(crate) struct ActivateRequest { pub(crate) images: Vec, pub(crate) switch_time: Option, pub(crate) specs_enabled: Option, + pub(crate) specs_mode: Option, pub(crate) memes_enabled: Option, pub(crate) gifs_enabled: Option, } diff --git a/crates/aster-webui/src/ui.rs b/crates/aster-webui/src/ui.rs index c2115e8..465cdac 100644 --- a/crates/aster-webui/src/ui.rs +++ b/crates/aster-webui/src/ui.rs @@ -30,6 +30,7 @@ const INDEX_HTML: &str = r##" --surface: #10161d; --surface-2: #141b24; --surface-3: #0c1117; + --surface-4: #172230; --line: #223042; --line-soft: #182333; --text: #eef3f8; @@ -37,6 +38,7 @@ const INDEX_HTML: &str = r##" --faint: #667789; --accent: #6ee7b7; --accent-2: #b9f66a; + --accent-3: #7ec8ff; --warn: #f6c65b; --danger: #f18479; --shadow: 0 18px 60px rgba(0, 0, 0, .28); @@ -130,6 +132,8 @@ const INDEX_HTML: &str = r##" justify-content: space-between; gap: 12px; margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(34, 48, 66, .72); } h2 { margin: 0; @@ -192,6 +196,15 @@ const INDEX_HTML: &str = r##" flex-wrap: wrap; margin-top: 12px; } + .select-wrap select { + min-height: 42px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--surface-3); + color: var(--text); + padding: 10px 12px; + font: inherit; + } .toggle-pill { display: inline-flex; align-items: center; @@ -221,6 +234,16 @@ const INDEX_HTML: &str = r##" display: grid; align-content: space-between; background: rgba(16, 22, 30, .86); + position: relative; + overflow: hidden; + } + .status::after { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: linear-gradient(180deg, var(--accent), var(--accent-3)); + opacity: .9; } .status span, .metric-label { color: var(--muted); @@ -244,6 +267,7 @@ const INDEX_HTML: &str = r##" .metric-card { min-height: 104px; border-left: 3px solid var(--accent); + background: linear-gradient(180deg, rgba(17, 24, 32, .98), rgba(12, 17, 24, .98)); } .metric-card:nth-child(2) { border-left-color: #78bdf7; } .metric-card:nth-child(3) { border-left-color: var(--warn); } @@ -376,13 +400,31 @@ const INDEX_HTML: &str = r##" } .nyan-wrap { position: fixed; - right: 14px; - bottom: 10px; - width: 132px; + right: 18px; + bottom: 18px; + width: 184px; pointer-events: none; z-index: 30; - opacity: .42; - filter: drop-shadow(0 8px 14px rgba(0, 0, 0, .26)); + opacity: .92; + padding: 10px 12px 8px; + border: 1px solid rgba(126, 200, 255, .22); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(15, 22, 30, .88), rgba(10, 15, 22, .72)); + box-shadow: + 0 16px 36px rgba(0, 0, 0, .28), + inset 0 0 0 1px rgba(110, 231, 183, .08); + filter: drop-shadow(0 10px 18px rgba(0, 0, 0, .22)); + } + .nyan-wrap::before { + content: "UI mascot"; + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: .68rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; } .nyan-wrap img { display: block; @@ -406,7 +448,8 @@ const INDEX_HTML: &str = r##" .gallery { grid-template-columns: 1fr; } .order-item { grid-template-columns: 28px minmax(0, 1fr); } .order-item .mini { grid-column: 2; } - .nyan-wrap { width: 96px; opacity: .32; } + .nyan-wrap { width: 128px; right: 10px; bottom: 10px; opacity: .78; padding: 8px; } + .nyan-wrap::before { margin-bottom: 4px; font-size: .62rem; } } @@ -452,12 +495,21 @@ const INDEX_HTML: &str = r##" -
- - - -
+
+ + +
+
+ +
+