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:
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(", ") : "–";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./app.css";
|
||||
|
||||
export default mount(App, { target: document.getElementById("app")! });
|
||||
Reference in New Issue
Block a user