more bullshit IN!

This commit is contained in:
lda
2025-08-26 21:47:52 +07:00 Unverified
parent 7371d2ae8c
commit 9de286f59b
22 changed files with 845 additions and 122 deletions
+4
View File
@@ -21,3 +21,7 @@ urlencoding = "2.1.3"
[profile.release]
opt-level = "z"
strip = true
[features]
default = ["very-smart-parsing"]
very-smart-parsing = [] # this is the a-bit-redundant parse thing that copilot made
+3
View File
@@ -37,6 +37,9 @@ WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/upd
## Scripts overview
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
- `cross_build.ps1` — Simple wrapper for `cargo build` per target (default: armv7-unknown-linux-musleabihf).
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
- `package.ps1` — Dev bundle with binaries + init scripts (not a rootfs layout).
+157
View File
@@ -0,0 +1,157 @@
[System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidDefaultValueSwitchParameter', 'Default true is intentional for fast dev loop')]
param(
[string]$Pass,
[string]$HostName = "192.168.100.1",
[string]$User = "root",
[string]$RemotePath = "/root/.bin/wakey",
[string]$Target = "armv7-unknown-linux-musleabihf",
[string]$BinName = "wakey",
[string]$HostKey,
[switch]$Restart = $true,
[switch]$Quiet = $true
)
$ErrorActionPreference = 'Stop'
try { $PSStyle.OutputRendering = 'Host' } catch {}
function Invoke-Ext {
param(
[Parameter(Mandatory = $true)][string]$Exe,
[Parameter(Mandatory = $true)][string[]]$Args,
[Parameter(Mandatory = $true)][string]$Label
)
$displayArgs = @($Args)
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) { $displayArgs[$i + 1] = '****' } # lowkey leaked my password
}
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
$out = & $Exe @Args 2>&1
$code = $LASTEXITCODE
if ($code -ne 0) {
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $code, ($out -join "`n"))
throw ("{0} failed ({1})" -f $Label, $code)
}
return $out
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
cargo build --release --target $Target
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
$local = Join-Path $repoRoot ("target/" + $Target + "/release/" + $BinName)
if (-not (Test-Path $local)) { throw "binary not found: $local" }
$remoteTmp = "$RemotePath.tmp"
$destTmp = "{0}@{1}:{2}" -f $User, $HostName, $remoteTmp
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$destDeployTmp = "{0}@{1}:{2}" -f $User, $HostName, $deployTmp
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
if ($Pass) {
$pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue
if ($pscp) {
$pscpArgs = @()
if ($Quiet) { $pscpArgs += '-q' }
if ($HostKey) { $pscpArgs += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $local, $destTmp) }
else { $pscpArgs += @('-scp', '-pw', $Pass, $local, $destTmp) }
Invoke-Ext -Exe $pscp.Path -Args $pscpArgs -Label 'push'
$plink = Get-Command plink.exe -ErrorAction SilentlyContinue
if (Test-Path $localDeploy) {
$pscpArgsD = @()
if ($Quiet) { $pscpArgsD += '-q' }
if ($HostKey) { $pscpArgsD += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $localDeploy, $destDeployTmp) }
else { $pscpArgsD += @('-scp', '-pw', $Pass, $localDeploy, $destDeployTmp) }
Invoke-Ext -Exe $pscp.Path -Args $pscpArgsD -Label 'push-deploy'
}
$remoteCmdCore = @'
DEPLOY=$DEPLOY_PREFERRED
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
fi
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
'@
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
$remoteCmd = "sh -lc '$remoteCmdCore'"
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
if ($plink) {
$plinkArgs = @('-batch', '-ssh', '-pw', $Pass, "$User@$HostName", $remoteCmd)
Invoke-Ext -Exe $plink.Path -Args $plinkArgs -Label 'ssh'
}
else {
$sshArgs = @()
if ($Quiet) { $sshArgs += '-q' }
$sshArgs += @("$User@$HostName", $remoteCmd)
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
}
}
else {
Write-Warning "pscp.exe not found. Falling back to scp (you may be prompted for a password)."
$scpArgs = @('-O')
if ($Quiet) { $scpArgs += '-q' }
$scpArgs += @($local, $destTmp)
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
if (Test-Path $localDeploy) {
$scpArgsD = @('-O')
if ($Quiet) { $scpArgsD += '-q' }
$scpArgsD += @($localDeploy, $destDeployTmp)
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
}
$remoteCmdCore = @'
DEPLOY=$DEPLOY_PREFERRED
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
fi
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
'@
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
$remoteCmd = "sh -lc '$remoteCmdCore'"
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
$sshArgs = @()
if ($Quiet) { $sshArgs += '-q' }
$sshArgs += @("$User@$HostName", $remoteCmd)
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
}
}
else {
$scpArgs = @('-O')
if ($Quiet) { $scpArgs += '-q' }
$scpArgs += @($local, $destTmp)
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
if (Test-Path $localDeploy) {
$scpArgsD = @('-O')
if ($Quiet) { $scpArgsD += '-q' }
$scpArgsD += @($localDeploy, $destDeployTmp)
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
}
$remoteCmdCore = @'
DEPLOY=$DEPLOY_PREFERRED
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true; chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP; fi
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
'@
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
$remoteCmd = "sh -lc '$remoteCmdCore'"
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
$sshArgs = @()
if ($Quiet) { $sshArgs += '-q' }
$sshArgs += @("$User@$HostName", $remoteCmd)
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
}
Write-Host "done ✔" -ForegroundColor Green
}
finally {
Pop-Location
}
+6
View File
@@ -15,3 +15,9 @@ start_service() {
stop_service() {
: # one-shot
}
# Test manually:
# chmod +x /etc/init.d/update_wakey
# /etc/init.d/update_wakey start
# Or enable on boot:
# /etc/init.d/update_wakey enable
+1 -1
View File
@@ -15,7 +15,7 @@ fi
kill -TERM "$pids" 2>/dev/null || true
# Optional: hard kill if still alive after a short grace
sleep 1 # uhhh sleep is stupid
usleep 250000 # uhhh sleep is stupid
remain=""
for p in $pids; do
kill -0 "$p" 2>/dev/null && remain="$remain $p"
+26 -4
View File
@@ -16,7 +16,9 @@
set -eu
ARCH=${WAKEY_ARCH:-armv7-unknown-linux-musleabihf}
TMPFILE="/tmp/wakey-rootfs.$$.$ARCH.tgz"
TMPDIR="/var/tmp"
TMPFILE="$TMPDIR/wakey-rootfs.$$.$ARCH.tgz"
STAGING="$TMPDIR/wakey-rootfs.$$.$ARCH"
log() { echo "[update_wakey] $*"; }
fail() { echo "[update_wakey] ERROR: $*" >&2; exit 1; }
@@ -90,12 +92,32 @@ main() {
fi
log "installing"
tar -xz -f "$TMPFILE" -C / || fail "extract failed"
mkdir -p "$TMPDIR" "$STAGING"
tar -xz -f "$TMPFILE" -C "$STAGING" || fail "extract failed"
# Ensure execute bits on staged files we know should be executable
for f in \
"$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/wakey" \
"$STAGING/root/.bin/kill_wakey.sh"; do
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
done
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
for f in \
"$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh; do
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
done
# Copy staged tree into /
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
rm -f "$TMPFILE"
rm -rf "$STAGING"
if [ -f /etc/init.d/wakey ]; then
chmod +x /etc/init.d/wakey || true
chmod +x /etc/init.d/kill_wakey.sh || true
/etc/init.d/wakey enable || true
/etc/init.d/wakey restart || /etc/init.d/wakey start || true
fi
+37 -6
View File
@@ -6,7 +6,8 @@
param(
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$Target,
[string]$OutDir = "dist"
[string]$OutDir = "dist",
[switch]$NoBin
)
$ErrorActionPreference = 'Stop'
@@ -17,7 +18,6 @@ New-Item -ItemType Directory -Force -Path $dist | Out-Null
$binName = if ($Target -like "*-windows-*") { "wakey.exe" } else { "wakey" }
$binSrc = Join-Path $root "target/$Target/release/$binName"
if (-not (Test-Path $binSrc)) { throw "Missing binary: $binSrc (build it first)" }
$staging = Join-Path $dist ("rootfs-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $staging | Out-Null
@@ -28,13 +28,37 @@ $etcDir = Join-Path $staging "etc/init.d"
New-Item -ItemType Directory -Force -Path $rootDir | Out-Null
New-Item -ItemType Directory -Force -Path $etcDir | Out-Null
Copy-Item $binSrc (Join-Path $rootDir "wakey") -Force
Copy-Item (Join-Path $root 'scripts/kill.sh') (Join-Path $rootDir "kill_wakey.sh") -Force
if (-not $NoBin) {
if (Test-Path $binSrc) {
Copy-Item $binSrc (Join-Path $rootDir "wakey") -Force
}
else {
throw "Missing binary: $binSrc (build it first or pass -NoBin)"
}
}
# Normalize kill script line endings and copy
$killSrc = Join-Path $root 'scripts/kill_wakey.sh'
if (Test-Path $killSrc) {
$killContent = Get-Content -Raw -LiteralPath $killSrc
$killContent = $killContent -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "kill_wakey.sh") -Value $killContent -Encoding UTF8
}
# Normalize remote deploy script and copy (optional helper)
$deploySrc = Join-Path $root 'scripts/remote_deploy_wakey.sh'
if (Test-Path $deploySrc) {
$deployContent = Get-Content -Raw -LiteralPath $deploySrc
$deployContent = $deployContent -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "remote_deploy_wakey.sh") -Value $deployContent -Encoding UTF8
}
# Copy all OpenWrt init scripts present in repo
Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
$dest = Join-Path $etcDir $_.Name
Copy-Item $_.FullName $dest -Force
$content = Get-Content -Raw -LiteralPath $_.FullName
# normalize to LF line endings
$content = $content -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
}
# Copy helper scripts intended for /etc/ldlda_help
@@ -43,7 +67,10 @@ if (Test-Path $helpSrc) {
$etcHelpDir = Join-Path $staging 'etc/ldlda_help'
New-Item -ItemType Directory -Force -Path $etcHelpDir | Out-Null
Get-ChildItem $helpSrc -File | ForEach-Object {
Copy-Item $_.FullName (Join-Path $etcHelpDir $_.Name) -Force
$d = Join-Path $etcHelpDir $_.Name
$c = Get-Content -Raw -LiteralPath $_.FullName
$c = $c -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $d -Value $c -Encoding UTF8
}
}
@@ -65,5 +92,9 @@ Remove-Item -Recurse -Force $staging
Write-Host "Rootfs package: " (Join-Path $dist $pkgName)
Write-Host "On router: wget -O- <URL/$pkgName> | tar -xz -C /"
Write-Host "Then: chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey start"
if ($NoBin) {
Write-Host "Note: -NoBin used, binary not included. Only scripts/configs were packaged." -ForegroundColor Yellow
}
Write-Host "Helper scripts: /etc/ldlda_help/* (e.g., update_wakey.sh, update_tailscale.sh). Mark executable if needed: chmod +x /etc/ldlda_help/*.sh"
Write-Host "Kill helper: /root/.bin/kill_wakey.sh (make it executable: chmod +x /root/.bin/kill_wakey.sh)"
<# if (Test-Path $deploySrc) { #> Write-Host "Deploy helper: /root/.bin/remote_deploy_wakey.sh (chmod +x /root/.bin/remote_deploy_wakey.sh)" # }
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
# Usage: remote_deploy_wakey.sh <bin_tmp> <dest_path> [restart_flag]
# restart_flag: 1 (default) to stop/start, 0 to only replace file.
set -e
BIN_TMP="$1"
DEST="$2"
RESTART="${3:-1}"
INIT="/etc/init.d/wakey"
KILL="/root/.bin/kill_wakey.sh"
if [ -z "$BIN_TMP" ] || [ -z "$DEST" ]; then
echo "usage: $0 <bin_tmp> <dest_path> [restart_flag]" >&2
exit 2
fi
[ -f "$BIN_TMP" ] || { echo "tmp binary not found: $BIN_TMP" >&2; exit 1; }
chmod +x "$BIN_TMP" || true
if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then
"$INIT" stop || true
elif [ -x "$KILL" ]; then
sh "$KILL" || true
fi
fi
mv -f "$BIN_TMP" "$DEST"
if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then
"$INIT" start || "$INIT" restart || true
else
nohup "$DEST" >/dev/null 2>&1 &
fi
fi
exit 0
+1 -17
View File
@@ -10,8 +10,8 @@
use std::{net::IpAddr, str::FromStr};
use r#impl::ser_opm;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize, Serializer, de};
use serde_with::skip_serializing_none;
use strum::{Display, EnumString};
@@ -39,22 +39,6 @@ pub struct IpNeighLine {
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(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)]
+20 -1
View File
@@ -1,4 +1,7 @@
use serde::{Deserialize, Deserializer, de};
//! r#impl AHHHHHH
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use super::NUDState;
@@ -12,3 +15,19 @@ impl<'de> Deserialize<'de> for NUDState {
s.parse().map_err(de::Error::custom)
}
}
/// 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(str::parse)
.transpose()
.map_err(de::Error::custom)
}
+3
View File
@@ -0,0 +1,3 @@
pub const HOME_2: &str = include_str!("../static/home_2.html");
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");
+42
View File
@@ -1,3 +1,45 @@
/// MAC->name cache location (ephemeral)
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
/// Load MAC->name cache from disk
async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
Err(e) => Err(e),
}
}
/// Save MAC->name cache to disk
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
let s = serde_json::to_string(map).map_err(io::Error::other)?;
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
Ok(())
}
/// Read all leases, filling names from MAC->name cache if missing
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
let leases = read_dhcp_leases().await?;
let mut cache = load_mac_name_cache().await.unwrap_or_default();
let mut changed = false;
let mut leases_with_names = Vec::with_capacity(leases.len());
for mut l in leases {
let mac_s = l.mac.to_string();
if let Some(ref name) = l.name {
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
cache.insert(mac_s, name.clone());
changed = true;
}
} else if let Some(prev) = cache.get(&mac_s) {
l.name = Some(prev.clone());
}
leases_with_names.push(l);
}
if changed {
let _ = save_mac_name_cache(&cache).await;
}
Ok(leases_with_names)
}
use macaddr::MacAddr;
use serde::Serializer;
use std::io::{self, ErrorKind};
+1 -1
View File
@@ -4,9 +4,9 @@ use axum::{
};
use tokio::net::TcpListener;
mod arpparse;
pub mod assets;
mod dhcpparse;
mod route;
pub mod r#static;
mod utils;
use std::io;
+4 -4
View File
@@ -2,7 +2,7 @@ pub mod api;
pub use crate::route::api::{DeviceQuery, api_router};
use crate::{
r#static as st,
assets,
utils::{ping::_ping_ip, wake::wake},
};
@@ -17,7 +17,7 @@ use axum_extra::extract::Query;
use crate::{MACHINE_NAME, utils::_status_build};
pub async fn home_2() -> Html<&'static str> {
Html(st::HOME_2)
Html(assets::HOME_2)
}
async fn home_2_css() -> impl IntoResponse {
(
@@ -25,7 +25,7 @@ async fn home_2_css() -> impl IntoResponse {
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
st::HOME_2_CSS,
assets::HOME_2_CSS,
)
}
async fn home_2_js() -> impl IntoResponse {
@@ -34,7 +34,7 @@ async fn home_2_js() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
st::HOME_2_JS,
assets::HOME_2_JS,
)
}
+49 -15
View File
@@ -1,4 +1,4 @@
use crate::utils::de_many;
use crate::utils::parse::{boolish_str, de_many, serialize_macs};
use std::collections::HashSet;
use std::net::IpAddr;
@@ -11,7 +11,6 @@ use axum::{
};
use axum_extra::extract::Query;
use macaddr::MacAddr;
use serde::ser::Serializer;
use serde_with::skip_serializing_none;
use crate::{
@@ -25,9 +24,18 @@ use crate::{
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
pub async fn status_smart_redirect(Path(q): Path<String>) -> Redirect {
let s = q.trim();
let s = if cfg!(feature = "very-smart-parsing") {
crate::utils::parse::extract_host(&q)
} else {
q.trim()
};
// 1) IP
if let Ok(ip) = s.parse::<IpAddr>() {
let ip = if cfg!(feature = "very-smart-parsing") {
crate::utils::parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok())
} else {
s.parse::<IpAddr>().ok()
};
if let Some(ip) = ip {
return Redirect::to(&format!("/api/status?ip={ip}"));
}
// 2) MAC
@@ -46,7 +54,7 @@ pub async fn status_smart_redirect(Path(q): Path<String>) -> Redirect {
if tokio::net::lookup_host((s, 0)).await.is_ok() {
return Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)));
}
// Default: name last
// Default: name last // it will fail also
Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)))
}
@@ -54,9 +62,42 @@ pub async fn devs_router() -> Json<Vec<String>> {
dev::devs_sorted().into()
}
async fn get_dhcp_leases() -> impl IntoResponse {
match dhcpparse::read_dhcp_leases().await {
Ok(leases) => (StatusCode::OK, Json(leases)).into_response(),
#[derive(Debug, Default, Clone, serde::Deserialize)]
struct DhcpLeasesQueryRaw {
include_state: Option<String>,
}
// #[skip_serializing_none]
// #[derive(Debug, Clone, serde::Serialize)]
// struct DhcpLeaseOut {
// expires_epoch: u64,
// ip: IpAddr,
// #[serde(serialize_with = "serialize_mac")]
// mac: macaddr::MacAddr,
// // #[serde(skip_serializing_if = "Option::is_none")]
// name: Option<String>,
// // extras
// // #[serde(skip_serializing_if = "Option::is_none")]
// nud_state: Option<NUDState>,
// // #[serde(skip_serializing_if = "Option::is_none")]
// rank: Option<u8>,
// }
async fn get_dhcp_leases(Query(raw): Query<DhcpLeasesQueryRaw>) -> impl IntoResponse {
let include_state = raw
.include_state
.as_deref()
.map(boolish_str)
.unwrap_or(false);
match dhcpparse::read_dhcp_leases_with_names().await {
Ok(leases_with_names) => {
if !include_state {
return (StatusCode::OK, Json(leases_with_names)).into_response();
}
let out = crate::utils::query::enrich_leases_with_nud_state(leases_with_names).await;
(StatusCode::OK, Json(out)).into_response()
}
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(StatusError {
@@ -110,13 +151,6 @@ pub struct Filters {
mac: Vec<MacAddr>,
}
fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
#[skip_serializing_none]
#[derive(Debug, serde::Serialize)]
pub struct StatusError {
+1 -52
View File
@@ -8,71 +8,20 @@
/// is it time to lookup host lda.lan for this...
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
pub mod wake;
use std::net::IpAddr;
use crate::utils::query::_get_macs_2_1;
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) -> 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)
}
}
pub(crate) mod parse;
pub async fn _status_build(machine_name: &str) -> String {
let formatted_macs = match _get_macs_2_1(machine_name).await {
+155
View File
@@ -0,0 +1,155 @@
/// Parses Chrome-style numeric IPv4 forms: hex (0x...), decimal, octal.
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
let s = s.trim();
// hex
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
&& hex.chars().all(|c| c.is_ascii_hexdigit())
&& let Ok(n) = u32::from_str_radix(hex, 16)
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
// decimal
if s.chars().all(|c| c.is_ascii_digit())
&& let Ok(n) = s.parse::<u32>()
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
// octal (leading 0, all octal digits)
if s.len() > 1
&& s.as_bytes()[0] == b'0'
&& s.chars().all(|c| matches!(c, '0'..='7'))
&& let Ok(n) = u32::from_str_radix(s, 8)
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
None
}
/// Extracts the host portion from a URL-like string, for smart input parsing.
pub fn extract_host(input: &str) -> &str {
let mut s = input.trim();
// Strip scheme (e.g., http://, https://, ssh://) or network-path reference (//host)
if let Some(idx) = s.find("://") {
s = &s[idx + 3..];
} else if let Some(rest) = s.strip_prefix("//") {
s = rest;
}
// Strip potential userinfo (user@host)
if let Some((_, host)) = s.rsplit_once('@') {
s = host;
}
// If bracketed IPv6 like [::1]:8080/path -> extract inside brackets
if let Some(host) = s.strip_prefix('[') {
if let Some(end) = host.find(']') {
s = &host[..end];
}
} else {
// Trim path suffix if any
if let Some(pos) = s.find('/') {
s = &s[..pos];
}
// Drop trailing :port if present and numeric, but only if there's exactly one ':'
if let Some((host, port)) = s.rsplit_once(':')
&& s.matches(':').count() == 1
&& port.chars().all(|c| c.is_ascii_digit())
{
s = host;
}
}
s.trim()
}
use macaddr::MacAddr;
use serde::Serializer;
/// key for yes: "1" | "true" | "yes" | "on" | "y"
///
/// frfr
pub fn _de_boolish<'de, D>(des: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum Boolish {
B(bool),
I(u8),
S(String),
}
Ok(match Boolish::deserialize(des)? {
Boolish::B(b) => b,
Boolish::I(i) => i != 0,
Boolish::S(s) => {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
true // presence implies true
} else {
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
}
}
})
}
/// Parse a tolerant boolean value from a string.
/// Accepts: "1", "true", "yes", "on", "y" as true; "0", "false", "no", "off", "n" as false.
/// Empty string means true (presence-only query flag).
pub fn boolish_str(s: &str) -> bool {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
return true;
}
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
}
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
pub fn serialize_mac<S: serde::Serializer>(m: &macaddr::MacAddr, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&m.to_string())
}
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)
}
}
+15 -1
View File
@@ -1,12 +1,14 @@
// this ENTIRE file is redundant... or?
use std::time::Duration;
use std::{net::IpAddr, time::Duration};
use tokio::{
net::{TcpStream, ToSocketAddrs},
time::timeout,
};
use crate::{arpparse::NUDState, utils::query::get_macs};
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
timeout(Duration::from_secs(1), TcpStream::connect(addr))
.await
@@ -15,3 +17,15 @@ pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
todo!("use icmp")
}
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
match get_macs(None, Some(&[addr.into()]), None, None).await {
Err(_) => 0,
Ok(l) => l
.into_iter()
.map(|e| e.state)
.max()
.map(NUDState::rank)
.unwrap_or_default(),
}
}
+66 -9
View File
@@ -1,17 +1,76 @@
use std::{collections::HashSet, net::IpAddr};
use crate::arpparse::NUDState;
use crate::dhcpparse::DhcpLeaseLine;
use crate::utils::parse::serialize_mac;
use std::net::IpAddr;
#[skip_serializing_none]
#[derive(Debug, Clone, serde::Serialize)]
pub struct DhcpLeaseOut {
pub expires_epoch: u64,
pub ip: IpAddr,
#[serde(serialize_with = "serialize_mac")]
pub mac: macaddr::MacAddr,
pub name: Option<String>,
pub nud_state: Option<NUDState>,
pub rank: Option<u8>,
}
/// Enrich DHCP leases with NUD state and rank using get_macs
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLeaseLine>) -> Vec<DhcpLeaseOut> {
use crate::utils::query::get_macs;
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
let mut map: std::collections::HashMap<IpAddr, (NUDState, u8)> =
std::collections::HashMap::new();
if let Ok(rows) = get_macs(None, Some(&ips), None, None).await {
for row in rows {
let state = row.state;
let r = state.rank();
map.entry(row.ip)
.and_modify(|e| {
if r > e.1 {
*e = (state, r)
}
})
.or_insert((state, r));
}
}
leases
.into_iter()
.map(|l| DhcpLeaseOut {
expires_epoch: l.expires_epoch,
ip: l.ip,
mac: l.mac,
name: l.name,
nud_state: map.get(&l.ip).map(|(s, _)| *s),
rank: map.get(&l.ip).map(|(_, r)| *r),
})
.collect()
}
use std::collections::HashSet;
use macaddr::MacAddr;
use serde_with::skip_serializing_none;
// use tokio::io;
use crate::{
arpparse::{self, IpNeighLine, NUDState},
arpparse::{self, IpNeighLine},
utils::{
cmd::exec_command,
error::{self, Error, Result},
get_ips,
},
};
/// this is because i like [`IpAddr`] more than [`SocketAddr`](std::net::SocketAddr)
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 async fn _get_macs_2_1(machine_name: &str) -> Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
Ok(get_macs_1(machine_name)
.await?
@@ -92,7 +151,7 @@ pub async fn get_macs(
// 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()));
// let nud_arg = 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()];
@@ -141,7 +200,7 @@ pub async fn get_macs(
}
pub mod dev {
use std::{collections::HashSet, sync::LazyLock};
use std::collections::HashSet;
pub fn get_dev() -> HashSet<String> {
// Prefer /sys/class/net, fallback to /proc/net/dev; filter out loopback
@@ -169,15 +228,13 @@ pub mod dev {
devs
}
pub static DEVS: LazyLock<HashSet<String>> = LazyLock::new(get_dev);
pub fn devs_sorted() -> Vec<String> {
let mut v: Vec<String> = DEVS.iter().cloned().collect();
let mut v: Vec<String> = get_dev().into_iter().collect();
v.sort();
v
}
pub fn has_dev(name: &str) -> bool {
DEVS.contains(name)
get_dev().contains(name)
}
}
+43
View File
@@ -111,6 +111,13 @@ pre {
a {
color: #93c5fd;
}
.table a.pick {
color: #93c5fd;
text-decoration: none;
}
.table a.pick:hover {
text-decoration: underline;
}
#html {
overflow-y: auto;
@@ -126,3 +133,39 @@ a {
color: var(--muted);
font-size: 12px;
}
/* simple table styling */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 6px 8px;
border-bottom: 1px solid #2a2a2a;
font-size: 12px;
}
.table th {
text-align: left;
color: var(--muted);
font-weight: 600;
}
.tiny {
font-size: 11px;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.ok {
background: var(--ok);
}
.dot.bad {
background: var(--bad);
}
.dot.warn {
background: var(--warn);
}
+158 -4
View File
@@ -5,8 +5,21 @@ const elCheck = $("check");
const elWake = $("wake");
const elLog = $("log");
const elHtml = $("html");
const elLeases = $("leases_html");
const pill = $("status-pill");
const link = $("permalink");
const elPreview = $("preview");
// One-time delegated click handler for all pickable links
function handlePickClick(e) {
const a = e.target && /** @type {HTMLElement} */ (e.target).closest("a.pick");
if (!a) return;
e.preventDefault();
const v = a.getAttribute("data-value");
pickTarget(v);
}
if (elHtml) elHtml.addEventListener("click", handlePickClick);
if (elLeases) elLeases.addEventListener("click", handlePickClick);
function setPill(kind, text) {
pill.className = `pill ${kind}`;
@@ -22,6 +35,46 @@ function setLink(name) {
function getName() {
return (elName.value || "").trim();
}
function extractHostLikeBackend(input) {
let s = String(input || "").trim();
if (!s) return "";
// strip scheme or network-path reference
const schemeIdx = s.indexOf("://");
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
else if (s.startsWith("//")) s = s.slice(2);
// strip userinfo
const at = s.lastIndexOf("@");
if (at >= 0) s = s.slice(at + 1);
// bracketed IPv6
if (s.startsWith("[")) {
const end = s.indexOf("]");
if (end > 1) s = s.slice(1, end);
} else {
const slash = s.indexOf("/");
if (slash >= 0) s = s.slice(0, slash);
const colon = s.lastIndexOf(":");
if (colon > 0 && (s.match(/:/g) || []).length === 1) {
const port = s.slice(colon + 1);
if (/^\d+$/.test(port)) s = s.slice(0, colon);
}
}
return s.trim();
}
function updatePreview() {
if (!elPreview) return;
const raw = getName();
const host = extractHostLikeBackend(raw);
elPreview.textContent = host && host !== raw ? `${host}` : "";
}
function pickTarget(value) {
const v = String(value || "").trim();
if (!v) return;
elName.value = v;
updatePreview();
saveName(v);
setLink(v);
fetchStatus(v);
}
function saveName(name) {
try {
localStorage.setItem("wakey:name", name);
@@ -51,9 +104,15 @@ function rankState(s) {
}
function buildStatusUrl(name) {
// If no extra filters, use smart redirect for name/ip/mac/dev/nud detection
const hasExtraFilters = ["ip", "dev", "nud", "mac"].some(
(k) => qs.getAll(k).length
);
if (name && !hasExtraFilters) {
return new URL(`/api/smart/${encodeURIComponent(name)}`, location.origin);
}
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", "mac"]) {
const vals = qs.getAll(k);
for (const v of vals) u.searchParams.append(k, v);
@@ -64,12 +123,35 @@ function buildStatusUrl(name) {
/** @param {{ name?: String, table: Array<{ ip, dev, mac, state }>, filters: {ip, dev, nud, mac}} | { name?: String, error }} data from /api/status */
function renderStatus(data) {
const tbl = document.createElement("table");
tbl.className = "table";
tbl.innerHTML = `<tr><th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr>`;
for (const row of data.table || []) {
const tr = document.createElement("tr");
const mac = row.mac ?? "";
const dev = row.dev ?? "";
tr.innerHTML = `<td>${row.ip}</td><td>${mac}</td><td>${row.state}</td><td>${dev}</td>`;
const ip = row.ip || "";
const mac = row.mac || "";
const dev = row.dev || "";
const state = row.state || "";
tr.innerHTML = `
<td>${
ip
? `<a href="#" class="pick" data-value="${ip}" title="filter by ip">${ip}</a>`
: ""
}</td>
<td>${
mac
? `<a href="#" class="pick" data-value="${mac}" title="filter by mac">${mac}</a>`
: ""
}</td>
<td>${
state
? `<a href="#" class="pick" data-value="${state}" title="filter by state">${state}</a>`
: ""
}</td>
<td>${
dev
? `<a href="#" class="pick" data-value="${dev}" title="filter by interface">${dev}</a>`
: ""
}</td>`;
tbl.appendChild(tr);
}
elHtml.innerHTML = "";
@@ -93,6 +175,8 @@ function renderStatus(data) {
}
elHtml.appendChild(tbl);
// click-to-filter handled by a single, persistent delegated listener (set once at load)
if ((data.table || []).length > 0) {
const best = data.table.reduce((a, b) =>
rankState(b.state) > rankState(a.state) ? b : a
@@ -134,6 +218,72 @@ async function fetchStatus(name) {
}
}
function renderLeases(leases) {
if (!elLeases) return;
if (!Array.isArray(leases) || leases.length === 0) {
elLeases.textContent = "no leases";
return;
}
const tbl = document.createElement("table");
tbl.className = "table";
tbl.innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
const nowSec = Math.floor(Date.now() / 1000);
for (const l of leases) {
const tr = document.createElement("tr");
const exp = Number(l.expires_epoch || 0);
const expired = exp > 0 && exp <= nowSec;
const when = exp > 0 ? new Date(exp * 1000) : null;
const whenText = when ? when.toLocaleString() : "";
let dotClass = "dot ok";
if (expired) {
dotClass = "dot bad";
} else if (typeof l?.rank === "number") {
if (l.rank >= 5) dotClass = "dot ok";
else if (l.rank >= 2) dotClass = "dot warn";
else dotClass = "dot bad";
}
const title = expired
? "expired"
: l?.nud_state
? String(l.nud_state).toLowerCase()
: "unknown";
const ip = l.ip || "";
const mac = l.mac || "";
const name = l.name || "";
tr.innerHTML = `
<td><span class="${dotClass}" title="${title}"></span></td>
<td>${
ip
? `<a href="#" class="pick" data-value="${ip}" title="filter by ip">${ip}</a>`
: ""
}</td>
<td>${
mac
? `<a href="#" class="pick" data-value="${mac}" title="filter by mac">${mac}</a>`
: ""
}</td>
<td>${
name
? `<a href="#" class="pick" data-value="${name}" title="filter by name">${name}</a>`
: ""
}</td>
<td><span class="tiny">${whenText}</span></td>`;
tbl.appendChild(tr);
}
elLeases.innerHTML = "";
elLeases.appendChild(tbl);
// click-to-filter handled by a single, persistent delegated listener (set once at load)
}
async function fetchLeases() {
try {
const r = await fetch("/api/dhcp_leases?include_state=1");
if (!r.ok) return;
const leases = await r.json();
renderLeases(leases);
} catch {}
}
async function sendWake(name) {
setPill("warn", "waking…");
elLog.textContent = "POST /wake?name=" + name;
@@ -169,14 +319,18 @@ elWake.addEventListener("click", () => {
elName.addEventListener("keydown", (e) => {
if (e.key === "Enter") elCheck.click();
});
elName.addEventListener("input", updatePreview);
// init
const initial = loadName();
if (initial) {
elName.value = initial;
updatePreview();
setLink(initial); // when the permalink doing YOUR job
// auto-check on load in this A/B page
fetchStatus(initial);
fetchLeases();
} else {
setPill("warn", "unknown");
fetchLeases();
}
+15 -7
View File
@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>wakey • home_2</title>
<title>wakey • home</title>
<link rel="stylesheet" href="/home_2.css" />
<script type="module" src="/home_2.js" defer></script>
</head>
<body>
<div class="wrap">
<header>
<h1>home_2 <span class="muted">A/B test page</span></h1>
<h1>home <span class="muted">ping your device with a WoL</span></h1>
<span id="status-pill" class="pill warn">unknown</span>
</header>
@@ -18,7 +18,7 @@
<input
id="name"
type="text"
placeholder="target name e.g. lda.lan"
placeholder="target (name, ip, mac...)"
spellcheck="false"
/>
<button id="check" class="secondary">Check</button>
@@ -26,17 +26,25 @@
</div>
<div class="row muted" style="gap: 16px">
<span
>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
>Uses query
<span title="available keys: name, ip, mac, 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>
><span id="preview" class="tiny"></span
><a id="permalink" href="#">permalink</a>
</div>
<section id="out" class="card">
<pre id="log">ready.</pre>
<div id="html"></div>
</section>
<section id="leases" class="card">
<h3 style="margin-top: 0">DHCP leases</h3>
<div id="leases_html"></div>
</section>
</div>
</body>
</html>