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:
@@ -2,3 +2,6 @@
|
||||
cmd/tunneld/tunneld
|
||||
.local/
|
||||
.DS_Store
|
||||
web/node_modules/
|
||||
internal/dashboard/dist/*
|
||||
!internal/dashboard/dist/.gitkeep
|
||||
|
||||
@@ -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
|
||||
- `internal/server/bridge.go` — WebSocket bridge sessions: attach, route
|
||||
claiming (one owner per route), heartbeats, idle retirement, visitor pumps
|
||||
- `internal/server/admin.go` + `dashboard.html` — the admin dashboard served
|
||||
at `/` (embedded HTML, no build step) and `GET /api/admin/overview`, both
|
||||
behind the `ADMIN_TOKEN` bearer token (503 when unset, 401 when wrong)
|
||||
- `internal/server/admin.go` — the admin dashboard handlers: `GET
|
||||
/api/admin/overview` (bearer `ADMIN_TOKEN`, 503 when unset, 401 when
|
||||
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,
|
||||
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 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`,
|
||||
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
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd ./cmd
|
||||
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
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
@@ -54,13 +54,22 @@ Every flag also reads an environment variable (`tunneld -h`):
|
||||
|
||||
## Admin dashboard
|
||||
|
||||
With `ADMIN_TOKEN` set, `https://<domain>/` (and `/admin`) serves a small
|
||||
self-contained dashboard: server uptime and version, CA expiry, tunnels with
|
||||
With `ADMIN_TOKEN` set, `https://<domain>/` (and `/admin`) serves the admin
|
||||
dashboard: server uptime and version, CA expiry, tunnels with
|
||||
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
|
||||
seconds. Without `ADMIN_TOKEN` the endpoint answers 503 and the page is
|
||||
inert; with a wrong token, 401.
|
||||
seconds and stores the token in `localStorage`. Without `ADMIN_TOKEN` the
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")! });
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user