Files
tunnel/internal/server/dashboard.html
T

163 lines
6.7 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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>