[WIP] A simple wake-on-lan service

add support for custom themes and urls

vielle.dev a610e804 2c6a9beb

verified
+108 -24
+43 -7
src/config.rs
··· 11 11 pub struct Config { 12 12 #[serde(default = "default_binding")] 13 13 binding: String, 14 + #[serde(default = "default_theme")] 15 + theme: Theme, 14 16 pinned: Option<Vec<String>>, 15 17 targets: HashMap<String, Target>, 16 18 } 17 19 20 + /// all colours are in rgb 21 + #[derive(Deserialize, Serialize, Debug, Clone)] 22 + pub struct Theme { 23 + background: (u8, u8, u8), 24 + foreground: (u8, u8, u8), 25 + text: (u8, u8, u8), 26 + text_secondary: (u8, u8, u8), 27 + accent_success: (u8, u8, u8), 28 + accent_fail: (u8, u8, u8), 29 + link: (u8, u8, u8), 30 + link_visited: (u8, u8, u8), 31 + highlight: (u8, u8, u8), 32 + highlight_opacity: u8, 33 + } 34 + 18 35 #[derive(Deserialize, Serialize, Debug, Clone)] 19 36 pub struct Target { 20 37 #[serde( ··· 23 40 )] 24 41 pub mac: crate::mac::MacAddress, 25 42 pub ip: Option<String>, 43 + pub url: Option<String>, 26 44 } 27 45 28 46 fn default_binding() -> String { 29 47 "0.0.0.0:3000".to_string() 30 48 } 31 49 50 + fn default_theme() -> Theme { 51 + Theme { 52 + background: (48, 52, 70), 53 + foreground: (35, 38, 52), 54 + text: (198, 208, 245), 55 + text_secondary: (165, 173, 206), 56 + accent_success: (166, 209, 137), 57 + accent_fail: (231, 130, 132), 58 + link: (140, 170, 238), 59 + link_visited: (202, 158, 230), 60 + highlight: (148, 156, 187), 61 + highlight_opacity: 64, 62 + } 63 + } 64 + 32 65 #[derive(Error, Debug)] 33 66 pub enum ConfigError { 34 67 #[error("Io error: {}", .0)] ··· 53 86 if let Some(mismatch) = &config 54 87 .pinned 55 88 .as_ref() 56 - .and_then(|p| p.iter().skip_while(|x| targets.contains(x)).next()) 89 + .and_then(|p| p.iter().find(|x| !targets.contains(x))) 57 90 { 58 91 return Err(ConfigError::UnknownPin(mismatch.to_string())); 59 92 }; 60 93 61 94 let mut uniq = HashSet::<String>::new(); 62 - if let Some(dupe) = &config.pinned.as_ref().and_then(|p| { 63 - p.iter() 64 - .skip_while(move |x| uniq.insert(x.to_string())) 65 - .next() 66 - }) { 95 + if let Some(dupe) = &config 96 + .pinned 97 + .as_ref() 98 + .and_then(|p| p.iter().find(move |x| !uniq.insert(x.to_string()))) 99 + { 67 100 return Err(ConfigError::DupePin(dupe.to_string())); 68 101 }; 69 102 70 - return Ok(config); 103 + Ok(config) 71 104 } 72 105 106 + #[allow(unused)] 73 107 pub fn get_binding(&self) -> &String { 74 108 &self.binding 75 109 } 76 110 111 + #[allow(unused)] 77 112 pub fn get_pinned(&self) -> &Option<Vec<String>> { 78 113 &self.pinned 79 114 } 80 115 116 + #[allow(unused)] 81 117 pub fn get_targets(&self) -> &HashMap<String, Target> { 82 118 &self.targets 83 119 }
+9 -6
src/mac.rs
··· 59 59 type Err = MacAddressParseError; 60 60 61 61 fn from_str(s: &str) -> Result<Self, Self::Err> { 62 - let mut parts = s.split(":"); 63 - let mut address: [u8; 6] = [0, 0, 0, 0, 0, 0]; 64 - for i in 0..address.len() { 65 - address[i] = 66 - u8::from_str_radix(parts.next().ok_or(MacAddressParseError::TooShort)?, 16)?; 62 + let address = s.split(":"); 63 + let address = address 64 + .map(|x| u8::from_str_radix(x, 16)) 65 + .collect::<Result<Vec<_>, ParseIntError>>()?; 66 + if address.len() != 6 { 67 + return Err(MacAddressParseError::TooShort); 67 68 } 68 - Ok(MacAddress(address)) 69 + Ok(MacAddress([ 70 + address[0], address[1], address[2], address[3], address[4], address[5], 71 + ])) 69 72 } 70 73 } 71 74
+14 -2
src/server.rs
··· 1 - use std::sync::Arc; 1 + use std::{sync::Arc, time::Instant}; 2 2 3 3 use axum::{ 4 4 Json, Router, 5 - extract::State, 5 + extract::{Request, State}, 6 6 http::Response, 7 + middleware::{self}, 7 8 routing::{get, post}, 8 9 }; 9 10 use serde::Deserialize; ··· 33 34 Json((*conf).clone()) 34 35 } 35 36 37 + async fn log(req: Request, next: axum::middleware::Next) -> axum::response::Response { 38 + let start = Instant::now(); 39 + let method = req.method().to_string(); 40 + let uri = req.uri().to_string(); 41 + let res = next.run(req).await; 42 + 43 + println!("({:?}) [{}] {}", start.elapsed(), method, uri); 44 + res 45 + } 46 + 36 47 pub fn router(conf: Config) -> Router { 37 48 Router::new() 38 49 .route("/wake", post(wake)) 39 50 .route("/config", get(config)) 40 51 .with_state(Arc::new(conf)) 41 52 .merge(dist::main()) 53 + .layer(middleware::from_fn(log)) 42 54 }
+4 -2
web/src/App.svelte
··· 15 15 ([k]) => !(config.pinned ?? []).includes(k), 16 16 ), 17 17 ]; 18 + 19 + console.log(config); 18 20 </script> 19 21 20 22 <h1>Wake on Lan</h1> 21 - {#each targets as [name, { mac, ip }]} 22 - <Power {name} {mac} {ip} /> 23 + {#each targets as [name, { mac, ip, url }]} 24 + <Power {name} {mac} {ip} {url} /> 23 25 {/each}
+20 -6
web/src/lib/Power.svelte
··· 5 5 name, 6 6 mac, 7 7 ip, 8 + url, 8 9 }: { 9 10 name: string; 10 11 mac: string; 11 12 ip: string | null; 13 + url: string | null; 12 14 } = $props(); 13 15 </script> 14 16 15 - <button 16 - onclick={() => 17 - Api.wake({ mac }).then((res) => alert(`Wake: ${mac} (${ip}) ${res}`))} 18 - > 17 + <section> 18 + <button 19 + onclick={() => 20 + Api.wake({ mac }).then((res) => alert(`Wake: ${mac} (${ip}) ${res}`))} 21 + > 22 + Power On 23 + </button> 24 + 25 + {#if url} 26 + <a href={url}>{url}</a> 27 + &bull; 28 + {/if} 19 29 {name} 30 + &bull; 20 31 {mac} 21 - {ip} 22 - </button> 32 + {#if ip} 33 + &bull; 34 + <a href={`http://${ip}/`}>{ip}</a> 35 + {/if} 36 + </section>
+18 -1
web/src/lib/api.ts
··· 57 57 : () => fetch(route, { method }).then(then); 58 58 } 59 59 60 + const u8 = z.int().min(0).max(255); 61 + const colour = z.tuple([u8, u8, u8]); 62 + 60 63 const Api = { 61 64 config: route({ 62 65 route: "/config", 63 66 method: "GET", 64 67 output: z.object({ 65 68 binding: z.string(), 66 - pinned: z.array(z.string()).optional(), 69 + theme: z.object({ 70 + background: colour, 71 + foreground: colour, 72 + text: colour, 73 + text_secondary: colour, 74 + accent_success: colour, 75 + accent_fail: colour, 76 + link: colour, 77 + link_visited: colour, 78 + highlight: colour, 79 + highlight_opacity: u8, 80 + }), 81 + pinned: z.array(z.string()).nullable(), 67 82 targets: z.record( 68 83 z.string(), 69 84 z.object({ 70 85 mac: z.string(), 71 86 ip: z.string().nullable(), 87 + // should be a url but we allow any string in rust 88 + url: z.string().nullable(), 72 89 }), 73 90 ), 74 91 }),