126 lines
6.1 KiB
HTML
126 lines
6.1 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Mesh control</title>
|
|
<style>
|
|
:root { color-scheme: light dark; }
|
|
body { font-family: -apple-system, "Helvetica Neue", sans-serif; margin: 0;
|
|
background: Canvas; color: CanvasText; user-select: none; }
|
|
#banner { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
|
|
#banner.home { background: #e6f4ea; color: #1e6b34; }
|
|
#banner.away { background: #e8f0fe; color: #1a56b0; }
|
|
#banner.unknown { background: #fef3e0; color: #8a5a00; }
|
|
#banner b { display: block; font-size: 15px; }
|
|
#banner small { font-size: 12px; opacity: .85; }
|
|
.row { display: flex; align-items: center; gap: 12px; padding: 10px 16px;
|
|
border-bottom: 0.5px solid color-mix(in srgb, CanvasText 12%, transparent); }
|
|
.row:hover { background: color-mix(in srgb, CanvasText 5%, transparent); }
|
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: #999; flex: none; }
|
|
.dot.ok { background: #2da44e; } .dot.bad { background: #d1242f; } .dot.idle { background: #bbb; }
|
|
.meta { flex: 1; min-width: 0; }
|
|
.meta b { font-size: 14px; font-weight: 600; }
|
|
.meta b small { font-weight: 400; opacity: .55; font-size: 11px; }
|
|
.meta span { display: block; font-size: 12px; opacity: .65; }
|
|
.ip { font-family: ui-monospace, monospace; font-size: 12px; opacity: .5; }
|
|
#foot { display: flex; justify-content: space-between; align-items: center;
|
|
padding: 10px 16px; font-size: 12px; opacity: .7; }
|
|
#menu { position: fixed; display: none; min-width: 220px; background: Canvas;
|
|
border: 0.5px solid color-mix(in srgb, CanvasText 30%, transparent);
|
|
border-radius: 8px; padding: 4px 0; font-size: 13px; z-index: 10;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,.18); }
|
|
#menu p { margin: 0; padding: 7px 14px; cursor: default; }
|
|
#menu p:hover { background: color-mix(in srgb, CanvasText 8%, transparent); }
|
|
#menu .hdr { font-size: 11px; opacity: .5; cursor: default; }
|
|
#menu .hdr:hover { background: none; }
|
|
#out { white-space: pre-wrap; font-family: ui-monospace, monospace; font-size: 11.5px;
|
|
padding: 10px 16px; display: none; border-top: 0.5px solid
|
|
color-mix(in srgb, CanvasText 12%, transparent); max-height: 180px; overflow-y: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="banner" class="unknown"><div><b id="loc">Checking where you are…</b><small id="locsub"></small></div></div>
|
|
<div id="hosts"></div>
|
|
<div id="foot"><span>right-click a device for the power tools</span><span id="agent"></span></div>
|
|
<div id="out"></div>
|
|
<div id="menu"></div>
|
|
<script>
|
|
let MENU = document.getElementById('menu');
|
|
let fleet = null;
|
|
|
|
function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; }
|
|
|
|
async function refresh() {
|
|
fleet = await window.pywebview.api.fleet();
|
|
const b = document.getElementById('banner');
|
|
if (fleet.location === 'HOME') {
|
|
b.className = 'home';
|
|
document.getElementById('loc').textContent = "You're home — fast lane is on";
|
|
document.getElementById('locsub').textContent = 'devices talk directly (via ' + (fleet.route || '?') + '), not via Iceland';
|
|
} else if (fleet.location === 'AWAY') {
|
|
b.className = 'away';
|
|
document.getElementById('loc').textContent = "You're away — secure tunnel to home";
|
|
document.getElementById('locsub').textContent = 'everything still works, a bit slower (via Iceland)';
|
|
} else {
|
|
b.className = 'unknown';
|
|
document.getElementById('loc').textContent = 'Agent not reporting';
|
|
document.getElementById('locsub').textContent = 'run: sudo smart-lan-router/install-agent.sh';
|
|
}
|
|
const age = fleet.agent_ts ? Math.round(Date.now() / 1000 - fleet.agent_ts) : null;
|
|
document.getElementById('agent').textContent =
|
|
age === null ? 'agent: no status' : (age > 90 ? 'agent: STALE ' + age + 's' : 'agent: ok (' + age + 's)');
|
|
|
|
const wrap = document.getElementById('hosts');
|
|
wrap.textContent = '';
|
|
for (const h of fleet.hosts) {
|
|
const row = el('div', 'row');
|
|
const dot = el('span', 'dot' + (h.phone ? ' idle' : ''));
|
|
const meta = el('div', 'meta');
|
|
const alias = h.aliases.length ? ' <small>(' + h.aliases[0] + ')</small>' : '';
|
|
meta.appendChild(el('b', null, h.name + alias));
|
|
const sub = el('span', null, h.friendly + (h.phone ? ' · tunnel client' : ' · checking…'));
|
|
meta.appendChild(sub);
|
|
row.appendChild(dot); row.appendChild(meta);
|
|
row.appendChild(el('span', 'ip', h.ip || ''));
|
|
row.oncontextmenu = (ev) => { ev.preventDefault(); openMenu(ev, h); };
|
|
wrap.appendChild(row);
|
|
if (!h.phone && h.ip) probeRow(h, dot, sub);
|
|
}
|
|
}
|
|
|
|
async function probeRow(h, dot, sub) {
|
|
const r = await window.pywebview.api.probe(h.ip);
|
|
if (r.ok) { dot.className = 'dot ok'; sub.textContent = h.friendly + ' · online · ' + (r.ms < 30 ? 'fast (' + r.ms + ' ms)' : r.ms + ' ms'); }
|
|
else { dot.className = 'dot bad'; sub.textContent = h.friendly + ' · not answering'; }
|
|
}
|
|
|
|
function item(label, fn) { const p = el('p', null, label); p.onclick = () => { hideMenu(); fn(); }; return p; }
|
|
|
|
function openMenu(ev, h) {
|
|
MENU.textContent = '';
|
|
MENU.appendChild(el('p', 'hdr', h.name + ' — advanced'));
|
|
MENU.appendChild(item('Copy address', () => window.pywebview.api.copy(h.ip)));
|
|
if (!h.phone) {
|
|
MENU.appendChild(item('Open terminal here (ssh)', () => window.pywebview.api.ssh_terminal(h.name)));
|
|
MENU.appendChild(item('Diagnose path…', () => runDoctor(h.name)));
|
|
if (h.wg) MENU.appendChild(item('Copy tunnel address (.wg)', () => window.pywebview.api.copy(h.wg)));
|
|
}
|
|
MENU.style.display = 'block';
|
|
MENU.style.left = Math.min(ev.clientX, window.innerWidth - 240) + 'px';
|
|
MENU.style.top = Math.min(ev.clientY, window.innerHeight - 160) + 'px';
|
|
}
|
|
|
|
function hideMenu() { MENU.style.display = 'none'; }
|
|
document.addEventListener('click', hideMenu);
|
|
|
|
async function runDoctor(name) {
|
|
const out = document.getElementById('out');
|
|
out.style.display = 'block';
|
|
out.textContent = 'diagnosing ' + name + '…';
|
|
out.textContent = await window.pywebview.api.doctor(name);
|
|
}
|
|
|
|
window.addEventListener('pywebviewready', () => { refresh(); setInterval(refresh, 30000); });
|
|
</script>
|
|
</body>
|
|
</html>
|