Add experimental aster web UI
Rust / Clippy, Rustfmt, Tests (push) Has been cancelled
Rust / Linux-x64 build (push) Has been cancelled
Rust / GitHub release (push) Has been cancelled

This commit is contained in:
2026-06-09 15:55:56 +02:00
parent 2f4d95957d
commit 1e1d37bfc2
4 changed files with 1296 additions and 7 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "aster-webui"
version = "0.1.0"
description = "Web UI and config API for AOOSTAR WTR MAX / GEM12+ PRO"
rust-version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "1.0.98"
axum = { version = "0.8.4", features = ["multipart"] }
chrono = "0.4"
clap = { version = "4.5.42", features = ["derive"] }
image = "0.25.6"
mime_guess = "2.0.5"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
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"] }
+773
View File
@@ -0,0 +1,773 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use anyhow::{Context, Result};
use axum::{
Json, Router,
body::Body,
extract::{DefaultBodyLimit, Multipart, Path, State},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use chrono::Local;
use clap::Parser;
use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::fs;
use tracing::info;
const DISPLAY_WIDTH: u32 = 960;
const DISPLAY_HEIGHT: u32 = 376;
#[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,
}
#[derive(Clone)]
struct AppState {
monitor_path: PathBuf,
image_dir: PathBuf,
}
#[derive(Serialize)]
struct StateResponse {
monitor_path: String,
image_dir: String,
setup: SetupView,
active_images: Vec<String>,
images: Vec<ImageView>,
}
#[derive(Serialize)]
struct SetupView {
custom_panel: bool,
switch_time: String,
}
#[derive(Serialize)]
struct ImageView {
name: String,
size: u64,
url: String,
}
#[derive(Deserialize)]
struct ActivateRequest {
images: Vec<String>,
switch_time: Option<u32>,
}
#[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 state = Arc::new(AppState {
monitor_path: cli.config_dir.join("Monitor3.json"),
image_dir: cli.config_dir.join("img"),
});
ensure_layout(&state).await?;
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/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(())
}
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",
"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)?;
fs::write(&state.monitor_path, payload)
.await
.with_context(|| format!("Failed to write {:?}", state.monitor_path))
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let body = Json(ErrorResponse {
error: message.into(),
});
(status, body).into_response()
}
async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn healthz() -> Json<Value> {
Json(json!({"ok": true}))
}
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 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);
}
}
if valid.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "No valid images selected");
}
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, true, switch_time, &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,
"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()),
};
if let Some(setup) = monitor.get_mut("setup").and_then(Value::as_object_mut) {
setup.insert("customPanel".into(), Value::Bool(false));
}
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 before = active_images.len();
active_images.retain(|name| name != &payload.name);
if active_images.len() != before {
set_custom_panels(
&mut monitor,
!active_images.is_empty(),
switch_time,
&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(Path(name): Path<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 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();
Ok(StateResponse {
monitor_path: state.monitor_path.display().to_string(),
image_dir: state.image_dir.display().to_string(),
setup: SetupView {
custom_panel,
switch_time,
},
active_images: current_active_images(&monitor),
images: list_images(state).await?,
})
}
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 set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, images: &[String]) {
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()));
}
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 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")
}
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 { display: grid; gap: 18px; }
.hero { grid-template-columns: 1.5fr 1fr; margin-bottom: 18px; }
.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 { display: grid; gap: 12px; }
.status strong { display: block; margin-top: 8px; font-size: 1.5rem; }
.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 {
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; }
.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; }
.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 { grid-template-columns: 1fr; }
}
</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 fuer Panelbilder, Rotation und `Monitor3.json`-Steuerung. Uploads werden auf 960x376 JPG normalisiert; animierte GIFs nutzen aktuell nur das erste Frame.</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>
<div class="status">
<div class="card"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
<div class="card"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div>
<div class="card"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
</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>
</div>
</section>
</main>
<div id="toast" class="toast"></div>
<script>
const state = { images: [], activeImages: [], setup: { switch_time: "10", custom_panel: false } };
const el = {
gallery: document.getElementById("gallery"),
order: document.getElementById("order"),
switchTime: document.getElementById("switchTime"),
customPanelValue: document.getElementById("customPanelValue"),
imageCountValue: document.getElementById("imageCountValue"),
configPathValue: document.getElementById("configPathValue"),
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;
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);
el.configPathValue.textContent = data.monitor_path;
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),
}),
});
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.activeImages.length) {
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>${name}</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));
</script>
</body>
</html>
"##;