diff --git a/.gitignore b/.gitignore index 5ab0f7e..54c4961 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ cmd/tunneld/tunneld .local/ .DS_Store +web/node_modules/ +internal/dashboard/dist/* +!internal/dashboard/dist/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index b0564d1..50ea043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 807b461..52a7c7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 3735274..58b04db 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,22 @@ Every flag also reads an environment variable (`tunneld -h`): ## Admin dashboard -With `ADMIN_TOKEN` set, `https:///` (and `/admin`) serves a small -self-contained dashboard: server uptime and version, CA expiry, tunnels with +With `ADMIN_TOKEN` set, `https:///` (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 `) 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) diff --git a/internal/dashboard/embed.go b/internal/dashboard/embed.go new file mode 100644 index 0000000..b4a5c23 --- /dev/null +++ b/internal/dashboard/embed.go @@ -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 +} diff --git a/internal/server/admin.go b/internal/server/admin.go index 8b847c7..97c351f 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -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) } diff --git a/internal/server/admin_test.go b/internal/server/admin_test.go index 5240fa6..bc74e66 100644 --- a/internal/server/admin_test.go +++ b/internal/server/admin_test.go @@ -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) } } } diff --git a/internal/server/api.go b/internal/server/api.go index 7f87ed2..932e37e 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -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) diff --git a/internal/server/dashboard.html b/internal/server/dashboard.html deleted file mode 100644 index 70cc409..0000000 --- a/internal/server/dashboard.html +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - -tunneld admin - - - -
-

tunneld admin

- - -
- - - -
-
-
-
-
-
Tunnels
–
-
Online
–
-
Bridges
–
-
Visitors
–
-
CA expires
–
-
- -

Tunnels

- - -
HostnameStateRoutesVisitorsCert expiryCreated
- -

Bridges

- - -
SessionTunnelClientRoutesVisitorsConnected
-
- - - diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..36ce42a --- /dev/null +++ b/web/bun.lock @@ -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=="], + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..594af47 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + tunneld admin + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f36bb26 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..0ba692e --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,77 @@ + + +
+

tunneld admin

+ + {#if overview} + {overview.server.domain} · v{overview.server.version} · {overview.server.go} + {/if} + + + +
+ +
+

{error}

+ {#if !token} +

Enter the ADMIN_TOKEN to view the dashboard.

+ {:else if overview} + +

Tunnels

+ +

Bridges

+ + {/if} +
diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..312a964 --- /dev/null +++ b/web/src/app.css @@ -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; +} diff --git a/web/src/lib/BridgesTable.svelte b/web/src/lib/BridgesTable.svelte new file mode 100644 index 0000000..5807a5b --- /dev/null +++ b/web/src/lib/BridgesTable.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + {#if bridges && bridges.length > 0} + {#each bridges as bridge (bridge.session)} + + + + + + + + + {/each} + {:else} + + {/if} + +
SessionTunnelClientRoutesVisitorsConnected
{bridge.session}{bridge.tunnel}{bridge.client_version || "–"}{fmtRoutes(bridge.routes)}{bridge.visitors} / {bridge.max_conns}{fmtDate(bridge.connected_at)}
No bridges attached.
diff --git a/web/src/lib/Login.svelte b/web/src/lib/Login.svelte new file mode 100644 index 0000000..93ea3a1 --- /dev/null +++ b/web/src/lib/Login.svelte @@ -0,0 +1,37 @@ + + +
+ + + {#if hasToken} + + {/if} +
+ + diff --git a/web/src/lib/SummaryCards.svelte b/web/src/lib/SummaryCards.svelte new file mode 100644 index 0000000..03a4e6d --- /dev/null +++ b/web/src/lib/SummaryCards.svelte @@ -0,0 +1,28 @@ + + +
+
Tunnels
{totals.tunnels}
+
Online
{totals.online}
+
Bridges
{totals.bridges}
+
Visitors
{totals.visitors}
+
CA expires
{fmtDate(ca.expires)}
+
Uptime
{fmtUptime(server.uptime_seconds)}
+
+ + diff --git a/web/src/lib/TunnelsTable.svelte b/web/src/lib/TunnelsTable.svelte new file mode 100644 index 0000000..c630222 --- /dev/null +++ b/web/src/lib/TunnelsTable.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + {#if tunnels && tunnels.length > 0} + {#each tunnels as tunnel (tunnel.id)} + + + + + + + + + {/each} + {:else} + + {/if} + +
HostnameStateRoutesVisitorsCert expiryCreated
{tunnel.hostname}{tunnel.state}{fmtRoutes(tunnel.routes)}{tunnel.visitors}{fmtDate(tunnel.certificate_expiry)}{fmtDate(tunnel.created_at)}
No tunnels yet.
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..aef8105 --- /dev/null +++ b/web/src/lib/api.ts @@ -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 { + 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; +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..50b6db8 --- /dev/null +++ b/web/src/lib/format.ts @@ -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(", ") : "–"; +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..727d356 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,5 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./app.css"; + +export default mount(App, { target: document.getElementById("app")! }); diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..d0e6448 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..7caf75e --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..1fc3bba --- /dev/null +++ b/web/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, + }, +});