Rebuild the admin dashboard as a Svelte 5 + TypeScript app

Frontend in web/ (Vite via bun, pure CSS, no component libraries),
built to internal/dashboard/dist and embedded into the binary; the
Dockerfile builds it in a bun stage. Old static dashboard.html removed.
This commit is contained in:
2026-10-25 16:34:21 +08:00
parent f5fcdd7bbb
commit bc52af33cf
24 changed files with 760 additions and 183 deletions
+22
View File
@@ -0,0 +1,22 @@
// Package dashboard embeds the built Svelte admin dashboard from web/.
package dashboard
import (
"embed"
"io/fs"
)
// dist holds the built frontend. A committed .gitkeep keeps the embed
// pattern valid in checkouts where `bun run build` has not run yet.
//
//go:embed all:dist
var files embed.FS
// FS returns the dashboard files rooted at the build output directory.
func FS() fs.FS {
sub, err := fs.Sub(files, "dist")
if err != nil {
return files
}
return sub
}
+19 -8
View File
@@ -2,16 +2,15 @@ package server
import (
"crypto/subtle"
_ "embed"
"io/fs"
"net/http"
"runtime"
"sort"
"strings"
"time"
)
//go:embed dashboard.html
var dashboardHTML []byte
"gitea.tzgyn.dev/TZGyn/tunnel/internal/dashboard"
)
// ---- admin API: GET /api/admin/overview, bearer ADMIN_TOKEN.
@@ -153,9 +152,21 @@ func (a *API) caExpiry() string {
return expiryString(ca.cert.NotAfter)
}
// dashboard serves the admin page; data comes from /api/admin/overview.
// dashboard serves the built Svelte admin page and its assets; data comes
// from /api/admin/overview. Without a built frontend (only the .gitkeep
// placeholder), it answers 503 with the build command.
func (a *API) dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Robots-Tag", "noindex")
w.Write(dashboardHTML)
dist := dashboard.FS()
if r.URL.Path == "/" || r.URL.Path == "/admin" {
page, err := fs.ReadFile(dist, "index.html")
if err != nil {
http.Error(w, "dashboard not built: run `bun install && bun run build` in web/", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Robots-Tag", "noindex")
w.Write(page)
return
}
http.FileServer(http.FS(dist)).ServeHTTP(w, r)
}
+10 -4
View File
@@ -2,11 +2,15 @@ package server
import (
"encoding/json"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.tzgyn.dev/TZGyn/tunnel/internal/dashboard"
)
func TestAdminOverview(t *testing.T) {
@@ -98,19 +102,21 @@ func TestDashboardPage(t *testing.T) {
srv := httptest.NewServer(api)
defer srv.Close()
if _, err := fs.Stat(dashboard.FS(), "index.html"); err != nil {
t.Skip("dashboard not built (run `bun install && bun run build` in web/)")
}
for _, path := range []string{"/", "/admin"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
body := make([]byte, 512)
n, _ := resp.Body.Read(body)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
t.Errorf("%s: got %d %q", path, resp.StatusCode, resp.Header.Get("Content-Type"))
}
if !strings.Contains(string(body[:n]), "tunneld admin") {
t.Errorf("%s: unexpected body", path)
if !strings.Contains(string(body), `id="app"`) {
t.Errorf("%s: body does not look like the built dashboard", path)
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"ok":true}`))
case path == "/openapi.json" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, json.RawMessage(openapiStub))
case (path == "/" || path == "/admin") && r.Method == http.MethodGet:
case (path == "/" || path == "/admin" || strings.HasPrefix(path, "/assets/")) && r.Method == http.MethodGet:
a.dashboard(w, r)
case path == "/api/admin/overview" && r.Method == http.MethodGet:
a.adminOverviewHandler(w, r)
-162
View File
@@ -1,162 +0,0 @@
<!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>