Files
aoostar-rs/crates/aster-webui/src/main.rs
T
max e4c161ce45
Rust / Clippy, Rustfmt, Tests (push) Has been cancelled
Rust / Linux-x64 build (push) Has been cancelled
Rust / GitHub release (push) Has been cancelled
Use provided Nyancat GIF in WebUI
2026-06-10 00:18:26 +02:00

1764 lines
60 KiB
Rust

use std::{
net::SocketAddr,
path::PathBuf,
sync::{Arc, OnceLock, RwLock},
thread,
time::{Duration, Instant},
};
use ab_glyph::{FontArc, PxScale};
use anyhow::{Context, Result};
use asterctl::img;
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
use axum::{
Json, Router,
body::Body,
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use chrono::{Local, Utc};
use clap::Parser;
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, imageops::FilterType};
use imageproc::{
drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
rect::Rect,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sysinfo::{Components, Disks, System};
use tokio::fs;
use tracing::{error, info, warn};
const DISPLAY_WIDTH: u32 = 960;
const DISPLAY_HEIGHT: u32 = 376;
const SYSTEM_FRAME_NAME: &str = "System Specs";
const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Cli {
#[arg(long, default_value = "0.0.0.0:8080")]
bind: String,
#[arg(long, default_value = "/config")]
config_dir: PathBuf,
#[arg(long)]
device: Option<String>,
#[arg(long)]
usb: Option<String>,
#[arg(long)]
simulate: bool,
#[arg(long)]
write_only: bool,
#[arg(long)]
disable_display: bool,
}
#[derive(Clone)]
struct AppState {
monitor_path: PathBuf,
image_dir: PathBuf,
display_status: Arc<RwLock<DisplayStatus>>,
}
#[derive(Clone)]
struct DisplayConfig {
device: Option<String>,
usb: Option<String>,
simulate: bool,
write_only: bool,
native_enabled: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct RotationSnapshot {
custom_panel: bool,
switch_time: u32,
specs_enabled: bool,
memes_enabled: bool,
active_images: Vec<String>,
}
impl RotationSnapshot {
fn rotation_active(&self) -> bool {
self.custom_panel && !self.frames().is_empty()
}
fn frames(&self) -> Vec<RotationFrame> {
let mut frames = Vec::new();
if self.memes_enabled {
frames.extend(self.active_images.iter().cloned().map(RotationFrame::Image));
}
if frames.is_empty() && self.specs_enabled {
frames.push(RotationFrame::SystemSpecs);
}
frames
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum RotationFrame {
SystemSpecs,
Image(String),
}
#[derive(Clone, Serialize)]
struct StateResponse {
monitor_path: String,
image_dir: String,
setup: SetupView,
active_images: Vec<String>,
images: Vec<ImageView>,
display: DisplayStatus,
system: SystemView,
}
#[derive(Clone, Serialize)]
struct SetupView {
custom_panel: bool,
switch_time: String,
specs_enabled: bool,
memes_enabled: bool,
}
#[derive(Clone, Serialize)]
struct ImageView {
name: String,
size: u64,
url: String,
}
#[derive(Clone, Debug, Default, Serialize)]
struct DisplayStatus {
native_enabled: bool,
connected: bool,
mode: String,
device: String,
custom_panel: bool,
specs_enabled: bool,
memes_enabled: bool,
rotation_active: bool,
switch_time: String,
active_images: Vec<String>,
current_image: Option<String>,
last_error: Option<String>,
updated_at: Option<String>,
}
#[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,
cache_usage_percent: String,
cache_used: String,
cache_total: String,
user_usage_percent: String,
user_used: String,
user_total: String,
cpu_count: usize,
process_count: usize,
uptime: String,
temperature_cpu: Option<String>,
temperature_gpu: Option<String>,
}
#[derive(Deserialize)]
struct ActivateRequest {
images: Vec<String>,
switch_time: Option<u32>,
specs_enabled: Option<bool>,
memes_enabled: Option<bool>,
}
#[derive(Deserialize)]
struct DeleteRequest {
name: String,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,aster_webui=debug".into()),
)
.init();
let cli = Cli::parse();
let addr: SocketAddr = cli
.bind
.parse()
.with_context(|| format!("Invalid bind address: {}", cli.bind))?;
let display_config = DisplayConfig {
device: cli.device,
usb: cli.usb,
simulate: cli.simulate,
write_only: cli.write_only,
native_enabled: !cli.disable_display,
};
let state = Arc::new(AppState {
monitor_path: cli.config_dir.join("Monitor3.json"),
image_dir: cli.config_dir.join("img"),
display_status: Arc::new(RwLock::new(initial_display_status(&display_config))),
});
ensure_layout(&state).await?;
spawn_display_worker(state.clone(), display_config);
let app = Router::new()
.route("/", get(index))
.route("/healthz", get(healthz))
.route("/api/state", get(api_state))
.route("/api/upload", post(api_upload))
.route("/api/panels/activate", post(api_activate))
.route("/api/panels/disable", post(api_disable))
.route("/api/images/delete", post(api_delete))
.route("/api/assets/nyan-cat.gif", get(api_nyan_cat))
.route("/api/images/{name}", get(api_image))
.layer(DefaultBodyLimit::max(64 * 1024 * 1024))
.with_state(state);
info!("Starting aster-webui on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
let mut status = DisplayStatus {
native_enabled: config.native_enabled,
connected: false,
mode: display_mode(config),
device: display_target(config),
custom_panel: false,
specs_enabled: false,
memes_enabled: false,
rotation_active: false,
switch_time: "10".into(),
active_images: Vec::new(),
current_image: None,
last_error: None,
updated_at: Some(stamp_now()),
};
if !config.native_enabled {
status.last_error = Some("Native display loop disabled via --disable-display".into());
}
status
}
async fn ensure_layout(state: &AppState) -> Result<()> {
fs::create_dir_all(&state.image_dir).await?;
if fs::try_exists(&state.monitor_path).await? {
return Ok(());
}
let default = json!({
"credentials": {
"username": "admin",
"password": "123456"
},
"setup": {
"type": 1,
"offDisplay": true,
"controlParams": true,
"controlDiskTemp": true,
"customPanel": false,
"language": 1,
"switchTime": "10",
"nativeSpecs": false,
"nativeMemes": true,
"operationMode": 0,
"theme": 1,
"diskUpdate": 300,
"ha_url": "",
"ha_token": "",
"refresh": 1
},
"mianban": [],
"diy": []
});
save_monitor_json(state, &default).await
}
async fn load_monitor_json(state: &AppState) -> Result<Value> {
let raw = fs::read_to_string(&state.monitor_path)
.await
.with_context(|| format!("Failed to read {:?}", state.monitor_path))?;
serde_json::from_str(&raw).context("Failed to parse Monitor3.json")
}
async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> {
let payload = serde_json::to_string_pretty(value)?;
let tmp_path = temp_monitor_path(&state.monitor_path);
fs::write(&tmp_path, payload)
.await
.with_context(|| format!("Failed to write {:?}", tmp_path))?;
fs::rename(&tmp_path, &state.monitor_path)
.await
.with_context(|| format!("Failed to replace {:?}", state.monitor_path))
}
fn temp_monitor_path(path: &PathBuf) -> PathBuf {
let file_name = path
.file_name()
.map(|name| format!("{}.tmp", name.to_string_lossy()))
.unwrap_or_else(|| "Monitor3.json.tmp".into());
path.with_file_name(file_name)
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let body = Json(ErrorResponse {
error: message.into(),
});
(status, body).into_response()
}
async fn index() -> Response {
(
[
(header::CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"),
(header::PRAGMA, "no-cache"),
(header::EXPIRES, "0"),
],
Html(INDEX_HTML),
)
.into_response()
}
async fn healthz(State(state): State<Arc<AppState>>) -> Json<Value> {
let display = read_display_status(&state.display_status);
Json(json!({
"ok": true,
"nativeDisplay": display.native_enabled,
"displayConnected": display.connected,
}))
}
async fn api_state(State(state): State<Arc<AppState>>) -> Response {
match build_state_response(&state).await {
Ok(payload) => Json(payload).into_response(),
Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
}
}
async fn api_upload(State(state): State<Arc<AppState>>, mut multipart: Multipart) -> Response {
let mut stored = None;
while let Ok(Some(field)) = multipart.next_field().await {
if field.name() != Some("file") {
continue;
}
let file_name = field
.file_name()
.map(ToString::to_string)
.unwrap_or_else(|| "panel".into());
let bytes = match field.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
return error_response(StatusCode::BAD_REQUEST, format!("Upload failed: {err}"));
}
};
match convert_and_store_image(&state, &file_name, &bytes).await {
Ok(name) => stored = Some(name),
Err(err) => return error_response(StatusCode::BAD_REQUEST, err.to_string()),
}
}
match stored {
Some(name) => Json(json!({ "ok": true, "name": name })).into_response(),
None => error_response(StatusCode::BAD_REQUEST, "No file field provided"),
}
}
async fn api_activate(
State(state): State<Arc<AppState>>,
Json(payload): Json<ActivateRequest>,
) -> Response {
let switch_time = payload.switch_time.unwrap_or(10).clamp(1, 600);
let specs_enabled = payload.specs_enabled.unwrap_or(false);
let available = match list_images(&state).await {
Ok(items) => items,
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let available_names: Vec<String> = available.into_iter().map(|item| item.name).collect();
let mut valid = Vec::new();
for name in payload.images {
if available_names.iter().any(|candidate| candidate == &name) && !valid.contains(&name) {
valid.push(name);
}
}
let memes_enabled = payload
.memes_enabled
.unwrap_or(!valid.is_empty());
if !specs_enabled && (!memes_enabled || valid.is_empty()) {
return error_response(
StatusCode::BAD_REQUEST,
"Enable specs or select at least one meme image",
);
}
let mut monitor = match load_monitor_json(&state).await {
Ok(value) => value,
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &valid);
if let Err(err) = save_monitor_json(&state, &monitor).await {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
Json(json!({
"ok": true,
"switchTime": switch_time,
"specsEnabled": specs_enabled,
"memesEnabled": memes_enabled,
"activeImages": valid,
}))
.into_response()
}
async fn api_disable(State(state): State<Arc<AppState>>) -> Response {
let mut monitor = match load_monitor_json(&state).await {
Ok(value) => value,
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let switch_time = current_switch_time(&monitor);
let active_images = current_active_images(&monitor);
set_custom_panels(&mut monitor, switch_time, false, false, &active_images);
if let Err(err) = save_monitor_json(&state, &monitor).await {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
Json(json!({ "ok": true })).into_response()
}
async fn api_delete(
State(state): State<Arc<AppState>>,
Json(payload): Json<DeleteRequest>,
) -> Response {
if payload.name.contains('/') || payload.name.contains('\\') {
return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
}
let path = state.image_dir.join(&payload.name);
match fs::remove_file(&path).await {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return error_response(StatusCode::NOT_FOUND, "Image not found");
}
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
}
let mut monitor = match load_monitor_json(&state).await {
Ok(value) => value,
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let mut active_images = current_active_images(&monitor);
let switch_time = current_switch_time(&monitor);
let specs_enabled = current_specs_enabled(&monitor);
let memes_enabled = current_memes_enabled(&monitor);
let before = active_images.len();
active_images.retain(|name| name != &payload.name);
if active_images.len() != before {
set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &active_images);
if let Err(err) = save_monitor_json(&state, &monitor).await {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
}
Json(json!({ "ok": true })).into_response()
}
async fn api_image(
AxumPath(name): AxumPath<String>,
State(state): State<Arc<AppState>>,
) -> Response {
if name.contains('/') || name.contains('\\') {
return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
}
let path = state.image_dir.join(&name);
let bytes = match fs::read(&path).await {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return error_response(StatusCode::NOT_FOUND, "Image not found");
}
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let mime = mime_guess::from_path(&path).first_or_octet_stream();
let mut response = Response::new(Body::from(bytes));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
.unwrap_or(HeaderValue::from_static("application/octet-stream")),
);
response
}
async fn api_nyan_cat() -> Response {
let mut response = Response::new(Body::from(NYAN_CAT_GIF));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("image/gif"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=86400"),
);
response
}
async fn build_state_response(state: &AppState) -> Result<StateResponse> {
let monitor = load_monitor_json(state).await?;
let setup = monitor
.get("setup")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let custom_panel = setup
.get("customPanel")
.and_then(Value::as_bool)
.unwrap_or(false);
let switch_time = setup
.get("switchTime")
.and_then(Value::as_str)
.unwrap_or("10")
.to_string();
let specs_enabled = current_specs_enabled(&monitor);
let memes_enabled = current_memes_enabled(&monitor);
Ok(StateResponse {
monitor_path: state.monitor_path.display().to_string(),
image_dir: state.image_dir.display().to_string(),
setup: SetupView {
custom_panel,
switch_time,
specs_enabled,
memes_enabled,
},
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 {
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])
}
}
async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
let mut out = Vec::new();
let mut dir = fs::read_dir(&state.image_dir).await?;
while let Some(entry) = dir.next_entry().await? {
let path = entry.path();
let file_type = entry.file_type().await?;
if !file_type.is_file() {
continue;
}
let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else {
continue;
};
let meta = entry.metadata().await?;
out.push(ImageView {
url: format!("/api/images/{name}"),
name,
size: meta.len(),
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn current_active_images(monitor: &Value) -> Vec<String> {
monitor
.get("diy")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|panel| panel.get("img").and_then(Value::as_str))
.map(ToString::to_string)
.collect()
}
fn current_switch_time(monitor: &Value) -> u32 {
monitor
.get("setup")
.and_then(Value::as_object)
.and_then(|setup| setup.get("switchTime"))
.and_then(Value::as_str)
.and_then(|value| value.parse::<u32>().ok())
.filter(|value| (1..=600).contains(value))
.unwrap_or(10)
}
fn current_custom_panel_enabled(monitor: &Value) -> bool {
monitor
.get("setup")
.and_then(Value::as_object)
.and_then(|setup| setup.get("customPanel"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn current_specs_enabled(monitor: &Value) -> bool {
monitor
.get("setup")
.and_then(Value::as_object)
.and_then(|setup| setup.get("nativeSpecs"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn current_memes_enabled(monitor: &Value) -> bool {
monitor
.get("setup")
.and_then(Value::as_object)
.and_then(|setup| setup.get("nativeMemes"))
.and_then(Value::as_bool)
.unwrap_or_else(|| !current_active_images(monitor).is_empty())
}
fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
RotationSnapshot {
custom_panel: current_custom_panel_enabled(monitor),
switch_time: current_switch_time(monitor),
specs_enabled: current_specs_enabled(monitor),
memes_enabled: current_memes_enabled(monitor),
active_images: current_active_images(monitor),
}
}
fn set_custom_panels(
monitor: &mut Value,
switch_time: u32,
specs_enabled: bool,
memes_enabled: bool,
images: &[String],
) {
let enabled = specs_enabled || (memes_enabled && !images.is_empty());
let setup = monitor
.as_object_mut()
.expect("monitor config must be an object")
.entry("setup")
.or_insert_with(|| Value::Object(Default::default()));
if let Some(setup) = setup.as_object_mut() {
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("nativeMemes".into(), Value::Bool(memes_enabled));
}
monitor["mianban"] = Value::Array(
(1..=images.len())
.map(|index| Value::Number((index as u64).into()))
.collect(),
);
monitor["diy"] = Value::Array(
images
.iter()
.map(|name| json!({"type": 5, "img": name, "sensor": []}))
.collect(),
);
}
async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> {
let image = image::load_from_memory(bytes)
.with_context(|| format!("Unsupported image format: {file_name}"))?;
let target_name = make_image_name(file_name);
let path = state.image_dir.join(&target_name);
let rendered = render_display_panel(&image);
rendered
.save_with_format(&path, ImageFormat::Jpeg)
.with_context(|| format!("Failed to save {}", path.display()))?;
Ok(target_name)
}
fn render_display_panel(image: &DynamicImage) -> RgbImage {
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
let rgb = resized.to_rgb8();
let mut canvas = RgbImage::from_pixel(
DISPLAY_WIDTH,
DISPLAY_HEIGHT,
image::Rgb([8, 10, 12]),
);
let offset_x = ((DISPLAY_WIDTH - rgb.width()) / 2) as i64;
let offset_y = ((DISPLAY_HEIGHT - rgb.height()) / 2) as i64;
image::imageops::overlay(&mut canvas, &rgb, offset_x, offset_y);
canvas
}
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
}
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")
})
}
fn make_image_name(file_name: &str) -> String {
let base = file_name
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(file_name);
let clean: String = base
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(),
_ => '-',
})
.collect();
let clean = clean.trim_matches('-');
let clean = if clean.is_empty() { "panel" } else { clean };
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
format!("panel-{timestamp}-{clean}.jpg")
}
fn spawn_display_worker(state: Arc<AppState>, config: DisplayConfig) {
thread::spawn(move || run_display_worker(state, config));
}
fn run_display_worker(state: Arc<AppState>, config: DisplayConfig) {
if !config.native_enabled {
info!("Native display loop disabled");
return;
}
loop {
match open_screen(&config) {
Ok(mut screen) => {
info!("Display target opened: {}", display_target(&config));
if let Err(err) = screen.init() {
warn!("Display init failed: {err}");
set_display_error(
&state.display_status,
false,
format!("Display init failed: {err}"),
);
thread::sleep(Duration::from_secs(3));
continue;
}
update_display_status(&state.display_status, |status| {
status.connected = true;
status.last_error = None;
status.updated_at = Some(stamp_now());
});
if let Err(err) = run_display_session(&state, &mut screen) {
error!("Display loop error: {err}");
set_display_error(
&state.display_status,
false,
format!("Display loop error: {err}"),
);
}
}
Err(err) => {
warn!("Failed to open display target: {err}");
set_display_error(
&state.display_status,
false,
format!("Failed to open display target: {err}"),
);
}
}
thread::sleep(Duration::from_secs(3));
}
}
fn open_screen(config: &DisplayConfig) -> Result<AooScreen> {
let mut builder = AooScreenBuilder::new();
builder.no_init_check(config.write_only);
if config.simulate {
builder.simulate()
} else if let Some(device) = &config.device {
builder.open_device(device)
} else if let Some(usb) = &config.usb {
builder.open_usb_id(usb)
} else {
builder.open_default()
}
}
fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
let mut snapshot = RotationSnapshot::default();
let mut cycle_started_at = Instant::now();
let mut current_frame_index = None;
loop {
let monitor = match load_monitor_json_sync(&state.monitor_path) {
Ok(monitor) => monitor,
Err(err) => {
update_display_status(&state.display_status, |status| {
status.last_error = Some(format!("Config read failed: {err}"));
status.connected = true;
status.updated_at = Some(stamp_now());
});
thread::sleep(Duration::from_secs(1));
continue;
}
};
let new_snapshot = rotation_snapshot(&monitor);
if new_snapshot != snapshot {
snapshot = new_snapshot;
cycle_started_at = Instant::now();
current_frame_index = None;
}
let frames = snapshot.frames();
update_display_status(&state.display_status, |status| {
status.custom_panel = snapshot.custom_panel;
status.specs_enabled = snapshot.specs_enabled;
status.memes_enabled = snapshot.memes_enabled;
status.rotation_active = snapshot.rotation_active();
status.switch_time = snapshot.switch_time.to_string();
status.active_images = snapshot.active_images.clone();
if !snapshot.rotation_active() {
status.current_image = None;
}
status.connected = true;
status.updated_at = Some(stamp_now());
});
if !snapshot.rotation_active() {
thread::sleep(Duration::from_millis(750));
continue;
}
let switch_after = Duration::from_secs(snapshot.switch_time as u64);
let elapsed = cycle_started_at.elapsed();
let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64;
let next_index = (frame_slot as usize) % frames.len();
let send_due = current_frame_index != Some(next_index);
if send_due {
let frame = frames[next_index].clone();
match frame {
RotationFrame::SystemSpecs => {
let panel_name = SYSTEM_FRAME_NAME.to_string();
let rgb_img = render_system_panel();
screen
.send_image(&rgb_img)
.context("Failed to send system specs panel")?;
update_display_status(&state.display_status, |status| {
status.current_image = Some(panel_name);
status.last_error = None;
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name)
{
Ok(mut rgb_img) => {
if snapshot.specs_enabled {
overlay_system_specs(&mut rgb_img);
}
screen
.send_image(&rgb_img)
.with_context(|| format!("Failed to send panel {image_name}"))?;
update_display_status(&state.display_status, |status| {
status.current_image = Some(image_name.clone());
status.last_error = None;
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
Err(err) => {
warn!("Skipping panel {image_name}: {err}");
update_display_status(&state.display_status, |status| {
status.current_image = Some(image_name.clone());
status.last_error =
Some(format!("Panel {image_name} could not be rendered: {err}"));
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
},
}
current_frame_index = Some(next_index);
}
thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at));
}
}
fn load_monitor_json_sync(path: &PathBuf) -> Result<Value> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&raw).context("Failed to parse Monitor3.json")
}
fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<RgbImage> {
let path = image_dir.join(image_name);
let image = img::load_image(&path, Some(DISPLAY_SIZE))
.with_context(|| format!("Failed to load panel image {}", path.display()))?;
Ok(image.to_rgb8())
}
fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration {
if frame_count <= 1 {
return Duration::from_millis(750);
}
let elapsed = cycle_started_at.elapsed();
let switch_secs = switch_after.as_secs().max(1);
let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs;
let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed);
if remaining > Duration::from_millis(750) {
Duration::from_millis(750)
} else if remaining < Duration::from_millis(200) {
Duration::from_millis(200)
} else {
remaining
}
}
fn read_display_status(status: &Arc<RwLock<DisplayStatus>>) -> DisplayStatus {
status
.read()
.expect("display status lock poisoned")
.clone()
}
fn update_display_status(
status: &Arc<RwLock<DisplayStatus>>,
apply: impl FnOnce(&mut DisplayStatus),
) {
let mut guard = status.write().expect("display status lock poisoned");
apply(&mut guard);
}
fn set_display_error(status: &Arc<RwLock<DisplayStatus>>, connected: bool, message: String) {
update_display_status(status, |current| {
current.connected = connected;
current.last_error = Some(message);
current.updated_at = Some(stamp_now());
if !connected {
current.current_image = None;
}
});
}
fn display_mode(config: &DisplayConfig) -> String {
if !config.native_enabled {
"disabled".into()
} else if config.simulate {
"simulate".into()
} else if config.device.is_some() {
"device".into()
} else if config.usb.is_some() {
"usb-id".into()
} else {
"usb-default".into()
}
}
fn display_target(config: &DisplayConfig) -> String {
if !config.native_enabled {
"native loop disabled".into()
} else if config.simulate {
"simulated LCD".into()
} else if let Some(device) = &config.device {
device.clone()
} else if let Some(usb) = &config.usb {
format!("USB {usb}")
} else {
"default AOOSTAR USB UART 0416:90A1".into()
}
}
fn stamp_now() -> String {
Utc::now().to_rfc3339()
}
const INDEX_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>aster-webui</title>
<style>
:root {
--bg: #0d1117;
--panel: #121821;
--line: #243243;
--text: #edf2f7;
--muted: #92a3b5;
--accent: #7ce7bf;
--accent-2: #d8fd72;
--danger: #ff7f73;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(124,231,191,.15), transparent 30%),
radial-gradient(circle at bottom right, rgba(216,253,114,.12), transparent 25%),
var(--bg);
color: var(--text);
font-family: "IBM Plex Sans", sans-serif;
}
main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
.hero, .content, .status-grid { display: grid; gap: 18px; }
.hero { grid-template-columns: 1.4fr 1fr; margin-bottom: 18px; }
.status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card {
background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9));
border: 1px solid var(--line);
border-radius: 26px;
padding: 22px;
}
.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; }
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.toggle-pill {
display: inline-flex; align-items: center; gap: 10px; padding: 10px 14px;
border: 1px solid var(--line); border-radius: 999px; background: rgba(9,14,20,.7);
color: var(--text); font-size: .95rem;
}
.toggle-pill input { margin: 0; width: 18px; height: 18px; accent-color: var(--accent); }
label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
input, button {
border-radius: 14px;
border: 1px solid var(--line);
padding: 12px 14px;
font: inherit;
}
input { background: rgba(6,10,15,.8); color: var(--text); }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
button { cursor: pointer; }
.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #071016; font-weight: 700; }
.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);
border-radius: 20px;
overflow: hidden;
background: rgba(8,12,18,.78);
}
.tile.selected { outline: 2px solid var(--accent-2); }
.tile img { width: 100%; aspect-ratio: 960 / 376; object-fit: cover; display: block; }
.tile-body { padding: 12px; display: grid; gap: 10px; }
.tile-title { word-break: break-word; }
.mini { display: flex; gap: 8px; flex-wrap: wrap; }
.order { display: grid; gap: 10px; }
.order-item {
display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
border: 1px solid var(--line); border-radius: 18px; padding: 12px 14px; background: rgba(8,12,18,.78);
}
.index { color: var(--accent-2); font-weight: 700; }
.meta { display: grid; gap: 4px; }
.toast {
position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px;
padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
background: rgba(10,14,20,.94); display: none;
}
@media (max-width: 1024px) {
.hero, .content, .controls, .status-grid, .metrics-grid { grid-template-columns: 1fr; }
}
.nyan-wrap {
position: fixed;
right: 24px;
bottom: 18px;
width: 260px;
pointer-events: none;
z-index: 30;
opacity: 0.98;
filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.35));
}
.nyan-wrap img {
display: block;
width: 100%;
height: auto;
}
@media (max-width: 900px) {
.nyan-wrap {
right: 14px;
bottom: 12px;
width: 180px;
}
}
</style>
</head>
<body>
<main>
<section class="hero">
<div class="card">
<p class="muted">aoostar-rs fork</p>
<h1>aster-webui</h1>
<p>Eigene Rust-WebUI mit nativer Display-Logik. Uploads werden auf 960x376 JPG normalisiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.</p>
<div class="controls">
<label>
<span>Switch Time</span>
<input id="switchTime" type="number" min="1" max="600" value="10">
</label>
<label>
<span>Upload New Source</span>
<input id="upload" type="file" accept="image/*">
</label>
<div class="actions">
<button id="apply" class="primary" type="button">Apply Rotation</button>
<button id="disable" class="danger" type="button">Disable</button>
<button id="refresh" class="ghost" type="button">Refresh</button>
</div>
</div>
<div class="toggle-row">
<label class="toggle-pill"><input id="specsEnabled" type="checkbox"> System Specs on LCD</label>
<label class="toggle-pill"><input id="memesEnabled" type="checkbox"> Memes on LCD</label>
</div>
</div>
<div class="status-grid">
<div class="card status"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
<div class="card status"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div>
<div class="card status"><span class="muted">Display Status</span><strong id="displayStatusValue">-</strong></div>
<div class="card status"><span class="muted">Current Frame</span><strong id="currentImageValue" style="font-size: 1rem;">-</strong></div>
<div class="card status"><span class="muted">Display Target</span><strong id="displayTargetValue" style="font-size: 1rem;">-</strong></div>
<div class="card status"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
</div>
</section>
<section class="metrics-grid">
<div class="card status"><span class="muted">CPU</span><strong id="cpuUsageValue">-</strong><small id="cpuMetaValue">-</small></div>
<div class="card status"><span class="muted">Memory</span><strong id="memUsageValue">-</strong><small id="memMetaValue">-</small></div>
<div class="card status"><span class="muted">Storage</span><strong id="diskUsageValue">-</strong><small id="diskMetaValue">-</small></div>
<div class="card status"><span class="muted">Swap</span><strong id="swapUsageValue">-</strong><small id="swapMetaValue">-</small></div>
<div class="card status"><span class="muted">Load Avg</span><strong id="loadValue">-</strong><small id="uptimeValue">-</small></div>
<div class="card status"><span class="muted">Temperatures</span><strong id="tempCpuValue">-</strong><small id="tempGpuValue">-</small></div>
</section>
<section class="content">
<div class="card">
<h2>Available Images</h2>
<div id="gallery" class="gallery"></div>
</div>
<div class="card">
<h2>Rotation Order</h2>
<div id="order" class="order"></div>
<p id="displayErrorValue" class="muted"></p>
</div>
</section>
</main>
<div id="toast" class="toast"></div>
<div class="nyan-wrap" aria-hidden="true">
<img src="/api/assets/nyan-cat.gif" alt="Nyancat">
</div>
<script>
const state = {
images: [],
activeImages: [],
setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false },
display: {},
system: {},
};
const el = {
gallery: document.getElementById("gallery"),
order: document.getElementById("order"),
switchTime: document.getElementById("switchTime"),
specsEnabled: document.getElementById("specsEnabled"),
memesEnabled: document.getElementById("memesEnabled"),
customPanelValue: document.getElementById("customPanelValue"),
imageCountValue: document.getElementById("imageCountValue"),
displayStatusValue: document.getElementById("displayStatusValue"),
currentImageValue: document.getElementById("currentImageValue"),
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"),
refresh: document.getElementById("refresh"),
toast: document.getElementById("toast"),
};
function toast(msg) {
el.toast.textContent = msg;
el.toast.style.display = "block";
clearTimeout(toast.timer);
toast.timer = setTimeout(() => el.toast.style.display = "none", 2600);
}
function bytes(n) {
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / 1024 / 1024).toFixed(1) + " MB";
}
function move(index, delta) {
const target = index + delta;
if (target < 0 || target >= state.activeImages.length) return;
const next = [...state.activeImages];
const [item] = next.splice(index, 1);
next.splice(target, 0, item);
state.activeImages = next;
render();
}
function toggle(name) {
if (state.activeImages.includes(name)) {
state.activeImages = state.activeImages.filter((item) => item !== name);
} else {
state.activeImages = [...state.activeImages, name];
}
render();
}
async function request(url, options = {}) {
const res = await fetch(url, options);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "request failed");
return data;
}
async function load() {
const data = await request("/api/state");
state.images = data.images;
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.specsEnabled.checked = !!data.setup.specs_enabled;
el.memesEnabled.checked = !!data.setup.memes_enabled;
const enabledModes = [
data.setup.specs_enabled ? "Specs" : "",
data.setup.memes_enabled ? "Memes" : "",
].filter(Boolean);
el.customPanelValue.textContent = data.setup.custom_panel
? (enabledModes.join(" + ") || "Active")
: "Disabled";
el.imageCountValue.textContent = String(data.active_images.length);
el.displayStatusValue.textContent = state.display.connected
? `Connected (${state.display.mode || "native"})`
: (state.display.native_enabled ? "Disconnected" : "Disabled");
el.currentImageValue.textContent = state.display.current_image || "-";
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.innerHTML = `/mnt/cache ${state.system.cache_usage_percent || "-"}%<br>/mnt/user ${state.system.user_usage_percent || "-"}%`;
el.diskMetaValue.textContent = `${state.system.cache_used || "-"} / ${state.system.cache_total || "-"} | ${state.system.user_used || "-"} / ${state.system.user_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();
}
async function uploadFile(event) {
const file = event.target.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
await request("/api/upload", { method: "POST", body: form });
event.target.value = "";
toast("Upload complete");
await load();
}
async function apply() {
await request("/api/panels/activate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
images: state.activeImages,
switch_time: Number.parseInt(el.switchTime.value || "10", 10),
specs_enabled: el.specsEnabled.checked,
memes_enabled: el.memesEnabled.checked,
}),
});
toast("Rotation updated");
await load();
}
async function disablePanels() {
await request("/api/panels/disable", { method: "POST" });
toast("Custom panels disabled");
await load();
}
async function removeImage(name) {
await request("/api/images/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
toast("Image deleted");
await load();
}
function render() {
el.gallery.innerHTML = "";
for (const image of state.images) {
const selected = state.activeImages.includes(image.name);
const tile = document.createElement("article");
tile.className = "tile" + (selected ? " selected" : "");
tile.innerHTML = `
<img src="${image.url}" alt="${image.name}">
<div class="tile-body">
<div class="tile-title">${image.name}</div>
<div class="muted">${bytes(image.size)}</div>
<div class="mini">
<button class="ghost" type="button">${selected ? "Remove" : "Add"}</button>
<button class="danger" type="button">Delete</button>
</div>
</div>
`;
const [toggleBtn, deleteBtn] = tile.querySelectorAll("button");
toggleBtn.addEventListener("click", () => toggle(image.name));
deleteBtn.addEventListener("click", () => removeImage(image.name));
el.gallery.appendChild(tile);
}
el.order.innerHTML = "";
if (state.setup.specs_enabled && !state.activeImages.length) {
const row = document.createElement("div");
row.className = "order-item";
row.innerHTML = `
<div class="index">S</div>
<div class="meta">
<div>System Specs</div>
<div class="muted">${state.display.current_image === "System Specs" ? "Currently shown" : "Fixed first frame when enabled"}</div>
</div>
<div class="mini"><span class="muted">Toggle above</span></div>
`;
el.order.appendChild(row);
}
if (state.setup.specs_enabled && state.activeImages.length) {
const row = document.createElement("div");
row.className = "order-item";
row.innerHTML = `
<div class="index">S</div>
<div class="meta">
<div>System Specs Overlay</div>
<div class="muted">Live stats are drawn over every meme while enabled.</div>
</div>
<div class="mini"><span class="muted">Toggle above</span></div>
`;
el.order.appendChild(row);
}
if (!state.activeImages.length && !state.setup.specs_enabled) {
el.order.innerHTML = `<p class="muted">No active images.</p>`;
return;
}
state.activeImages.forEach((name, index) => {
const row = document.createElement("div");
row.className = "order-item";
row.innerHTML = `
<div class="index">${index + 1}</div>
<div class="meta">
<div>${name}</div>
<div class="muted">${state.display.current_image === name ? "Currently shown" : ""}</div>
</div>
<div class="mini">
<button class="ghost" type="button">Up</button>
<button class="ghost" type="button">Down</button>
<button class="danger" type="button">Remove</button>
</div>
`;
const [up, down, remove] = row.querySelectorAll("button");
up.addEventListener("click", () => move(index, -1));
down.addEventListener("click", () => move(index, 1));
remove.addEventListener("click", () => toggle(name));
el.order.appendChild(row);
});
}
el.upload.addEventListener("change", (event) => uploadFile(event).catch((err) => toast(err.message)));
el.apply.addEventListener("click", () => apply().catch((err) => toast(err.message)));
el.disable.addEventListener("click", () => disablePanels().catch((err) => toast(err.message)));
el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message)));
load().catch((err) => toast(err.message));
setInterval(() => load().catch(() => {}), 5000);
</script>
</body>
</html>
"##;