Skip to content

Commit ca45c5e

Browse files
committed
feat(admin): React + Vite SPA for the admin dashboard
Phase P3 of 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 but StaticFS was wired as nil, leaving /admin/* answering 404 for any non-API path. This PR adds the React bundle + flips StaticFS on. Source layout (web/admin/): - React 18 + TypeScript + Vite per design doc Section 5.1 - Tailwind CSS for tokens; no UI framework dependency (kept the bundle at ~60 KB gzip per Section 7.2's ~150 KB target) - vite.config.ts writes the build output straight into internal/admin/dist so `npm run build && go build` produces a single binary with the latest SPA Pages (Section 5.2): - /login - SigV4 access-key + secret-key form, server-side JWT Cookie issued, status-specific error messages for 401/403/429 - / (dashboard) - cluster overview, raft groups, summary cards - /dynamo - table list + Create dialog (full role only) - /dynamo/:name - schema + GSIs + Delete confirmation modal - /s3 - bucket list + Create dialog (endpoint pending, renders graceful "endpoint pending" notice on 404) - /s3/:name - bucket meta + ACL toggle + Delete (same) Auth (Section 6): - Cookie-based session (admin_session, HttpOnly) handled by browser - CSRF double-submit: admin_csrf cookie value echoed in X-Admin-CSRF header on every POST/PUT/DELETE (apiFetch in src/api/client.ts) - Read-only sessions hide Create/Delete affordances and surface a RequireFullAccess notice; backend re-evaluates the role - Session expiry tracked in sessionStorage; expires_at trip clears in-memory state and redirects to /login Backend wiring: - main_admin.go: load admin.StaticFS() and pass to ServerDeps so /admin/assets/* and the SPA fallback start serving real files - internal/admin/embed.go: //go:embed all:dist on the build output - .gitignore: keep the placeholder index.html committed (so go:embed always has at least one file in a fresh clone), exclude hashed Vite assets and the npm/vite caches - committed bundles invariably drift from source Verification: - npm run build: 192 KB JS / 60 KB gzip + 14 KB CSS - npm run lint (tsc strict, noUnusedLocals, noUncheckedSideEffects) - go build ./... - go test -race ./internal/admin/... - golangci-lint run ./internal/admin/... S3 admin endpoints (CreateBucket / ListBuckets / PutBucketAcl / DeleteBucket) are not yet wired on the Go side; the SPA pages render a soft "endpoint pending" notice on the 404 they currently get and will populate transparently once the handlers ship.
1 parent 76cb788 commit ca45c5e

31 files changed

Lines changed: 4600 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)