163 lines
6.7 KiB
HTML
163 lines
6.7 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex">
|
||
<title>tunneld admin</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
background: #0d1117; color: #e6edf3; }
|
||
header { display: flex; flex-wrap: wrap; gap: 12px; align-items: baseline;
|
||
padding: 20px 24px; border-bottom: 1px solid #21262d; }
|
||
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
||
header .meta { color: #8b949e; font-size: 12px; }
|
||
header .spacer { flex: 1; }
|
||
main { padding: 24px; max-width: 1100px; }
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 12px; margin-bottom: 24px; }
|
||
.card { background: #161b22; border: 1px solid #21262d; border-radius: 8px;
|
||
padding: 14px 16px; }
|
||
.card .label { color: #8b949e; font-size: 11px; text-transform: uppercase;
|
||
letter-spacing: .06em; }
|
||
.card .value { font-size: 22px; margin-top: 4px; }
|
||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em;
|
||
color: #8b949e; margin: 24px 0 8px; }
|
||
table { width: 100%; border-collapse: collapse; background: #161b22;
|
||
border: 1px solid #21262d; border-radius: 8px; overflow: hidden; }
|
||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #21262d; }
|
||
th { color: #8b949e; font-weight: 500; font-size: 12px; }
|
||
tr:last-child td { border-bottom: none; }
|
||
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px;
|
||
font-size: 12px; border: 1px solid; }
|
||
.online { color: #3fb950; border-color: #238636; background: #0f2417; }
|
||
.offline { color: #8b949e; border-color: #30363d; background: #161b22; }
|
||
.empty { color: #8b949e; padding: 12px; }
|
||
form { display: flex; gap: 8px; align-items: center; }
|
||
input { background: #0d1117; border: 1px solid #30363d; color: #e6edf3;
|
||
border-radius: 6px; padding: 6px 10px; font: inherit; width: 340px; }
|
||
button { background: #21262d; color: #e6edf3; border: 1px solid #30363d;
|
||
border-radius: 6px; padding: 6px 14px; font: inherit; cursor: pointer; }
|
||
button:hover { background: #30363d; }
|
||
#error { color: #f85149; margin: 16px 0 0; white-space: pre-wrap; }
|
||
.dim { color: #8b949e; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>tunneld admin</h1>
|
||
<span class="meta" id="server-meta"></span>
|
||
<span class="spacer"></span>
|
||
<form id="auth">
|
||
<input type="password" id="token" placeholder="ADMIN_TOKEN" autocomplete="off">
|
||
<button type="submit">Use token</button>
|
||
<button type="button" id="forget">Forget</button>
|
||
</form>
|
||
</header>
|
||
<main>
|
||
<div id="error"></div>
|
||
<div class="cards">
|
||
<div class="card"><div class="label">Tunnels</div><div class="value" id="t-tunnels">–</div></div>
|
||
<div class="card"><div class="label">Online</div><div class="value" id="t-online">–</div></div>
|
||
<div class="card"><div class="label">Bridges</div><div class="value" id="t-bridges">–</div></div>
|
||
<div class="card"><div class="label">Visitors</div><div class="value" id="t-visitors">–</div></div>
|
||
<div class="card"><div class="label">CA expires</div><div class="value" style="font-size:13px" id="t-ca">–</div></div>
|
||
</div>
|
||
|
||
<h2>Tunnels</h2>
|
||
<table id="tunnels"><thead><tr>
|
||
<th>Hostname</th><th>State</th><th>Routes</th><th>Visitors</th><th>Cert expiry</th><th>Created</th>
|
||
</tr></thead><tbody></tbody></table>
|
||
|
||
<h2>Bridges</h2>
|
||
<table id="bridges"><thead><tr>
|
||
<th>Session</th><th>Tunnel</th><th>Client</th><th>Routes</th><th>Visitors</th><th>Connected</th>
|
||
</tr></thead><tbody></tbody></table>
|
||
</main>
|
||
<script>
|
||
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
const KEY = "tunneld-admin-token";
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
|
||
(c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
const fmt = (t) => t ? new Date(t).toLocaleString() : "–";
|
||
|
||
const tokenInput = $("token");
|
||
tokenInput.value = localStorage.getItem(KEY) || "";
|
||
|
||
$("auth").addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
localStorage.setItem(KEY, tokenInput.value.trim());
|
||
refresh();
|
||
});
|
||
$("forget").addEventListener("click", () => {
|
||
localStorage.removeItem(KEY);
|
||
tokenInput.value = "";
|
||
refresh();
|
||
});
|
||
|
||
function row(cells) {
|
||
return "<tr>" + cells.map((c) => "<td>" + c + "</td>").join("") + "</tr>";
|
||
}
|
||
|
||
function render(data) {
|
||
const s = data.server;
|
||
$("server-meta").textContent =
|
||
s.domain + " · v" + s.version + " · " + s.go + " · up " +
|
||
Math.floor(s.uptime_seconds / 60) + "m";
|
||
$("t-tunnels").textContent = data.totals.tunnels;
|
||
$("t-online").textContent = data.totals.online;
|
||
$("t-bridges").textContent = data.totals.bridges;
|
||
$("t-visitors").textContent = data.totals.visitors;
|
||
$("t-ca").textContent = fmt(data.ca.expires);
|
||
|
||
$("tunnels").querySelector("tbody").innerHTML = (data.tunnels || []).length
|
||
? data.tunnels.map((t) => row([
|
||
esc(t.hostname),
|
||
'<span class="pill ' + (t.state === "online" ? "online" : "offline") + '">' + esc(t.state) + "</span>",
|
||
esc((t.routes || []).join(", ")) || '<span class="dim">–</span>',
|
||
t.visitors,
|
||
fmt(t.certificate_expiry),
|
||
fmt(t.created_at),
|
||
])).join("")
|
||
: '<tr><td colspan="6" class="empty">No tunnels yet.</td></tr>';
|
||
|
||
$("bridges").querySelector("tbody").innerHTML = (data.bridges || []).length
|
||
? data.bridges.map((b) => row([
|
||
esc(b.session),
|
||
esc(b.tunnel),
|
||
esc(b.client_version) || '<span class="dim">–</span>',
|
||
esc((b.routes || []).join(", ")) || '<span class="dim">–</span>',
|
||
b.visitors + " / " + b.max_conns,
|
||
fmt(b.connected_at),
|
||
])).join("")
|
||
: '<tr><td colspan="6" class="empty">No bridges attached.</td></tr>';
|
||
}
|
||
|
||
async function refresh() {
|
||
const token = localStorage.getItem(KEY) || "";
|
||
const err = $("error");
|
||
if (!token) { err.textContent = "Enter the ADMIN_TOKEN to view the dashboard."; return; }
|
||
try {
|
||
const res = await fetch("/api/admin/overview", {
|
||
headers: { "Authorization": "Bearer " + token },
|
||
});
|
||
if (res.status === 401) { err.textContent = "Invalid admin token."; return; }
|
||
if (res.status === 503) { err.textContent = "Server has no ADMIN_TOKEN configured."; return; }
|
||
if (!res.ok) { err.textContent = "Error " + res.status + " from the server."; return; }
|
||
err.textContent = "";
|
||
render(await res.json());
|
||
} catch (e) {
|
||
err.textContent = "Request failed: " + e.message;
|
||
}
|
||
}
|
||
|
||
refresh();
|
||
setInterval(refresh, 5000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|