2 Commits
15 changed files with 433 additions and 63 deletions

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
# advices from gpt5rustup target add armv7-unknown-linux-musleabihf
[build]
target = "armv7-unknown-linux-musleabihf"
# target = "armv7-unknown-linux-musleabihf"
+5
View File
@@ -0,0 +1,5 @@
you can use block code as so:
```rust
hello_world!(println);
```
in the chat.
Generated
+40 -1
View File
@@ -99,6 +99,31 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d"
dependencies = [
"axum",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde",
"serde_html_form",
"serde_path_to_error",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-macros"
version = "0.5.0"
@@ -804,6 +829,19 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_html_form"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
dependencies = [
"form_urlencoded",
"indexmap 2.11.0",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_json"
version = "1.0.143"
@@ -1141,9 +1179,10 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "wakey"
version = "0.0.0"
version = "0.1.0"
dependencies = [
"axum",
"axum-extra",
"color-eyre",
"futures",
"macaddr",
+2 -1
View File
@@ -1,11 +1,12 @@
[package]
name = "wakey"
version = "0.0.0"
version = "0.1.0"
edition = "2024"
publish = ["gitea"]
[dependencies]
axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10.1", features = ["query"] }
color-eyre = "0.6.5"
futures = "0.3.31"
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
+26 -7
View File
@@ -12,11 +12,12 @@ use std::{net::IpAddr, str::FromStr};
use macaddr::MacAddr;
use serde::{Deserialize, Serialize, Serializer, de};
use serde_with::skip_serializing_none;
use serde_with::skip_serializing_none ;
use strum::{Display, EnumString};
use crate::arpparse::error::IPNeighParseError;
mod error;
mod r#impl; // custom (de)serialization impls
/// ip neigh has some cool shit.
/// IP
/// dev DEV | None
@@ -32,26 +33,30 @@ pub struct IpNeighLine {
/// link layer address
#[serde(serialize_with = "ser_opm")]
pub mac: Option<MacAddr>,
/// Neighbour Unreachability Detection
/// Neighbour Unreachability Detection
pub state: NUDState,
}
/// serialize an [`Option<MacAddr>`]
pub fn ser_opm<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
}
/// deserialize an [`Option<MacAddr>`]
pub fn des_opm<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<&str>::deserialize(des)?
.map(MacAddr::from_str)
.map(str::parse)
.transpose()
.map_err(de::Error::custom)
}
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec
#[derive(Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize)]
#[strum(serialize_all = "UPPERCASE")]
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")]
pub enum NUDState {
/// the neighbour entry is valid forever and can
@@ -92,6 +97,20 @@ pub enum NUDState {
}
impl NUDState {
/// Argument form expected by `ip neigh ... nud <state>` (lowercase)
pub const fn as_ip_neigh_arg(self) -> &'static str {
match self {
NUDState::Permanent => "permanent",
NUDState::Reachable => "reachable",
NUDState::Stale => "stale",
NUDState::Delay => "delay",
NUDState::Probe => "probe",
NUDState::Incomplete => "incomplete",
NUDState::Noarp => "noarp",
NUDState::None => "none",
NUDState::Failed => "failed",
}
}
/// dumb UI label
pub fn dumber_state(&self) -> &'static str {
match self {
@@ -104,7 +123,7 @@ impl NUDState {
}
}
/// dumb boolean: Some(true)=on, Some(false)=off, None=shrug
pub fn dumber_state_this_way(&self) -> Option<bool> {
pub fn _dumber_state_this_way(&self) -> Option<bool> {
match self {
NUDState::Permanent | NUDState::Reachable => Some(true),
NUDState::Failed => Some(false),
@@ -180,7 +199,7 @@ impl IpNeighLine {
..self_
}
}
pub fn with_mac(mac: MacAddr) -> impl FnMut(Self) -> Self {
pub fn _with_mac(mac: MacAddr) -> impl FnMut(Self) -> Self {
move |self_| Self {
mac: Some(mac),
..self_
@@ -216,7 +235,7 @@ impl Ord for NUDState {
impl IpNeighLine {
// score for “local and online”: state, has-mac, v4, iface preference
pub fn score(&self) -> (u8, u8, u8, u8) {
pub fn _score(&self) -> (u8, u8, u8, u8) {
let iface = self
.dev
.as_deref()
+14
View File
@@ -0,0 +1,14 @@
use serde::{Deserialize, Deserializer, de};
use super::NUDState;
// Case-insensitive parsing for NUDState via manual Deserialize
impl<'de> Deserialize<'de> for NUDState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
s.parse().map_err(de::Error::custom)
}
}
+14 -6
View File
@@ -7,12 +7,15 @@ use axum::{
};
use tokio::net::TcpListener;
mod arpparse;
mod route;
mod r#static;
mod utils;
mod route;
use crate::{
route::DeviceQuery,
utils::{ping::ping_ip, query::get_macs_2_1, wake::wake},
};
use r#static as st;
const MACHINE_NAME: &str = "lda.lan";
@@ -110,9 +113,14 @@ async fn main() -> color_eyre::Result<()> {
}
#[cfg(not(target_os = "linux"))]
fn main() {
use std::process::exit;
eprintln!("OS not supported! run this on your ahh router!");
exit(1)
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
use std::net::ToSocketAddrs;
// use crate::arpparse::NUDState;
// println!("{}", NUDState::Reachable.to_string().to_lowercase());
println!("{:?}", "svuhuvshdv:331".to_socket_addrs());
// Err(Os { code: 11001, kind: Uncategorized, message: "No such host is known." })
Err(color_eyre::eyre::eyre!(
"OS not supported! run this on your ahh router!"
))
}
+88 -17
View File
@@ -2,17 +2,28 @@ use std::net::IpAddr;
use axum::{
Json, Router,
extract::{Path, Query},
extract::Path,
http::{StatusCode, header},
response::{Html, IntoResponse, Redirect},
routing::get,
};
use axum_extra::extract::Query;
use macaddr::MacAddr;
use serde_with::skip_serializing_none;
use crate::{MACHINE_NAME, arpparse::{self, des_opm}, status_build, utils::query::get_macs_1};
// use serde_with::skip_serializing_none;
// use serde::{Deserialize, de};
// use serde_with::{OneOrMany, serde_as};
use crate::{
MACHINE_NAME,
arpparse::{self, NUDState, des_opm},
st, status_build,
utils::{de_many, query::get_macs},
};
pub async fn home_2() -> Html<&'static str> {
Html(include_str!("../static/home_2"))
Html(st::HOME_2)
}
async fn home_2_css() -> impl IntoResponse {
(
@@ -20,7 +31,7 @@ async fn home_2_css() -> impl IntoResponse {
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
include_str!("../static/assets/home_2.css"),
st::HOME_2_CSS,
)
}
async fn home_2_js() -> impl IntoResponse {
@@ -29,7 +40,7 @@ async fn home_2_js() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
include_str!("../static/assets/home_2.js"),
st::HOME_2_JS,
)
}
@@ -44,42 +55,102 @@ pub fn home_2_route() -> Router {
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct DeviceQuery {
pub name: Option<String>,
ip: Option<IpAddr>,
// Accept single or many; ignore blanks
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
ip: Vec<IpAddr>,
#[serde(default, deserialize_with = "des_opm")]
mac: Option<MacAddr>,
/// optional interface filter (e.g., br-lan)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub dev: Vec<String>,
/// optional NUD state filter; accepts any case (e.g., reachable, REACHABLE)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub nud: Vec<NUDState>,
}
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct NamePath {
name: String,
}
#[skip_serializing_none]
#[derive(Debug, Default, serde::Serialize)]
pub struct Status {
name: String,
name: Option<String>,
table: Vec<arpparse::IpNeighLine>,
filters: Filters,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct Filters {
#[serde(skip_serializing_if = "Vec::is_empty")]
ip: Vec<IpAddr>,
#[serde(skip_serializing_if = "Vec::is_empty")]
dev: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
nud: Vec<NUDState>,
}
#[skip_serializing_none]
#[derive(Debug, serde::Serialize)]
pub struct StatusError {
name: String,
name: Option<String>,
error: String,
}
// pub struct statuserror? table? and error? on status? what is the strat here
pub async fn get_status_json(
// p: Option<Path<NamePath>>,
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
Query(DeviceQuery {
name, ip, dev, nud, ..
}): Query<DeviceQuery>,
) -> impl IntoResponse {
let name = /* p
.map(|Path(n)| n.name)
.or */(name)
.unwrap_or_else(|| MACHINE_NAME.to_owned());
match get_macs_1(&name).await {
fn to_opts<T: Clone>(slice: &[T]) -> Vec<Option<T>> {
if slice.is_empty() {
vec![None]
} else {
slice.iter().cloned().map(Some).collect()
}
}
/* let name = /* p
.map(|Path(n)| n.name)
.or */(name)
// .unwrap_or_else(|| MACHINE_NAME.to_owned())
; */
// ip/dev/nud already parsed; assemble options
let ips_opt = if ip.is_empty() {
None
} else {
Some(ip.clone())
};
let dev_opts: Vec<Option<String>> = to_opts(&dev);
let nud_opts: Vec<Option<NUDState>> = to_opts(&nud);
let filters = Filters { ip, dev, nud };
// Run combinations of dev/nud and merge results
let mut tasks = Vec::new();
for d in &dev_opts {
for n in &nud_opts {
tasks.push(get_macs(
name.as_deref(),
ips_opt.as_deref(),
d.as_deref(),
*n,
));
}
}
match futures::future::try_join_all(tasks)
.await
.map(|v| v.into_iter().flatten().collect::<Vec<_>>())
{
Ok(table) => {
// let canonical = format!("/api/status?name={name}");
(
StatusCode::OK,
// [(header::LINK, format!("<{canonical}>; rel=\"canonical\""))],
Json(Status { name, table }),
Json(Status {
name,
table,
filters,
}),
)
.into_response()
}
+3
View File
@@ -0,0 +1,3 @@
pub const HOME_2: &str = include_str!("../static/home_2");
pub const HOME_2_CSS: &str = include_str!("../static/assets/home_2.css");
pub const HOME_2_JS: &str = include_str!("../static/assets/home_2.js");
+48 -6
View File
@@ -11,19 +11,61 @@ pub mod wake;
use std::{net::IpAddr, sync::LazyLock};
use macaddr::MacAddr;
use tokio::io;
pub mod error;
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
// this is so bad
pub mod ping;
/// this is because i like [`IpAddr`] more than [`SocketAddr`](std::net::SocketAddr)
pub async fn get_ips(machine_name: &str) -> io::Result<Vec<IpAddr>> {
Ok(tokio::net::lookup_host((machine_name, 0))
.await?
.map(|c| c.ip())
.collect())
pub async fn get_ips(machine_name: &str) -> error::Result<Vec<IpAddr>> {
let it = tokio::net::lookup_host((machine_name, 0))
.await
.map_err(|e| error::Error::DnsResolve { name: machine_name.to_string(), source: e })?;
Ok(it.map(|c| c.ip()).collect())
}
pub mod cmd;
pub mod query;
// no custom ip deserializer needed when using axum_extra::extract::Query
// but we add a generic one to ignore blanks and accept OneOrMany
pub mod de_many {
use serde::Deserialize;
use serde::de;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
pub fn vec_from_strs<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
let mut out = Vec::new();
match raw {
OneOrMany::One(s) => {
let t = s.trim();
if !t.is_empty() {
out.push(t.parse().map_err(de::Error::custom)?);
}
}
OneOrMany::Many(vs) => {
for s in vs {
let t = s.trim();
if t.is_empty() {
continue;
}
out.push(t.parse().map_err(de::Error::custom)?);
}
}
}
Ok(out)
}
}
+48
View File
@@ -0,0 +1,48 @@
use std::{fmt, io};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
/// Failure to resolve a host name to IPs.
DnsResolve { name: String, source: io::Error },
/// External command failed (e.g., ip neigh)
CommandFailed {
cmd: &'static str,
args: Vec<String>,
status: Option<i32>,
stderr: String,
},
/// Generic IO error fallback
Io(io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::DnsResolve { name, source } => write!(f, "DNS resolve failed for {name}: {source}"),
Error::CommandFailed { cmd, args, status, stderr } => {
let code = status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into());
write!(
f,
"{cmd} {args:?} failed (status: {code}): {stderr}",
)
}
Error::Io(e) => write!(f, "IO error: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::DnsResolve { source, .. } => Some(source),
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self { Error::Io(e) }
}
+84 -15
View File
@@ -1,14 +1,14 @@
use std::{collections::HashSet, net::IpAddr};
use macaddr::MacAddr;
use tokio::io;
// use tokio::io;
use crate::{
arpparse::{self, IpNeighLine, NUDState},
utils::{cmd::exec_command, get_ips},
utils::{cmd::exec_command, error::{self, Error, Result}, get_ips},
};
pub async fn get_macs_2_1(machine_name: &str) -> io::Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
pub async fn get_macs_2_1(machine_name: &str) -> Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
@@ -22,7 +22,7 @@ pub async fn get_macs_2_1(machine_name: &str) -> io::Result<HashSet<(IpAddr, Mac
)
.collect())
}
pub async fn get_macs_2_mac(machine_name: &str) -> io::Result<HashSet<MacAddr>> {
pub async fn get_macs_2_mac(machine_name: &str) -> Result<HashSet<MacAddr>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
@@ -37,20 +37,22 @@ pub async fn get_macs_2_mac(machine_name: &str) -> io::Result<HashSet<MacAddr>>
.collect())
}
pub async fn get_macs_1(machine_name: &str) -> io::Result<Vec<arpparse::IpNeighLine>> {
pub async fn get_macs_1(machine_name: &str) -> Result<Vec<arpparse::IpNeighLine>> {
let dev = "br-lan";
let ips = get_ips(machine_name).await?;
let futures = ips.iter().map(|ip| {
let ip = ip.to_canonical();
async move {
let o =
exec_command("ip", ["neigh", "show", "to", &ip.to_string(), "dev", dev]).await?;
let cmd = "ip";
let args = ["neigh", "show", "to", &ip.to_string(), "dev", dev];
let o = exec_command(cmd, args).await?;
if !o.status.success() {
return Err(io::Error::other(format!(
"`ip neigh` failed for {ip} (status: {st}): {err}",
st = o.status,
err = String::from_utf8_lossy(&o.stderr),
)));
return Err(Error::CommandFailed {
cmd,
args: args.iter().map(ToString::to_string).collect(),
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
};
Ok(String::from_utf8_lossy(&o.stdout)
.lines()
@@ -62,12 +64,79 @@ pub async fn get_macs_1(machine_name: &str) -> io::Result<Vec<arpparse::IpNeighL
let res = futures::future::try_join_all(futures).await?; // async move block errs.
Ok(res
.into_iter()
.flatten() /* resolve double vec */
// .flatten() /* drop parse errors (flat_map cleared) */
.collect())
}
pub async fn get_macs(machine_name: Option<&str>, ips: Option<impl IntoIterator<Item = IpAddr>>, dev: Option<&str>, state: Option<NUDState>) -> io::Result<Vec<IpNeighLine>> {
todo!()
pub async fn get_macs(
machine_name: Option<&str>,
ips: Option<&[IpAddr]>,
dev: Option<&str>,
state: Option<NUDState>,
) -> Result<Vec<IpNeighLine>> {
// Collect IPs early (before any await) to avoid holding generics across await points
let ip_list: Option<Vec<IpAddr>> = ips.map(|slice| {
slice
.iter()
.copied()
.map(|ip| ip.to_canonical())
.collect()
});
// Resolve by machine name if no IPs provided but we have a name
let ip_list = match (ip_list, machine_name) {
(Some(list), _) => list,
(None, Some(name)) => get_ips(name).await?.into_iter().collect(),
(None, None) => Vec::new(),
};
// Helper to convert NUDState to the string expected by `ip neigh`
let nud_arg = state.map(NUDState::as_ip_neigh_arg);
// let nud_arg = Rc::new(state.map(|s| s.to_string().to_lowercase()));
// Build a closure to run one `ip neigh` invocation and parse results
let run_one = |to_ip: Option<IpAddr>| async move {
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
if let Some(ip) = to_ip {
args.push("to".into());
args.push(ip.to_string());
}
if let Some(d) = dev {
args.push("dev".into());
args.push(d.to_string());
}
if let Some(nud) = nud_arg {
args.push("nud".into());
args.push(nud.to_string());
}
let o = exec_command("ip", args.iter().map(String::as_str).collect::<Vec<_>>()).await?; // hope to rustc that it knows how to unfuck ts
if !o.status.success() {
return Err(Error::CommandFailed {
cmd: "ip",
args,
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
}
let lines = String::from_utf8_lossy(&o.stdout);
// Parse lines and, if a specific dev filter was used, stamp that dev onto rows
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
parsed.map(IpNeighLine::with_dev(d)).collect()
} else {
parsed.collect()
};
Ok::<Vec<IpNeighLine>, error::Error>(rows)
};
// If we have specific IPs, query each; otherwise query the whole table once
if !ip_list.is_empty() {
let futures = ip_list.into_iter().map(|ip| run_one(Some(ip)));
let res = futures::future::try_join_all(futures).await?;
Ok(res.into_iter().flatten().collect())
} else {
run_one(None).await
}
}
+15
View File
@@ -111,3 +111,18 @@ pre {
a {
color: #93c5fd;
}
#html {
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
#html::-webkit-scrollbar {
display: none;
}
.filters {
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
}
+43 -8
View File
@@ -50,7 +50,18 @@ function rankState(s) {
);
}
/** @param {{ name: String, table: Array<{ ip, dev, mac, state }>} | { name: String, error }} data from /api/status */
function buildStatusUrl(name) {
const u = new URL("/api/status", location.origin);
if (name) u.searchParams.set("name", name);
// forward multi-value filters from current page URL
for (const k of ["ip", "dev", "nud"]) {
const vals = qs.getAll(k);
for (const v of vals) u.searchParams.append(k, v);
}
return u;
}
/** @param {{ name: String, table: Array<{ ip, dev, mac, state }>, filters: {ip, dev, nud}} | { name: String, error }} data from /api/status */
function renderStatus(data) {
const tbl = document.createElement("table");
tbl.innerHTML = `<tr><th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr>`;
@@ -62,6 +73,22 @@ function renderStatus(data) {
tbl.appendChild(tr);
}
elHtml.innerHTML = "";
// Optional: show applied filters
if (data.filters) {
const parts = [];
if (Array.isArray(data.filters.ip) && data.filters.ip.length)
parts.push(`ip=[${data.filters.ip.join(", ")}]`);
if (Array.isArray(data.filters.dev) && data.filters.dev.length)
parts.push(`dev=[${data.filters.dev.join(", ")}]`);
if (Array.isArray(data.filters.nud) && data.filters.nud.length)
parts.push(`nud=[${data.filters.nud.join(", ")}]`);
if (parts.length) {
const info = document.createElement("div");
info.className = "filters";
info.textContent = `Filters: ${parts.join("; ")}`;
elHtml.appendChild(info);
}
}
elHtml.appendChild(tbl);
if ((data.table || []).length > 0) {
@@ -79,13 +106,21 @@ function renderStatus(data) {
async function fetchStatus(name) {
setPill("warn", "checking…");
elLog.textContent = "GET /api/status?name=" + name;
const u = buildStatusUrl(name);
elLog.textContent = "GET " + u.pathname + u.search;
elHtml.innerHTML = "";
try {
const r = await fetch(`/api/status?name=${encodeURIComponent(name)}`);
const r = await fetch(u);
if (!r.ok) {
const err = await r.json().catch((_) => (elHtml.textContent = r.text));
elLog.textContent = `status error: ${err.error || r.status}`;
let msg = String(r.status);
try {
const err = await r.clone().json();
msg = err.error || JSON.stringify(err);
} catch {
msg = await r.text();
}
elLog.textContent = `status error: ${msg}`;
// elHtml.textContent = msg;
setPill("bad", "error");
return;
}
@@ -107,8 +142,8 @@ async function sendWake(name) {
});
const t = await r.text();
elLog.textContent = t || "ok";
// Optional: recheck after small delay
setTimeout(() => fetchStatus(name), 600);
// Optional: recheck after medium delay
setTimeout(() => fetchStatus(name), 1500);
} catch (e) {
elLog.textContent = "wake error: " + e;
setPill("bad", "error");
@@ -137,7 +172,7 @@ elName.addEventListener("keydown", (e) => {
const initial = loadName();
if (initial) {
elName.value = initial;
// setLink(initial); // when the permalink doing YOUR job
setLink(initial); // when the permalink doing YOUR job
// auto-check on load in this A/B page
fetchStatus(initial);
} else {
+2 -1
View File
@@ -26,7 +26,8 @@
</div>
<div class="row muted" style="gap: 16px">
<span
>Uses query (?name=...) to view its status.<!-- and header (X-Target-Name) so either extractor
>Uses query <span title="available keys: name, ip, dev, nud">(?name=...)</span> on this page to view the
status.<!-- and header (X-Target-Name) so either extractor
path works. --></span
>
<a id="permalink" href="#">permalink</a>