Add admin dashboard at / with bearer-token overview API
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user