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
+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")! });