Skip to content

Commit ecd7f66

Browse files
authored
feat(admin): React + Vite SPA for the admin dashboard (P3) (#649)
## Summary Phase **P3** of [docs/design/2026_04_24_proposed_admin_dashboard.md](https://github.com/bootjp/elastickv/blob/main/docs/design/2026_04_24_proposed_admin_dashboard.md). The Go side (auth / JWT / cluster handler / dynamo handler / follower→leader forwarding / `embed.go` skeleton) shipped in earlier PRs (#644 et al.) but `StaticFS` was wired as `nil`, so `/admin/*` answered 404 for any non-API path. This PR ships the React bundle and flips `StaticFS` on. - React 18 + TypeScript + Vite, Tailwind CSS for tokens (Section 5.1). No UI framework dependency — bundle stays at **~60 KB gzip** vs the doc's 150 KB ceiling (Section 7.2). - `vite.config.ts` writes its build output straight into `internal/admin/dist/` so `npm run build && go build` produces a single-binary release. - Five pages per Section 5.2: Login, Dashboard, DynamoDB list/detail (with Create + Delete dialogs), S3 list/detail (placeholder — endpoints not yet wired, the SPA renders a soft "endpoint pending" notice on the 404). - Cookie-based session (Section 6): `admin_session` (HttpOnly) handled by the browser; `admin_csrf` value echoed in `X-Admin-CSRF` for every POST/PUT/DELETE per the double-submit pattern. Read-only sessions hide write affordances; backend re-evaluates the role. - `.gitignore` keeps the placeholder `internal/admin/dist/index.html` committed (so `//go:embed all:dist` always has a match in a fresh clone) and excludes the hashed Vite asset outputs and npm/vite caches — committed bundles drift from source. ## Test plan - [x] `cd web/admin && npm install && npm run build` — 192 KB JS / 60 KB gzip + 14 KB CSS - [x] `cd web/admin && npm run lint` — `tsc --strict --noUnusedLocals --noUncheckedSideEffectImports` - [x] `go build ./...` — clean (placeholder + built bundle both compile) - [x] `go test -race -count=1 ./internal/admin/...` - [x] `golangci-lint run ./internal/admin/...` - [ ] Manual smoke: start admin listener with `--adminEnabled --adminAllowInsecureDevCookie --adminListen 127.0.0.1:8080`, visit `http://127.0.0.1:8080/admin/`, log in with a SigV4 access key, confirm dashboard / dynamo list / dynamo detail / dynamo create / dynamo delete + see graceful "endpoint pending" on the S3 pages. ## Notes - **Self-review (5 lenses)** — these changes only ship UI + 1 line of Go wiring (`StaticFS` activation). No replication, MVCC, OCC, or HLC code paths touched. Lenses 1 (data loss) / 2 (concurrency) / 4 (consistency) → no impact. Lens 3 (perf) → +1 cold read of `embed.FS` per startup, ~1 MB binary growth (under the doc's 1–2 MB target). Lens 5 (test coverage) → SPA-side Vitest is documented as a P4 follow-up in the design doc; this PR does not regress any existing Go test. - The S3 admin endpoints, follower→leader forwarding for write paths, and the design-doc lifecycle rename (`proposed_` → `partial_`) are out of scope here and are tracked elsewhere.
2 parents 76cb788 + e3b9555 commit ecd7f66

31 files changed

Lines changed: 4607 additions & 1 deletion

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,16 @@ jepsen/.ssh/
3838
.cache/
3939
.golangci-cache/
4040
server
41+
42+
# Admin SPA build outputs. The placeholder internal/admin/dist/index.html
43+
# is committed so `go build` succeeds in a fresh clone (the //go:embed
44+
# directive needs at least one matching file). Everything else under
45+
# dist/ is regenerated by `cd web/admin && npm run build` and must not
46+
# be committed — committed bundles invariably drift from source.
47+
/internal/admin/dist/assets/
48+
/internal/admin/dist/index-*.js
49+
/internal/admin/dist/index-*.css
50+
51+
# Admin SPA source toolchain
52+
/web/admin/node_modules/
53+
/web/admin/.vite/

internal/admin/dist/index.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>elastickv admin (bundle missing)</title>
7+
<style>
8+
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem; color: #1f2937; }
9+
code, pre { background: #f3f4f6; padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
10+
pre { padding: 0.75rem 1rem; overflow-x: auto; }
11+
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
12+
p { line-height: 1.5; }
13+
</style>
14+
</head>
15+
<body>
16+
<h1>elastickv admin SPA bundle is missing</h1>
17+
<p>This is the placeholder <code>index.html</code> shipped in the repository so that <code>go build</code> succeeds before the React bundle is built.</p>
18+
<p>To populate the real dashboard:</p>
19+
<pre>cd web/admin
20+
npm install
21+
npm run build</pre>
22+
<p>The Vite build writes its output into <code>internal/admin/dist/</code>, replacing this placeholder. Rebuild the Go binary afterwards.</p>
23+
</body>
24+
</html>

internal/admin/embed.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package admin
2+
3+
import (
4+
"embed"
5+
"errors"
6+
"io/fs"
7+
)
8+
9+
// distFS holds the Vite build output for the admin SPA. The directory
10+
// is populated by `npm run build` under web/admin/, which writes its
11+
// output straight into internal/admin/dist (see web/admin/vite.config.ts).
12+
//
13+
// We embed `dist` as a directory rather than a glob so that adding a new
14+
// asset under dist/assets/ does not require touching this file. The
15+
// `all:` prefix is intentional — Vite occasionally emits files whose
16+
// names start with `.` (sourcemaps, tooling artefacts), and the default
17+
// embed selector would silently drop them.
18+
//
19+
//go:embed all:dist
20+
var distFS embed.FS
21+
22+
// StaticFS returns the io/fs.FS that backs /admin/assets/* and the SPA
23+
// fallback. The returned FS is rooted at the embedded `dist` directory,
24+
// so `index.html` resolves to `dist/index.html` and assets resolve to
25+
// `dist/assets/*` — matching the pathing the Router expects.
26+
//
27+
// When the SPA bundle has not been built (only the placeholder
28+
// index.html that ships with the repo is present), the FS is still
29+
// returned: the placeholder renders a short message telling the
30+
// operator how to populate the bundle. Returning nil here would have
31+
// the router answer with JSON 404, which is more confusing than a
32+
// page that explains itself.
33+
func StaticFS() (fs.FS, error) {
34+
sub, err := fs.Sub(distFS, "dist")
35+
if err != nil {
36+
return nil, errors.Join(errors.New("admin: open embedded dist subtree"), err)
37+
}
38+
return sub, nil
39+
}

main_admin.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,10 @@ func buildAdminHTTPServer(adminCfg *admin.Config, creds map[string]string, clust
445445
if err != nil {
446446
return nil, errors.Wrap(err, "build admin verifier")
447447
}
448+
staticFS, err := admin.StaticFS()
449+
if err != nil {
450+
return nil, errors.Wrap(err, "open embedded admin SPA")
451+
}
448452
server, err := admin.NewServer(admin.ServerDeps{
449453
Signer: signer,
450454
Verifier: verifier,
@@ -453,7 +457,7 @@ func buildAdminHTTPServer(adminCfg *admin.Config, creds map[string]string, clust
453457
ClusterInfo: cluster,
454458
Tables: tables,
455459
Forwarder: forwarder,
456-
StaticFS: nil,
460+
StaticFS: staticFS,
457461
AuthOpts: admin.AuthServiceOpts{
458462
InsecureCookie: adminCfg.AllowInsecureDevCookie,
459463
},

web/admin/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
.vite
3+
dist
4+
*.log
5+
*.tsbuildinfo

web/admin/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="referrer" content="no-referrer" />
7+
<title>elastickv admin</title>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" src="/src/main.tsx"></script>
12+
</body>
13+
</html>

0 commit comments

Comments
 (0)