move things around do some BullShit
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
export const qs = new URLSearchParams(location.search);
|
||||
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
export const elName = $("name");
|
||||
export const elCheck = $("check");
|
||||
export const elWake = $("wake");
|
||||
export const elLog = $("log");
|
||||
export const elHtml = $("html");
|
||||
export const elLeases = $("leases_html");
|
||||
export const pill = $("status-pill");
|
||||
export const link = $("permalink");
|
||||
export const elPreview = $("preview");
|
||||
|
||||
export function setPill(kind, text) {
|
||||
pill.className = `pill ${kind}`;
|
||||
pill.textContent = text;
|
||||
}
|
||||
|
||||
export function setLink(name) {
|
||||
const url = new URL(location.href);
|
||||
if (name) url.searchParams.set("name", name);
|
||||
else url.searchParams.delete("name");
|
||||
history.replaceState(null, "", url);
|
||||
link.href = url.toString();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { elLeases } from "./dom.js";
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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 {}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
elName,
|
||||
elCheck,
|
||||
elWake,
|
||||
elHtml,
|
||||
elLeases,
|
||||
elPreview,
|
||||
setLink,
|
||||
setPill,
|
||||
qs,
|
||||
} from "./dom.js";
|
||||
import { getName, saveName, loadName, extractHostLikeBackend } from "./utils.js";
|
||||
import { fetchStatus } from "./status.js";
|
||||
import { fetchLeases } from "./leases.js";
|
||||
import { sendWake } from "./wake.js";
|
||||
|
||||
// delegated click handler
|
||||
function handlePickClick(e) {
|
||||
const a = e.target && 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 updatePreview() {
|
||||
const raw = getName(elName);
|
||||
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);
|
||||
}
|
||||
|
||||
// events
|
||||
elCheck.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
fetchStatus(name);
|
||||
});
|
||||
elWake.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
sendWake(name);
|
||||
});
|
||||
elName.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") elCheck.click();
|
||||
});
|
||||
elName.addEventListener("input", updatePreview);
|
||||
|
||||
// init
|
||||
const initial = loadName(qs);
|
||||
if (initial) {
|
||||
elName.value = initial;
|
||||
updatePreview();
|
||||
setLink(initial);
|
||||
fetchStatus(initial);
|
||||
fetchLeases();
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
fetchLeases();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { elHtml, elLog, setPill, qs } from "./dom.js";
|
||||
import { rankState } from "./utils.js";
|
||||
|
||||
function buildStatusUrl(name) {
|
||||
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);
|
||||
for (const k of ["ip", "dev", "nud", "mac"]) {
|
||||
const vals = qs.getAll(k);
|
||||
for (const v of vals) u.searchParams.append(k, v);
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
export 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");
|
||||
tr.innerHTML = `
|
||||
<td>${
|
||||
row.ip
|
||||
? `<a href="#" class="pick" data-value="${row.ip}" title="filter by ip">${row.ip}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
row.mac
|
||||
? `<a href="#" class="pick" data-value="${row.mac}" title="filter by mac">${row.mac}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
row.state
|
||||
? `<a href="#" class="pick" data-value="${row.state}" title="filter by state">${row.state}</a>`
|
||||
: ""
|
||||
}</td>
|
||||
<td>${
|
||||
row.dev
|
||||
? `<a href="#" class="pick" data-value="${row.dev}" title="filter by interface">${row.dev}</a>`
|
||||
: ""
|
||||
}</td>`;
|
||||
tbl.appendChild(tr);
|
||||
}
|
||||
|
||||
elHtml.innerHTML = "";
|
||||
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 (Array.isArray(data.filters.mac) && data.filters.mac.length)
|
||||
parts.push(`mac=[${data.filters.mac.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) {
|
||||
const best = data.table.reduce((a, b) =>
|
||||
rankState(b.state) > rankState(a.state) ? b : a
|
||||
);
|
||||
const r = rankState(best.state);
|
||||
if (r >= 5) setPill("ok", "online");
|
||||
else if (r >= 2) setPill("warn", "maybe");
|
||||
else setPill("bad", "offline");
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStatus(name) {
|
||||
setPill("warn", "checking…");
|
||||
const u = buildStatusUrl(name);
|
||||
elLog.textContent = "GET " + u.pathname + u.search;
|
||||
elHtml.innerHTML = "";
|
||||
try {
|
||||
const r = await fetch(u);
|
||||
if (!r.ok) {
|
||||
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}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
const data = await r.json();
|
||||
renderStatus(data);
|
||||
} catch (e) {
|
||||
elLog.textContent = "status error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b0b0b;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #888;
|
||||
--ok: #17a34a;
|
||||
--warn: #d97706;
|
||||
--bad: #dc2626;
|
||||
--btn: #2563eb;
|
||||
--card: #111827;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
.wrap {
|
||||
width: min(900px, 95vw);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
input[type="text"] {
|
||||
flex: 1 1 240px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: #0f0f0f;
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: var(--btn);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary {
|
||||
background: #1f2937;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.ok {
|
||||
background: #052e1a;
|
||||
border-color: #064e3b;
|
||||
color: #86efac;
|
||||
}
|
||||
.warn {
|
||||
background: #2b1800;
|
||||
border-color: #7c2d12;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.bad {
|
||||
background: #330b0b;
|
||||
border-color: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
}
|
||||
#out {
|
||||
min-height: 100px;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.table a.pick {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.table a.pick:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
#preview:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export function getName(elName) {
|
||||
return (elName.value || "").trim();
|
||||
}
|
||||
|
||||
export function saveName(name) {
|
||||
try {
|
||||
localStorage.setItem("wakey:name", name);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function loadName(qs) {
|
||||
return qs.get("name") || localStorage.getItem("wakey:name") || "";
|
||||
}
|
||||
|
||||
export function extractHostLikeBackend(input) {
|
||||
let s = String(input || "").trim();
|
||||
if (!s) return "";
|
||||
const schemeIdx = s.indexOf("://");
|
||||
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
|
||||
else if (s.startsWith("//")) s = s.slice(2);
|
||||
const at = s.lastIndexOf("@");
|
||||
if (at >= 0) s = s.slice(at + 1);
|
||||
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();
|
||||
}
|
||||
|
||||
export function rankState(s) {
|
||||
const key = String(s || "").trim().toUpperCase();
|
||||
return (
|
||||
{
|
||||
PERMANENT: 5,
|
||||
REACHABLE: 5,
|
||||
STALE: 4,
|
||||
DELAY: 3,
|
||||
PROBE: 3,
|
||||
INCOMPLETE: 3,
|
||||
NOARP: 2,
|
||||
NONE: 1,
|
||||
FAILED: 0,
|
||||
}[key] ?? 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { elLog, setPill } from "./dom.js";
|
||||
import { fetchStatus } from "./status.js";
|
||||
|
||||
export async function sendWake(name) {
|
||||
setPill("warn", "waking…");
|
||||
elLog.textContent = "POST /wake?name=" + name;
|
||||
try {
|
||||
const r = await fetch(`/wake?name=${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
});
|
||||
const t = await r.text();
|
||||
elLog.textContent = t || "ok";
|
||||
setTimeout(() => fetchStatus(name), 1500);
|
||||
} catch (e) {
|
||||
elLog.textContent = "wake error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user