Add experimental aster web UI
This commit is contained in:
Generated
+474
-7
@@ -152,6 +152,23 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aster-webui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"image",
|
||||
"mime_guess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asterctl"
|
||||
version = "0.2.0"
|
||||
@@ -185,6 +202,12 @@ dependencies = [
|
||||
"serialport",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -214,6 +237,59 @@ dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
@@ -305,7 +381,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.0",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -422,6 +498,15 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.3"
|
||||
@@ -521,6 +606,15 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
@@ -530,6 +624,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
@@ -636,6 +739,86 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.64"
|
||||
@@ -867,6 +1050,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.2"
|
||||
@@ -945,6 +1134,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
@@ -971,6 +1175,22 @@ version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -999,6 +1219,23 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra"
|
||||
version = "0.32.6"
|
||||
@@ -1080,6 +1317,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -1211,6 +1457,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
@@ -1585,18 +1837,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1615,6 +1877,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -1635,6 +1908,18 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialport"
|
||||
version = "4.7.3"
|
||||
@@ -1654,6 +1939,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -1700,6 +1994,22 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -1717,6 +2027,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.37.0"
|
||||
@@ -1803,6 +2119,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.9.1"
|
||||
@@ -1814,6 +2139,31 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
@@ -1848,6 +2198,96 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -1869,6 +2309,12 @@ dependencies = [
|
||||
"thiserror 2.0.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
@@ -1892,12 +2338,24 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -2104,9 +2562,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
@@ -2154,6 +2612,15 @@ dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
||||
@@ -18,6 +18,31 @@ Check out the **[User Guide](https://zehnm.github.io/aoostar-rs)** for a list of
|
||||
- Rotate through multiple panels in a defined interval.
|
||||
- USB device/serial port selection.
|
||||
|
||||
## Experimental Web UI
|
||||
|
||||
This fork also contains an experimental `aster-webui` crate for managing custom panel images and
|
||||
`Monitor3.json` through a browser.
|
||||
|
||||
Current scope:
|
||||
|
||||
- upload source images through the browser
|
||||
- normalize them to AOOSTAR-safe `960x376` JPG files
|
||||
- manage rotation order and switch interval
|
||||
- enable or disable custom panels without the vendor web UI
|
||||
|
||||
Start it from the workspace root:
|
||||
|
||||
```bash
|
||||
cargo run -p aster-webui -- --config-dir /config --bind 0.0.0.0:8080
|
||||
```
|
||||
|
||||
Important:
|
||||
|
||||
- the current web UI writes compatible `Monitor3.json` and `/config/img/*` assets
|
||||
- animated GIF uploads currently use the first frame only
|
||||
- native screen driving is still handled separately; the next step is wiring the web UI directly
|
||||
into a full `aoostar-rs`-based display daemon
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> I take no responsibility for the use of this software.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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>
|
||||
"##;
|
||||
Reference in New Issue
Block a user