feat: fleet hub dashboard + /api/fleet endpoint with direct access links

server.py:
- GET / serves dashboard.html (dark-themed fleet console)
- GET /api/fleet returns all registered devices with tunnel status,
  SSH commands, and web dashboard access instructions
- Static file serving via serve_file()

dashboard.html:
- Shows all registered devices from port-registry.json
- Live tunnel status (port-open check on VPS)
- Platform badges (Synology vs GL-XE3000)
- Copy-to-clipboard SSH commands per device
- Web dashboard access via SSH port-forwarding instructions
- Auto-refreshes every 15s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 16:11:10 +00:00
parent 68306dcb14
commit c8bb08ed35
2 changed files with 194 additions and 96 deletions
+94 -74
View File
@@ -5,113 +5,133 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pioneer Bus Fleet — Console</title> <title>Pioneer Bus Fleet — Console</title>
<style> <style>
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --dim: #8b949e; :root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9;
--green: #3fb950; --amber: #d29922; --red: #f85149; --blue: #58a6ff; } --dim: #8b949e; --green: #3fb950; --amber: #d29922; --red: #f85149;
--blue: #58a6ff; --accent: #2563EB; }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); padding: 20px; } background: var(--bg); color: var(--text); padding: 20px; }
h1 { font-size: 24px; margin-bottom: 4px; } .header { background: linear-gradient(135deg, var(--accent), #1d4ed8);
.subtitle { color: var(--dim); font-size: 14px; margin-bottom: 24px; } margin: -20px -20px 20px -20px; padding: 20px 24px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; } .header h1 { font-size: 22px; margin: 0; color: #fff; }
.header h1 span { color: #93c5fd; }
.header .sub { color: #bfdbfe; font-size: 12px; letter-spacing: 1px; margin-top: 4px; }
.stats { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.stat-card { background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 20px; text-align: center; min-width: 100px; }
.stat-card .num { font-size: 28px; font-weight: 700; color: var(--accent); }
.stat-card .lbl { font-size: 10px; color: var(--dim); text-transform: uppercase; margin-top: 2px; }
.stat-card .num.online { color: var(--green); }
.stat-card .num.offline { color: var(--red); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 16px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
.card h2 { font-size: 16px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; } .card h2 { font-size: 16px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; } .badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; }
.badge-gl { background: rgba(88,166,255,0.15); color: var(--blue); } .badge-gl { background: rgba(88,166,255,0.15); color: var(--blue); }
.badge-synology { background: rgba(210,153,34,0.15); color: var(--amber); } .badge-synology { background: rgba(210,153,34,0.15); color: var(--amber); }
.online { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--green); margin-right: 4px; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.offline { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--red); margin-right: 4px; } .dot-up { background: var(--green); }
.row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; } .dot-down { background: var(--red); }
.row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 13px; border-bottom: 1px solid rgba(255,255,255,0.04); }
.row .label { color: var(--dim); } .row .label { color: var(--dim); }
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; } .row .val { font-family: monospace; font-size: 12px; }
.metric { text-align: center; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 6px; } .access { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.metric .val { font-size: 20px; font-weight: 700; } .access .title { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.metric .lbl { font-size: 10px; color: var(--dim); text-transform: uppercase; margin-top: 2px; } .cmd { background: #0d1117; padding: 6px 10px; border-radius: 4px; font-family: monospace;
.score-bar { height: 4px; border-radius: 2px; margin-top: 4px; transition: width 0.5s; } font-size: 12px; color: var(--green); margin-bottom: 4px; word-break: break-all; }
.score-high { background: var(--green); } .cmd a { color: var(--blue); text-decoration: none; }
.score-mid { background: var(--amber); } .cmd a:hover { text-decoration: underline; }
.score-low { background: var(--red); } .empty { color: var(--dim); text-align: center; padding: 60px 20px; font-size: 14px; }
.wan-row { display: flex; gap: 8px; margin-top: 8px; }
.wan-box { flex: 1; padding: 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; }
.wan-box.active { border-color: var(--green); }
.wan-box .wan-name { font-weight: 600; margin-bottom: 4px; }
.refresh { color: var(--dim); font-size: 11px; text-align: right; margin-top: 20px; } .refresh { color: var(--dim); font-size: 11px; text-align: right; margin-top: 20px; }
.empty { color: var(--dim); text-align: center; padding: 40px; }
</style> </style>
</head> </head>
<body> <body>
<h1>Pioneer Bus Fleet</h1> <div class="header">
<div class="subtitle">Single-pane console — GL-XE3000 + Synology RT2600ac routers</div> <h1>PIONEER <span>BUS FLEET</span></h1>
<div class="sub">Keylink IT Fleet Connectivity Console</div>
</div>
<div class="stats" id="stats"></div>
<div class="grid" id="fleet"></div> <div class="grid" id="fleet"></div>
<div class="refresh" id="refresh">Last updated: --</div> <div class="refresh" id="refresh">Loading…</div>
<script> <script>
const HUB = 'http://167.172.237.162:8080'; var HUB = window.location.origin;
async function loadFleet() { function loadFleet() {
try { fetch(HUB + '/api/fleet')
const resp = await fetch(`${HUB}/api/status`); .then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
const data = await resp.json(); .then(function(data) { render(data); })
renderFleet(data.devices || []); .catch(function(e) {
} catch(e) { document.getElementById('fleet').innerHTML =
document.getElementById('fleet').innerHTML = '<div class="empty">Hub unreachable — fleet console offline</div>'; '<div class="empty">Hub unreachable — ' + e + '</div>';
} });
document.getElementById('refresh').textContent = 'Last updated: ' + new Date().toLocaleTimeString(); document.getElementById('refresh').textContent = 'Last updated: ' + new Date().toLocaleTimeString();
} }
function renderFleet(devices) { function render(data) {
const el = document.getElementById('fleet'); var devices = data.devices || [];
var online = devices.filter(function(d) { return d.tunnel_up; }).length;
document.getElementById('stats').innerHTML =
'<div class="stat-card"><div class="num">' + devices.length +
'</div><div class="lbl">Registered</div></div>' +
'<div class="stat-card"><div class="num online">' + online +
'</div><div class="lbl">Online</div></div>' +
'<div class="stat-card"><div class="num offline">' + (devices.length - online) +
'</div><div class="lbl">Offline</div></div>' +
'<div class="stat-card"><div class="num">' + (data.hub || '') +
'</div><div class="lbl">Hub</div></div>';
var el = document.getElementById('fleet');
if (!devices.length) { if (!devices.length) {
el.innerHTML = '<div class="empty">No devices reporting yet</div>'; el.innerHTML = '<div class="empty">No devices registered yet.<br><br>Install kit-connect on a router and it will appear here.</div>';
return; return;
} }
el.innerHTML = devices.map(d => {
const platform = d.platform || 'openwrt'; el.innerHTML = devices.map(function(d) {
const badge = platform === 'synology' var isSynology = d.device_id.startsWith('x');
var badge = isSynology
? '<span class="badge badge-synology">Synology</span>' ? '<span class="badge badge-synology">Synology</span>'
: '<span class="badge badge-gl">GL-XE3000</span>'; : '<span class="badge badge-gl">GL-XE3000</span>';
const online = d.last_seen && (Date.now()/1000 - d.timestamp) < 120 var status = d.tunnel_up
? '<span class="online"></span>Online' ? '<span><span class="dot dot-up"></span>ONLINE</span>'
: '<span class="offline"></span>Offline'; : '<span><span class="dot dot-down"></span>OFFLINE</span>';
const w1 = (d.wan && d.wan.wan1) || {}; var ssh = 'ssh -p ' + d.tunnel_port + ' kitadmin@' + data.hub;
const w2 = (d.wan && d.wan.wan2) || {}; var web = d.dashboard_url;
return ` return '<div class="card">' +
<div class="card"> '<h2>' + esc(d.device_id) + ' ' + badge + ' <span style="font-size:13px">' + status + '</span></h2>' +
<h2>${d.device_id} ${badge} <span>${online}</span></h2> '<div class="row"><span class="label">Tunnel Port</span><span class="val">' + d.tunnel_port + '</span></div>' +
<div class="row"><span class="label">Uptime</span><span>${fmtUptime(d.uptime)}</span></div> '<div class="row"><span class="label">Assigned</span><span class="val">' + (d.assigned || '--') + '</span></div>' +
<div class="row"><span class="label">Active WAN</span><span>${d.sel_primary || '--'}</span></div> (d.tunnel_up
${d.gps && d.gps.fix ? ` ? '<div class="access">' +
<div class="row"><span class="label">GPS</span><span>${d.gps.lat}, ${d.gps.lon}</span></div> '<div class="title">Direct Access</div>' +
` : ''} '<div class="cmd">$ <a href="#" onclick="copySSH(\'' + esc(ssh) + '\');return false">' + esc(ssh) + '</a></div>' +
<div class="wan-row"> (web ? '<div class="cmd">→ <a href="' + web + '" target="_blank">' + web + '</a> (direct — only works when SSH tunnel forwards HTTP)</div>' : '') +
<div class="wan-box ${d.sel_primary === 'wan1' ? 'active' : ''}"> '<div class="cmd" style="color:var(--dim);font-size:11px">' +
<div class="wan-name">WAN1</div> 'For web dashboard: <code>ssh -L 8089:127.0.0.1:8089 -p ' + d.tunnel_port + ' kitadmin@' + data.hub + '</code><br>' +
<div>Score: ${w1.score || '--'} | Lat: ${w1.latency_ms || '--'}ms</div> 'then open <a href="http://localhost:8089/">http://localhost:8089/</a></div>' +
${w1.rsrp_dbm ? `<div>RSRP: ${w1.rsrp_dbm} dBm</div>` : ''} '</div>'
</div> : '<div class="row"><span class="label">Status</span><span class="val" style="color:var(--red)">Tunnel not connected — restart daemon on router</span></div>'
<div class="wan-box ${d.sel_primary === 'wan2' ? 'active' : ''}"> ) +
<div class="wan-name">WAN2</div> '</div>';
<div>Score: ${w2.score || '--'} | Lat: ${w2.latency_ms || '--'}ms</div>
</div>
</div>
<div class="metric-grid">
<div class="metric"><div class="val">${w1.score || 0}</div><div class="lbl">WAN1 Score</div></div>
<div class="metric"><div class="val">${w2.score || 0}</div><div class="lbl">WAN2 Score</div></div>
<div class="metric"><div class="val">${d.sim_slot || '--'}</div><div class="lbl">SIM Slot</div></div>
</div>
</div>`;
}).join(''); }).join('');
} }
function fmtUptime(s) { function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
if (!s) return '--';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60); function copySSH(cmd) {
return h > 24 ? `${Math.floor(h/24)}d ${h%24}h` : `${h}h ${m}m`; navigator.clipboard.writeText(cmd).then(function() {
alert('Copied: ' + cmd);
}).catch(function() {
prompt('Copy this command:', cmd);
});
} }
loadFleet(); loadFleet();
setInterval(loadFleet, 10000); setInterval(loadFleet, 15000);
</script> </script>
</body> </body>
</html> </html>
+100 -22
View File
@@ -1,53 +1,70 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Busfleet Hub — registration server for kit-connect devices. """Busfleet Hub — fleet registration + dashboard server.
Listens on :8080. Routes: Listens on :8080. Routes:
GET / — fleet dashboard (HTML)
GET /health — health check
GET /api/register/<DEVICE_ID> — assign tunnel port, return JSON GET /api/register/<DEVICE_ID> — assign tunnel port, return JSON
POST /api/authorize-key — authorize device's tunnel key GET /api/fleet — all registered devices with
GET /health — health check tunnel status + access links
POST /api/authorize-key — authorize device tunnel key
""" """
import json import json
import os
import socket
import subprocess import subprocess
import sys import sys
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
REGISTER_SH = "/opt/busfleet-hub/register.sh" REGISTER_SH = "/opt/busfleet-hub/register.sh"
REGISTRY = "/opt/busfleet-hub/port-registry.json"
DASHBOARD = "/opt/busfleet-hub/dashboard.html"
HUB_IP = "162.243.83.36"
# ── Helper: check if a TCP port is open (tunnel active) ──────────
def _port_is_open(port):
try:
s = socket.create_connection(("127.0.0.1", port), timeout=2)
s.close()
return True
except Exception:
return False
class HubHandler(BaseHTTPRequestHandler): class HubHandler(BaseHTTPRequestHandler):
"""Handle hub registration and key authorization requests.""" """Handle hub registration, fleet status, and dashboard requests."""
def do_GET(self): def do_GET(self):
# Route: /api/register/<DEVICE_ID> # Fleet dashboard (homepage)
if self.path in ("/", "/index.html"):
self.serve_file(DASHBOARD, "text/html; charset=utf-8")
return
# Health check
if self.path == "/health":
self.send_json(200, '{"status":"ok","service":"busfleet-hub"}')
return
# Device registration
if self.path.startswith("/api/register/"): if self.path.startswith("/api/register/"):
device_id = self.path.split("/api/register/", 1)[1].strip("/") device_id = self.path.split("/api/register/", 1)[1].strip("/")
if not device_id: if not device_id:
self.send_json_error(400, "Missing device ID") self.send_json_error(400, "Missing device ID")
return return
self.call_register_script(device_id)
try:
result = subprocess.run(
[REGISTER_SH, device_id],
capture_output=True, text=True, timeout=15,
)
status = 200 if result.returncode == 0 else 500
self.send_json(status, result.stdout.strip())
except subprocess.TimeoutExpired:
self.send_json_error(504, "Registration timed out")
except Exception as exc:
self.send_json_error(500, str(exc))
return return
# Health check # Fleet status — all registered devices
if self.path in ("/", "/health"): if self.path == "/api/fleet":
self.send_json(200, '{"status":"ok","service":"busfleet-hub"}') self.serve_fleet_status()
return return
self.send_json_error(404, "Not found") self.send_json_error(404, "Not found")
def do_POST(self): def do_POST(self):
# Route: /api/authorize-key # Key authorization
if self.path.startswith("/api/authorize-key"): if self.path.startswith("/api/authorize-key"):
content_length = int(self.headers.get("Content-Length", 0)) content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode() if content_length else "{}" body = self.rfile.read(content_length).decode() if content_length else "{}"
@@ -64,7 +81,6 @@ class HubHandler(BaseHTTPRequestHandler):
self.send_json_error(400, "Missing device_id or pubkey") self.send_json_error(400, "Missing device_id or pubkey")
return return
# Basic validation: key must look like an SSH public key
if not pubkey.startswith(("ssh-", "ecdsa-", "sk-")) or len(pubkey) < 80: if not pubkey.startswith(("ssh-", "ecdsa-", "sk-")) or len(pubkey) < 80:
self.send_json_error(400, "Invalid SSH public key format") self.send_json_error(400, "Invalid SSH public key format")
return return
@@ -84,6 +100,67 @@ class HubHandler(BaseHTTPRequestHandler):
self.send_json_error(404, "Not found") self.send_json_error(404, "Not found")
# ── Helpers ────────────────────────────────────────────────────
def serve_file(self, path, content_type):
"""Serve a static file."""
if not os.path.isfile(path):
self.send_json_error(404, "File not found")
return
with open(path, "rb") as f:
data = f.read()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def call_register_script(self, device_id):
"""Run register.sh and return its output."""
try:
result = subprocess.run(
[REGISTER_SH, device_id],
capture_output=True, text=True, timeout=15,
)
status = 200 if result.returncode == 0 else 500
self.send_json(status, result.stdout.strip())
except subprocess.TimeoutExpired:
self.send_json_error(504, "Registration timed out")
except Exception as exc:
self.send_json_error(500, str(exc))
def serve_fleet_status(self):
"""Build and return fleet status from port-registry.json +
live tunnel checks."""
devices = []
try:
with open(REGISTRY) as f:
registry = json.load(f)
except Exception:
registry = {}
for dev_id, entry in registry.items():
port = entry.get("tunnel_port", 0)
tunnel_up = _port_is_open(port) if port else False
devices.append({
"device_id": dev_id,
"tunnel_port": port,
"assigned": entry.get("assigned", ""),
"tunnel_up": tunnel_up,
"ssh_command": f"ssh -p {port} kitadmin@{HUB_IP}",
"dashboard_url": f"http://{HUB_IP}:{port}" if tunnel_up else None,
})
# Sort: tunnel_up first, then by device_id
devices.sort(key=lambda d: (not d["tunnel_up"], d["device_id"]))
self.send_json(200, json.dumps({
"hub": HUB_IP,
"device_count": len(devices),
"devices": devices,
}))
def send_json(self, status, body): def send_json(self, status, body):
"""Send a JSON response.""" """Send a JSON response."""
self.send_response(status) self.send_response(status)
@@ -104,6 +181,7 @@ def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
server = HTTPServer(("0.0.0.0", port), HubHandler) server = HTTPServer(("0.0.0.0", port), HubHandler)
print(f"Busfleet Hub listening on :{port}", flush=True) print(f"Busfleet Hub listening on :{port}", flush=True)
print(f" Dashboard: http://{HUB_IP}:{port}/", flush=True)
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt: