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
+3
View File
@@ -2,3 +2,6 @@
cmd/tunneld/tunneld cmd/tunneld/tunneld
.local/ .local/
.DS_Store .DS_Store
web/node_modules/
internal/dashboard/dist/*
!internal/dashboard/dist/.gitkeep
+12 -3
View File
@@ -30,9 +30,15 @@ what we take vs. skip). Never vendor it into the build.
checked before auth (404 before 401), body JSON syntax before everything checked before auth (404 before 401), body JSON syntax before everything
- `internal/server/bridge.go` — WebSocket bridge sessions: attach, route - `internal/server/bridge.go` — WebSocket bridge sessions: attach, route
claiming (one owner per route), heartbeats, idle retirement, visitor pumps claiming (one owner per route), heartbeats, idle retirement, visitor pumps
- `internal/server/admin.go` + `dashboard.html` — the admin dashboard served - `internal/server/admin.go` — the admin dashboard handlers: `GET
at `/` (embedded HTML, no build step) and `GET /api/admin/overview`, both /api/admin/overview` (bearer `ADMIN_TOKEN`, 503 when unset, 401 when
behind the `ADMIN_TOKEN` bearer token (503 when unset, 401 when wrong) wrong) and the page/assets served from the embedded frontend
- `web/` — the dashboard frontend: Svelte 5 (runes) + TypeScript, Vite via
bun, pure CSS, no component libraries. `bun run build` emits to
`internal/dashboard/dist` (gitignored; the committed `.gitkeep` keeps
`go:embed all:dist` valid, and the handler answers 503 until built).
`internal/dashboard` embeds the output; the Dockerfile builds it in a bun
stage before the Go build
- `internal/server/relay.go` — public TLS listener: parses the ClientHello, - `internal/server/relay.go` — public TLS listener: parses the ClientHello,
terminates the domain itself, passes tunnel hostnames to bridges untouched terminates the domain itself, passes tunnel hostnames to bridges untouched
@@ -40,6 +46,9 @@ what we take vs. skip). Never vendor it into the build.
- `go build ./...` / `go vet ./...` / `gofmt -l .` — must stay clean - `go build ./...` / `go vet ./...` / `gofmt -l .` — must stay clean
- `go test ./...` - `go test ./...`
- Frontend: `cd web && bun install && bun run check && bun run build` — the
dashboard test skips when `internal/dashboard/dist` has no build (only the
committed `.gitkeep`)
- Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`, - Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`,
then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api` then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api`
with the official CLI. `go build` drops a binary next to the source — it is with the official CLI. `go build` drops a binary next to the source — it is
+8
View File
@@ -3,12 +3,20 @@
# Deploy on Coolify (or any Docker host) with host port mappings # Deploy on Coolify (or any Docker host) with host port mappings
# 443 -> 8443 and 80 -> 8080 and a volume on /app/data; see README.md. # 443 -> 8443 and 80 -> 8080 and a volume on /app/data; see README.md.
FROM oven/bun:1 AS web
WORKDIR /src/web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web ./
RUN bun run build
FROM golang:1.27-alpine AS build FROM golang:1.27-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd ./cmd COPY cmd ./cmd
COPY internal ./internal COPY internal ./internal
COPY --from=web /src/internal/dashboard/dist ./internal/dashboard/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/tunneld ./cmd/tunneld RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/tunneld ./cmd/tunneld
FROM alpine:3.21 FROM alpine:3.21
+14 -5
View File
@@ -54,13 +54,22 @@ Every flag also reads an environment variable (`tunneld -h`):
## Admin dashboard ## Admin dashboard
With `ADMIN_TOKEN` set, `https://<domain>/` (and `/admin`) serves a small With `ADMIN_TOKEN` set, `https://<domain>/` (and `/admin`) serves the admin
self-contained dashboard: server uptime and version, CA expiry, tunnels with dashboard: server uptime and version, CA expiry, tunnels with
state/routes/cert expiry, and attached bridges with client version and live state/routes/cert expiry, and attached bridges with client version and live
connection counts. The page stores the token in `localStorage` and polls connection counts. It is a Svelte 5 + TypeScript app (pure CSS, no component
libraries) in `web/`, built with bun and embedded into the binary; it polls
`GET /api/admin/overview` (`Authorization: Bearer <ADMIN_TOKEN>`) every 5 `GET /api/admin/overview` (`Authorization: Bearer <ADMIN_TOKEN>`) every 5
seconds. Without `ADMIN_TOKEN` the endpoint answers 503 and the page is seconds and stores the token in `localStorage`. Without `ADMIN_TOKEN` the
inert; with a wrong token, 401. endpoint answers 503 and the page is inert; with a wrong token, 401.
```bash
cd web && bun install
bun run build # writes internal/dashboard/dist (gitignored), embedded by go:embed
bun run check # svelte-check
```
The Dockerfile builds the frontend in a bun stage before the Go build.
## Deploy on Coolify (or any Docker host) ## Deploy on Coolify (or any Docker host)
+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 ( import (
"crypto/subtle" "crypto/subtle"
_ "embed" "io/fs"
"net/http" "net/http"
"runtime" "runtime"
"sort" "sort"
"strings" "strings"
"time" "time"
)
//go:embed dashboard.html "gitea.tzgyn.dev/TZGyn/tunnel/internal/dashboard"
var dashboardHTML []byte )
// ---- admin API: GET /api/admin/overview, bearer ADMIN_TOKEN. // ---- admin API: GET /api/admin/overview, bearer ADMIN_TOKEN.
@@ -153,9 +152,21 @@ func (a *API) caExpiry() string {
return expiryString(ca.cert.NotAfter) 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) { func (a *API) dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") dist := dashboard.FS()
w.Header().Set("X-Robots-Tag", "noindex") if r.URL.Path == "/" || r.URL.Path == "/admin" {
w.Write(dashboardHTML) 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 ( import (
"encoding/json" "encoding/json"
"io"
"io/fs"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.tzgyn.dev/TZGyn/tunnel/internal/dashboard"
) )
func TestAdminOverview(t *testing.T) { func TestAdminOverview(t *testing.T) {
@@ -98,19 +102,21 @@ func TestDashboardPage(t *testing.T) {
srv := httptest.NewServer(api) srv := httptest.NewServer(api)
defer srv.Close() 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"} { for _, path := range []string{"/", "/admin"} {
resp, err := http.Get(srv.URL + path) resp, err := http.Get(srv.URL + path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
body := make([]byte, 512) body, _ := io.ReadAll(resp.Body)
n, _ := resp.Body.Read(body)
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Type"), "text/html") { 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")) t.Errorf("%s: got %d %q", path, resp.StatusCode, resp.Header.Get("Content-Type"))
} }
if !strings.Contains(string(body[:n]), "tunneld admin") { if !strings.Contains(string(body), `id="app"`) {
t.Errorf("%s: unexpected body", path) 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}`)) w.Write([]byte(`{"ok":true}`))
case path == "/openapi.json" && r.Method == http.MethodGet: case path == "/openapi.json" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, json.RawMessage(openapiStub)) 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) a.dashboard(w, r)
case path == "/api/admin/overview" && r.Method == http.MethodGet: case path == "/api/admin/overview" && r.Method == http.MethodGet:
a.adminOverviewHandler(w, r) 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>
+159
View File
@@ -0,0 +1,159 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "tunneld-dashboard",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
"svelte-check": "^4.7.7",
"typescript": "~6",
"vite": "^8.3.5",
},
},
},
"packages": {
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.155.0", "", {}, "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.15", "", { "os": "android", "cpu": "arm" }, "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.15", "", { "os": "android", "cpu": "arm64" }, "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.15", "", { "os": "linux", "cpu": "arm" }, "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.15", "", { "os": "none", "cpu": "arm64" }, "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.1", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.4.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rolldown": ["rolldown@1.2.15", "", { "dependencies": { "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-x64-msvc": "1.2.15" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"svelte": ["svelte@5.57.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ=="],
"svelte-check": ["svelte-check@4.7.7", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-Ge2IMdeTpbJuzNFGRiiMg0RWgbScysKQu7xwcyFJ22+DU9F5nPX51+Q3gHmGqRxYQMx2knreVWt5oCtfe0vf2g=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
}
}
+13
View File
@@ -0,0 +1,13 @@
<!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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "tunneld-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
"svelte-check": "^4.7.7",
"typescript": "~6",
"vite": "^8.3.5"
}
}
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { ApiError, fetchOverview, type Overview } from "./lib/api";
import Login from "./lib/Login.svelte";
import SummaryCards from "./lib/SummaryCards.svelte";
import TunnelsTable from "./lib/TunnelsTable.svelte";
import BridgesTable from "./lib/BridgesTable.svelte";
const KEY = "tunneld-admin-token";
const REFRESH_MS = 5000;
let token = $state(localStorage.getItem(KEY) ?? "");
let overview = $state<Overview | null>(null);
let error = $state("");
function useToken(value: string) {
token = value.trim();
if (token) localStorage.setItem(KEY, token);
else localStorage.removeItem(KEY);
}
function forget() {
token = "";
localStorage.removeItem(KEY);
overview = null;
error = "";
}
$effect(() => {
if (!token) {
overview = null;
return;
}
let active = true;
const load = async () => {
try {
const data = await fetchOverview(token);
if (!active) return;
overview = data;
error = "";
} catch (e) {
if (!active) return;
error = e instanceof ApiError ? e.message : `Request failed: ${(e as Error).message}`;
if (e instanceof ApiError && e.status === 401) overview = null;
}
};
load();
const id = setInterval(load, REFRESH_MS);
return () => {
active = false;
clearInterval(id);
};
});
</script>
<header>
<h1>tunneld admin</h1>
<span class="meta">
{#if overview}
{overview.server.domain} · v{overview.server.version} · {overview.server.go}
{/if}
</span>
<span class="spacer"></span>
<Login hasToken={token !== ""} onuse={useToken} onforget={forget} />
</header>
<main>
<p class="error" class:hidden={error === ""}>{error}</p>
{#if !token}
<p class="dim">Enter the ADMIN_TOKEN to view the dashboard.</p>
{:else if overview}
<SummaryCards server={overview.server} ca={overview.ca} totals={overview.totals} />
<h2>Tunnels</h2>
<TunnelsTable tunnels={overview.tunnels} />
<h2>Bridges</h2>
<BridgesTable bridges={overview.bridges} />
{/if}
</main>
+159
View File
@@ -0,0 +1,159 @@
: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;
}
.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: 0.06em;
}
.card .value {
font-size: 22px;
margin-top: 4px;
}
h2 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.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;
}
.pill.online {
color: #3fb950;
border-color: #238636;
background: #0f2417;
}
.pill.offline {
color: #8b949e;
border-color: #30363d;
background: #161b22;
}
.empty {
color: #8b949e;
padding: 12px;
}
input {
background: #0d1117;
border: 1px solid #30363d;
color: #e6edf3;
border-radius: 6px;
padding: 6px 10px;
font: inherit;
width: 320px;
}
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: 0 0 16px;
white-space: pre-wrap;
}
.error.hidden {
display: none;
}
.dim {
color: #8b949e;
}
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Bridge } from "./api";
import { fmtDate, fmtRoutes } from "./format";
let { bridges }: { bridges: Bridge[] | null } = $props();
</script>
<table>
<thead>
<tr>
<th>Session</th><th>Tunnel</th><th>Client</th><th>Routes</th><th>Visitors</th><th>Connected</th>
</tr>
</thead>
<tbody>
{#if bridges && bridges.length > 0}
{#each bridges as bridge (bridge.session)}
<tr>
<td>{bridge.session}</td>
<td>{bridge.tunnel}</td>
<td class="dim">{bridge.client_version || "–"}</td>
<td class="dim">{fmtRoutes(bridge.routes)}</td>
<td>{bridge.visitors} / {bridge.max_conns}</td>
<td>{fmtDate(bridge.connected_at)}</td>
</tr>
{/each}
{:else}
<tr><td colspan="6" class="empty">No bridges attached.</td></tr>
{/if}
</tbody>
</table>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
let { hasToken, onuse, onforget }: {
hasToken: boolean;
onuse: (token: string) => void;
onforget: () => void;
} = $props();
let value = $state("");
function submit(event: SubmitEvent) {
event.preventDefault();
onuse(value);
value = "";
}
</script>
<form onsubmit={submit}>
<input
type="password"
placeholder="ADMIN_TOKEN"
autocomplete="off"
bind:value
aria-label="admin token"
/>
<button type="submit">Use token</button>
{#if hasToken}
<button type="button" onclick={onforget}>Forget</button>
{/if}
</form>
<style>
form {
display: flex;
gap: 8px;
align-items: center;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import type { CaInfo, ServerInfo, Totals } from "./api";
import { fmtDate, fmtUptime } from "./format";
let { server, ca, totals }: { server: ServerInfo; ca: CaInfo; totals: Totals } = $props();
</script>
<div class="cards">
<div class="card"><div class="label">Tunnels</div><div class="value">{totals.tunnels}</div></div>
<div class="card"><div class="label">Online</div><div class="value">{totals.online}</div></div>
<div class="card"><div class="label">Bridges</div><div class="value">{totals.bridges}</div></div>
<div class="card"><div class="label">Visitors</div><div class="value">{totals.visitors}</div></div>
<div class="card"><div class="label">CA expires</div><div class="value small">{fmtDate(ca.expires)}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value small">{fmtUptime(server.uptime_seconds)}</div></div>
</div>
<style>
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.value.small {
font-size: 14px;
margin-top: 8px;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Tunnel } from "./api";
import { fmtDate, fmtRoutes } from "./format";
let { tunnels }: { tunnels: Tunnel[] | null } = $props();
</script>
<table>
<thead>
<tr>
<th>Hostname</th><th>State</th><th>Routes</th><th>Visitors</th><th>Cert expiry</th><th>Created</th>
</tr>
</thead>
<tbody>
{#if tunnels && tunnels.length > 0}
{#each tunnels as tunnel (tunnel.id)}
<tr>
<td>{tunnel.hostname}</td>
<td><span class="pill {tunnel.state}">{tunnel.state}</span></td>
<td class="dim">{fmtRoutes(tunnel.routes)}</td>
<td>{tunnel.visitors}</td>
<td>{fmtDate(tunnel.certificate_expiry)}</td>
<td>{fmtDate(tunnel.created_at)}</td>
</tr>
{/each}
{:else}
<tr><td colspan="6" class="empty">No tunnels yet.</td></tr>
{/if}
</tbody>
</table>
+67
View File
@@ -0,0 +1,67 @@
// Types mirroring GET /api/admin/overview (internal/server/admin.go).
export interface ServerInfo {
domain: string;
version: string;
go: string;
uptime_seconds: number;
}
export interface CaInfo {
expires: string;
}
export interface Totals {
tunnels: number;
online: number;
bridges: number;
visitors: number;
}
export interface Tunnel {
id: string;
hostname: string;
state: "online" | "offline";
routes: string[] | null;
visitors: number;
certificate_expiry?: string;
created_at?: string;
}
export interface Bridge {
session: string;
tunnel: string;
client_version?: string;
routes: string[] | null;
visitors: number;
max_conns: number;
connected_at: string;
}
export interface Overview {
server: ServerInfo;
ca: CaInfo;
totals: Totals;
tunnels: Tunnel[] | null;
bridges: Bridge[] | null;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
export async function fetchOverview(token: string): Promise<Overview> {
const res = await fetch("/api/admin/overview", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) throw new ApiError(401, "Invalid admin token.");
if (res.status === 503) throw new ApiError(503, "Server has no ADMIN_TOKEN configured.");
if (!res.ok) throw new ApiError(res.status, `Error ${res.status} from the server.`);
return (await res.json()) as Overview;
}
+17
View File
@@ -0,0 +1,17 @@
export function fmtDate(value?: string): string {
return value ? new Date(value).toLocaleString() : "–";
}
export function fmtUptime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s % 60}s`;
}
export function fmtRoutes(routes: string[] | null): string {
return routes && routes.length > 0 ? routes.join(", ") : "–";
}
+5
View File
@@ -0,0 +1,5 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
export default mount(App, { target: document.getElementById("app")! });
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["svelte", "vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// The build lands inside the Go package that embeds it.
export default defineConfig({
plugins: [svelte()],
build: {
outDir: "../internal/dashboard/dist",
emptyOutDir: true,
},
});