feat(router): Add local dashboard with auto-login, status, devices and restart
This commit is contained in:
1 parent
cc6fe142f0
commit
545098eceb
1 file changed
+547
Executable
+547
@@ -0,0 +1,547 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = ["bottle"]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import webbrowser
|
||||||
|
from collections.abc import Callable
|
||||||
|
from socketserver import ThreadingMixIn
|
||||||
|
from wsgiref.simple_server import WSGIRequestHandler, WSGIServer, make_server
|
||||||
|
|
||||||
|
import bottle
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ROUTER_HOST = "192.168.1.1"
|
||||||
|
ROUTER_USERNAME = "abdus"
|
||||||
|
ROUTER_PASSWORD = "xAsametk50"
|
||||||
|
INTERNET_TARGET = "8.8.8.8"
|
||||||
|
UNIT_SECONDS = {"day": 86400, "hour": 3600, "min": 60, "sec": 1}
|
||||||
|
|
||||||
|
PAGE = r"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Router</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f7f9; --card: #ffffff; --text: #1b1f24; --muted: #6a737d; --line: #e3e6ea;
|
||||||
|
--ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #8b949e; --line: #30363d;
|
||||||
|
--ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, sans-serif; }
|
||||||
|
main { padding: 16px; display: grid; gap: 12px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
||||||
|
.s4 { grid-column: span 4; }
|
||||||
|
.s8 { grid-column: span 8; }
|
||||||
|
.wide { grid-column: 1 / -1; }
|
||||||
|
.card.stack { display: flex; flex-direction: column; }
|
||||||
|
.card.stack .grid { align-content: start; }
|
||||||
|
@media (max-width: 1000px) { .s4, .s8 { grid-column: span 6; } }
|
||||||
|
@media (max-width: 640px) { .s4, .s8 { grid-column: 1 / -1; } }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
|
||||||
|
.banner { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--muted); flex: none; }
|
||||||
|
.banner h1 { margin: 0; font-size: 20px; }
|
||||||
|
.banner p { margin: 0; color: var(--muted); }
|
||||||
|
.ok .dot { background: var(--ok); } .warn .dot { background: var(--warn); } .bad .dot { background: var(--bad); }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
|
||||||
|
.stat small { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.stat b { font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||||
|
.notes { color: var(--warn); margin: 0; padding-left: 34px; }
|
||||||
|
.notes:empty { display: none; }
|
||||||
|
svg { width: 100%; flex: 1; min-height: 64px; display: block; margin-top: 12px; }
|
||||||
|
h2 { margin: 0 0 8px; font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
th, td { text-align: left; padding: 5px 6px; border-bottom: 1px solid var(--line); }
|
||||||
|
th { color: var(--muted); font-weight: 500; }
|
||||||
|
.off { color: var(--muted); }
|
||||||
|
button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--text); cursor: pointer; }
|
||||||
|
button.danger { color: var(--bad); border-color: var(--bad); }
|
||||||
|
button.armed { background: var(--bad); color: #fff; }
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
|
details summary { cursor: pointer; color: var(--muted); }
|
||||||
|
pre { margin: 8px 0 0; max-height: 260px; overflow: auto; font-size: 12px; white-space: pre-wrap; }
|
||||||
|
.row { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="card banner s8" id="banner">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<div><h1 id="title">Connecting...</h1><p id="subtitle"></p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card row s4">
|
||||||
|
<div><b id="model">Router</b><div class="off" id="fw"></div></div>
|
||||||
|
<button id="reboot" class="danger">Restart router</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ul class="card notes wide" id="notes"></ul>
|
||||||
|
|
||||||
|
<section class="card stack s4"><h2>Ping</h2>
|
||||||
|
<div class="grid" id="statsPing"></div>
|
||||||
|
<svg id="spark" viewBox="0 0 300 64" preserveAspectRatio="none"></svg>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack s4"><h2>Line</h2><div class="grid" id="statsLine"></div></section>
|
||||||
|
|
||||||
|
<section class="card stack s4"><h2>Connection</h2><div class="grid" id="statsConn"></div></section>
|
||||||
|
|
||||||
|
<section class="card wide"><h2>Connected devices</h2>
|
||||||
|
<table><thead><tr><th>Name</th><th>IP</th><th>Link</th><th>Signal</th></tr></thead><tbody id="hosts"></tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card wide"><details id="logbox"><summary>Router log</summary><pre id="log">loading...</pre></details></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const history = [];
|
||||||
|
const notes = [];
|
||||||
|
let last = null;
|
||||||
|
let restarting = false;
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return String(value ?? "").replace(/[&<>"']/g, (c) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
||||||
|
}
|
||||||
|
function duration(seconds) {
|
||||||
|
if (seconds == null) return "-";
|
||||||
|
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600), m = Math.floor(seconds % 3600 / 60);
|
||||||
|
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${seconds % 60}s` : `${seconds}s`;
|
||||||
|
}
|
||||||
|
function mbps(kbps) { return kbps == null ? "-" : (kbps / 1000).toFixed(1) + " Mbps"; }
|
||||||
|
function num(value, unit) { return value == null ? "-" : value + " " + unit; }
|
||||||
|
function clock() { return new Date().toLocaleTimeString(); }
|
||||||
|
function setLevel(level) {
|
||||||
|
$("banner").classList.remove("ok", "warn", "bad");
|
||||||
|
$("banner").classList.add(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdict(data) {
|
||||||
|
const { ping, dsl, wan } = data;
|
||||||
|
if (ping.router_ms == null) return ["bad", "Router unreachable", "No answer from 192.168.1.1"];
|
||||||
|
if (!dsl || dsl.state !== "Up") return ["bad", "Line down", `DSL state: ${dsl ? dsl.state : "unknown"} (line / ISP side)`];
|
||||||
|
if (!wan || !wan.ip) return ["bad", "No internet session", "DSL is up but PPPoE has no IP (ISP side)"];
|
||||||
|
if (ping.internet_ms == null) return ["bad", "Internet unreachable", "Line and PPPoE are up, 8.8.8.8 does not answer"];
|
||||||
|
if (ping.internet_ms > 150) return ["warn", "Degraded", `Slow: ${ping.internet_ms.toFixed(0)} ms to 8.8.8.8`];
|
||||||
|
return ["ok", "Online", `${ping.internet_ms.toFixed(0)} ms to 8.8.8.8`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackEvents(data) {
|
||||||
|
if (last && data.dsl && last.dsl && data.dsl.uptime_s < last.dsl.uptime_s) notes.unshift(`${clock()}: DSL line resynced`);
|
||||||
|
if (last && data.device && last.device && data.device.uptime_s < last.device.uptime_s) notes.unshift(`${clock()}: router rebooted`);
|
||||||
|
if (last && data.wan && last.wan && data.wan.ip !== last.wan.ip) notes.unshift(`${clock()}: WAN IP changed`);
|
||||||
|
if (last && last.ping.internet_ms != null && data.ping.internet_ms == null) notes.unshift(`${clock()}: internet ping lost`);
|
||||||
|
notes.length = Math.min(notes.length, 5);
|
||||||
|
$("notes").innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stat(label, value) { return `<div class="stat"><small>${label}</small><b>${esc(value)}</b></div>`; }
|
||||||
|
|
||||||
|
function drawSpark() {
|
||||||
|
const points = history.slice(-60);
|
||||||
|
const top = Math.max(50, ...points.filter((p) => p != null)) * 1.1;
|
||||||
|
const step = 300 / (Math.max(points.length, 12) - 1);
|
||||||
|
let path = "", marks = "";
|
||||||
|
points.forEach((value, index) => {
|
||||||
|
const x = index * step;
|
||||||
|
if (value == null) { marks += `<line x1="${x}" x2="${x}" y1="0" y2="64" stroke="var(--bad)" stroke-width="2" vector-effect="non-scaling-stroke"/>`; return; }
|
||||||
|
path += `${path ? "L" : "M"}${x.toFixed(1)},${(62 - value / top * 60).toFixed(1)}`;
|
||||||
|
});
|
||||||
|
$("spark").innerHTML = `<path d="${path}" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>${marks}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const [level, title, subtitle] = verdict(data);
|
||||||
|
setLevel(level);
|
||||||
|
$("title").textContent = title;
|
||||||
|
$("subtitle").textContent = subtitle;
|
||||||
|
trackEvents(data);
|
||||||
|
const { dsl, wan, device, ping } = data;
|
||||||
|
const ms = (value) => num(value == null ? null : value.toFixed(0), "ms");
|
||||||
|
$("statsPing").innerHTML = [
|
||||||
|
stat("Internet (8.8.8.8)", ms(ping.internet_ms)),
|
||||||
|
stat("Router", ms(ping.router_ms)),
|
||||||
|
stat("ISP gateway", ms(ping.isp_gateway_ms)),
|
||||||
|
].join("");
|
||||||
|
$("statsLine").innerHTML = [
|
||||||
|
stat("Down sync", mbps(dsl && dsl.down_kbps)),
|
||||||
|
stat("Up sync", mbps(dsl && dsl.up_kbps)),
|
||||||
|
stat("SNR down / up", dsl ? `${dsl.snr_down_db} / ${dsl.snr_up_db} dB` : "-"),
|
||||||
|
stat("Attenuation", dsl ? `${dsl.attn_down_db} / ${dsl.attn_up_db} dB` : "-"),
|
||||||
|
stat("Errors", dsl ? dsl.errors : "-"),
|
||||||
|
stat("DSL uptime", duration(dsl && dsl.uptime_s)),
|
||||||
|
].join("");
|
||||||
|
$("statsConn").innerHTML = [
|
||||||
|
stat("WAN IP", wan && wan.ip || "-"),
|
||||||
|
stat("DNS", wan && wan.dns ? wan.dns.split(",").join(", ") : "-"),
|
||||||
|
stat("Router uptime", duration(device && device.uptime_s)),
|
||||||
|
].join("");
|
||||||
|
$("model").textContent = device ? device.model : "Router";
|
||||||
|
$("fw").textContent = device ? "Firmware " + device.firmware : "";
|
||||||
|
const hosts = (data.hosts || []).slice().sort((a, b) => (b.active - a.active) || a.name.localeCompare(b.name));
|
||||||
|
$("hosts").innerHTML = hosts.map((h) =>
|
||||||
|
`<tr class="${h.active ? "" : "off"}"><td>${esc(h.name)}</td><td>${esc(h.ip)}</td><td>${esc(h.connection)}</td><td>${h.signal == null ? "" : esc(h.signal) + "/5"}</td></tr>`
|
||||||
|
).join("");
|
||||||
|
history.push(ping.internet_ms);
|
||||||
|
drawSpark();
|
||||||
|
last = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (restarting) return;
|
||||||
|
try {
|
||||||
|
render(await (await fetch("/api/overview")).json());
|
||||||
|
} catch (error) {
|
||||||
|
$("title").textContent = "Dashboard server unreachable";
|
||||||
|
setLevel("bad");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLog() {
|
||||||
|
try {
|
||||||
|
const rows = await (await fetch("/api/log")).json();
|
||||||
|
$("log").textContent = rows.length ? rows.map((r) => typeof r === "string" ? r : Object.values(r).join(" ")).join("\n") : "(empty)";
|
||||||
|
} catch (error) {
|
||||||
|
$("log").textContent = "could not load log: " + error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartRouter() {
|
||||||
|
restarting = true;
|
||||||
|
$("reboot").disabled = true;
|
||||||
|
setLevel("warn");
|
||||||
|
$("title").textContent = "Restarting router...";
|
||||||
|
const started = Date.now();
|
||||||
|
fetch("/api/reboot", { method: "POST" }).catch(() => {});
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
const elapsed = Math.round((Date.now() - started) / 1000);
|
||||||
|
$("subtitle").textContent = `${elapsed}s, waiting for the router to come back`;
|
||||||
|
if (elapsed < 20) return;
|
||||||
|
try {
|
||||||
|
const data = await (await fetch("/api/overview")).json();
|
||||||
|
if (data.ping.router_ms != null && data.device) {
|
||||||
|
clearInterval(timer);
|
||||||
|
restarting = false;
|
||||||
|
$("reboot").disabled = false;
|
||||||
|
last = null;
|
||||||
|
notes.unshift(`${clock()}: router restarted from dashboard`);
|
||||||
|
render(data);
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let armTimer = null;
|
||||||
|
$("reboot").addEventListener("click", () => {
|
||||||
|
const button = $("reboot");
|
||||||
|
if (!button.classList.contains("armed")) {
|
||||||
|
button.classList.add("armed");
|
||||||
|
button.textContent = "Click again to restart";
|
||||||
|
armTimer = setTimeout(() => { button.classList.remove("armed"); button.textContent = "Restart router"; }, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(armTimer);
|
||||||
|
button.classList.remove("armed");
|
||||||
|
button.textContent = "Restart router";
|
||||||
|
restartRouter();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("logbox").addEventListener("toggle", () => { if ($("logbox").open) loadLog(); });
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Local dashboard for the Zyxel router",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=0, help="0 picks a random free port")
|
||||||
|
parser.add_argument("--timeout", type=float, default=10.0, help="Router request timeout in seconds")
|
||||||
|
parser.add_argument("--no-browser", action="store_true", help="Do not open the page on start")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
class Router:
|
||||||
|
def __init__(self, host: str, timeout: float) -> None:
|
||||||
|
self.base_url = f"https://{host}"
|
||||||
|
self.timeout = timeout
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.session_key = ""
|
||||||
|
# Self-signed certificate; over HTTPS the router accepts a plain JSON login.
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
self.opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPSHandler(context=context),
|
||||||
|
urllib.request.HTTPCookieProcessor(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def request(self, path: str, body: dict | None = None) -> object:
|
||||||
|
separator = "&" if "?" in path else "?"
|
||||||
|
suffix = f"{separator}sessionkey={self.session_key}" if self.session_key else ""
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url=f"{self.base_url}{path}{suffix}",
|
||||||
|
data=json.dumps(body).encode() if body is not None else None,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST" if body is not None else "GET",
|
||||||
|
)
|
||||||
|
with self.opener.open(request, timeout=self.timeout) as response:
|
||||||
|
raw = response.read().decode(errors="replace")
|
||||||
|
return json.loads(raw) if raw.strip() else {}
|
||||||
|
|
||||||
|
def login(self) -> None:
|
||||||
|
self.session_key = ""
|
||||||
|
reply = self.request(
|
||||||
|
path="/UserLogin",
|
||||||
|
body={
|
||||||
|
"Input_Account": ROUTER_USERNAME,
|
||||||
|
"Input_Passwd": base64.b64encode(ROUTER_PASSWORD.encode()).decode(),
|
||||||
|
"currLang": "en",
|
||||||
|
"RememberPassword": 0,
|
||||||
|
"SHA512_password": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if reply.get("result") != "ZCFG_SUCCESS":
|
||||||
|
raise RuntimeError(f"Login rejected: {reply}")
|
||||||
|
self.session_key = reply.get("sessionkey", "")
|
||||||
|
logger.info("Logged in to router")
|
||||||
|
|
||||||
|
def call(self, path: str, body: dict | None = None) -> object:
|
||||||
|
with self.lock:
|
||||||
|
if not self.session_key:
|
||||||
|
self.login()
|
||||||
|
try:
|
||||||
|
return self.request(path=path, body=body)
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError, ConnectionError):
|
||||||
|
logger.info("Router call failed, logging in again")
|
||||||
|
self.login()
|
||||||
|
return self.request(path=path, body=body)
|
||||||
|
|
||||||
|
def reboot(self) -> object:
|
||||||
|
with self.lock:
|
||||||
|
if not self.session_key:
|
||||||
|
self.login()
|
||||||
|
try:
|
||||||
|
return self.request(path="/cgi-bin/Reboot", body={})
|
||||||
|
finally:
|
||||||
|
self.session_key = ""
|
||||||
|
|
||||||
|
|
||||||
|
def find_number(pattern: str, text: str) -> float | None:
|
||||||
|
match = re.search(pattern, text)
|
||||||
|
return float(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration(text: str) -> int:
|
||||||
|
return sum(int(number) * UNIT_SECONDS[unit] for number, unit in re.findall(r"(\d+)\s*(day|hour|min|sec)", text))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dsl(text: str) -> dict:
|
||||||
|
state = re.search(r"Line State:\s*(\S+)", text)
|
||||||
|
uptime = re.search(r"DSL uptime\s*:(.*)", text)
|
||||||
|
return {
|
||||||
|
"state": state.group(1) if state else None,
|
||||||
|
"uptime_s": parse_duration(text=uptime.group(1)) if uptime else None,
|
||||||
|
"down_kbps": find_number(pattern=r"near-end fast channel bit rate: (\d+)", text=text),
|
||||||
|
"up_kbps": find_number(pattern=r"far-end fast channel bit rate: (\d+)", text=text),
|
||||||
|
"snr_down_db": find_number(pattern=r"noise margin downstream: ([\d.]+)", text=text),
|
||||||
|
"snr_up_db": find_number(pattern=r"noise margin upstream: ([\d.]+)", text=text),
|
||||||
|
"attn_down_db": find_number(pattern=r"attenuation downstream: ([\d.]+)", text=text),
|
||||||
|
"attn_up_db": find_number(pattern=r"attenuation upstream: ([\d.]+)", text=text),
|
||||||
|
"errors": sum(int(count) for count in re.findall(r"(?:CRC|FEC|HEC) error \w+: (\d+)", text)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ping(host: str | None) -> float | None:
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
result = subprocess.run(
|
||||||
|
["ping", "-c", "1", "-W", "2000", host],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return find_number(pattern=r"time=([\d.]+)", text=result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def read_device(router: Router) -> dict:
|
||||||
|
info = router.call(path="/cgi-bin/DAL?oid=status")["Object"][0]["DeviceInfo"]
|
||||||
|
return {
|
||||||
|
"model": info.get("ModelName"),
|
||||||
|
"firmware": info.get("SoftwareVersion"),
|
||||||
|
"uptime_s": info.get("UpTime"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_dsl(router: Router) -> dict:
|
||||||
|
return parse_dsl(text=router.call(path="/cgi-bin/xDSLStatistics_handle?line=0")[0]["result"])
|
||||||
|
|
||||||
|
|
||||||
|
def read_wan(router: Router) -> dict:
|
||||||
|
entries = router.call(path="/cgi-bin/DAL?oid=wan")["Object"]
|
||||||
|
vdsl = next((entry for entry in entries if entry.get("Name") == "VDSL"), {})
|
||||||
|
return {
|
||||||
|
"ip": vdsl.get("IPAddress") or None,
|
||||||
|
"gateway": vdsl.get("GatewayIPAddress") or None,
|
||||||
|
"dns": vdsl.get("DNSServer") or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_hosts(router: Router) -> list[dict]:
|
||||||
|
hosts = router.call(path="/cgi-bin/DAL?oid=lanhosts")["Object"][0]["lanhosts"]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": host.get("HostName") or host.get("DeviceName") or "unknown",
|
||||||
|
"ip": host.get("IPAddress"),
|
||||||
|
"mac": host.get("PhysAddress"),
|
||||||
|
"connection": host.get("X_ZYXEL_ConnectionType"),
|
||||||
|
"active": bool(host.get("Active")),
|
||||||
|
"signal": host.get("X_ZYXEL_SignalStrength"),
|
||||||
|
}
|
||||||
|
for host in hosts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def safely(reader, router: Router) -> object:
|
||||||
|
try:
|
||||||
|
return reader(router)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("%s failed: %s", reader.__name__, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_parallel(tasks: dict[str, Callable[[], object]]) -> dict[str, object]:
|
||||||
|
# Daemon threads, so Ctrl+C does not wait for slow router calls or pings.
|
||||||
|
results: dict[str, object] = {}
|
||||||
|
|
||||||
|
def worker(name: str, task: Callable[[], object]) -> None:
|
||||||
|
results[name] = task()
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker, args=(name, task), daemon=True) for name, task in tasks.items()]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def overview(router: Router) -> dict:
|
||||||
|
results = run_parallel(
|
||||||
|
tasks={
|
||||||
|
"device": lambda: safely(read_device, router),
|
||||||
|
"dsl": lambda: safely(read_dsl, router),
|
||||||
|
"wan": lambda: safely(read_wan, router),
|
||||||
|
"hosts": lambda: safely(read_hosts, router),
|
||||||
|
"router_ms": lambda: ping(host=ROUTER_HOST),
|
||||||
|
"internet_ms": lambda: ping(host=INTERNET_TARGET),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
wan = results["wan"]
|
||||||
|
gateway = wan["gateway"] if wan and wan["ip"] else None
|
||||||
|
return {
|
||||||
|
"device": results["device"],
|
||||||
|
"dsl": results["dsl"],
|
||||||
|
"wan": wan,
|
||||||
|
"hosts": results["hosts"],
|
||||||
|
"ping": {
|
||||||
|
"router_ms": results["router_ms"],
|
||||||
|
"isp_gateway_ms": ping(host=gateway),
|
||||||
|
"internet_ms": results["internet_ms"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(router: Router) -> bottle.Bottle:
|
||||||
|
app = bottle.Bottle()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index() -> str:
|
||||||
|
return PAGE
|
||||||
|
|
||||||
|
@app.get("/api/overview")
|
||||||
|
def api_overview() -> dict:
|
||||||
|
return overview(router=router)
|
||||||
|
|
||||||
|
@app.get("/api/log")
|
||||||
|
def api_log() -> dict:
|
||||||
|
bottle.response.content_type = "application/json"
|
||||||
|
try:
|
||||||
|
return json.dumps(router.call(path="/cgi-bin/DAL?oid=zlog")["Object"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("log failed: %s", exc)
|
||||||
|
bottle.response.status = 502
|
||||||
|
return json.dumps([])
|
||||||
|
|
||||||
|
@app.post("/api/reboot")
|
||||||
|
def api_reboot() -> dict:
|
||||||
|
logger.warning("Rebooting router on request")
|
||||||
|
try:
|
||||||
|
router.reboot()
|
||||||
|
except Exception as exc:
|
||||||
|
# The router may drop the connection while it goes down.
|
||||||
|
logger.info("Reboot call ended with: %s", exc)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadingServer(ThreadingMixIn, WSGIServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
class QuietHandler(WSGIRequestHandler):
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
router = Router(host=ROUTER_HOST, timeout=args.timeout)
|
||||||
|
server = make_server(
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
app=build_app(router=router),
|
||||||
|
server_class=ThreadingServer,
|
||||||
|
handler_class=QuietHandler,
|
||||||
|
)
|
||||||
|
url = f"http://{args.host}:{server.server_port}"
|
||||||
|
logger.info("Serving %s", url)
|
||||||
|
if not args.no_browser:
|
||||||
|
opener = threading.Timer(interval=0.5, function=webbrowser.open, args=[url])
|
||||||
|
opener.daemon = True
|
||||||
|
opener.start()
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.2)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Stopping")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user