feat(ui): Frontend MVP — React 19 + AntD 6 + Vite + StaticFS-Wiring

Scaffold und Core-Infrastruktur 1:1 nach enconf-Pattern (netcell-
webpanel/management-ui), reduziert auf EdgeGuard-Scope (kein reseller/
customer-Roles, keine codemirror/extensions). Stack: React 19 + AntD 6
+ TS strict + Vite + TanStack-Query + zustand + react-i18next.

Layout: AppLayout (Sider+Header+Content), Sidebar (Dashboard/Domains),
Header (User-Dropdown + Logout). i18n mit de/en common.json.

Pages: Login (POST /auth/login), Setup-Wizard (POST /setup/complete),
Dashboard (Health-Polling + Statistics), Domains (volles CRUD via
TanStack-Query gegen /domains-API). UpdateBanner-Komponente
(/system/package-versions, alle 5 min poll, /system/upgrade trigger)
ist von Tag 1 wie vom User gefordert eingebaut.

API-Wiring: cmd/edgeguard-api/main.go mountUI() — gin StaticFS für
/usr/share/edgeguard/ui/ (overridebar via EDGEGUARD_UI_DIR), echte
Files werden direkt geserved, alle nicht-API-Pfade fallen via
NoRoute auf index.html für React-Router-SPA. Wenn dist/ fehlt:
HTML-Placeholder mit Build-Hinweis.

Verifiziert: bun install + npx tsc -b strict (0 errors) + bun run
build (12 chunks). End-to-end gegen /tmp/eg-api: / serviert echte
React-index.html, /domains SPA-Fallback, /api/v1/* JSON, /assets/*
direkt, /api/v1/nonexistent korrekt 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-09 11:16:04 +02:00
parent 914538eed1
commit b507d2a7d5
26 changed files with 1817 additions and 0 deletions

View File

@@ -10,6 +10,8 @@ import (
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -98,6 +100,8 @@ func main() {
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID).Register(authed)
}
mountUI(r)
log.Printf("edgeguard-api %s listening on %s", version, addr)
srv := &http.Server{Addr: addr, Handler: r}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -105,6 +109,76 @@ func main() {
}
}
// mountUI serves the management UI — Vite-built static assets under
// /usr/share/edgeguard/ui/ — with SPA fallback (any path that isn't
// /api/* or /healthz and isn't a real file → index.html). When the
// dist directory is missing (dev box without `bun run build`), a
// placeholder HTML page is served at /.
func mountUI(r *gin.Engine) {
uiDir := os.Getenv("EDGEGUARD_UI_DIR")
if uiDir == "" {
uiDir = "/usr/share/edgeguard/ui"
}
indexPath := filepath.Join(uiDir, "index.html")
if _, err := os.Stat(indexPath); err != nil {
slog.Warn("UI dist not found, serving placeholder",
"ui_dir", uiDir, "error", err)
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if isAPIPath(path) {
c.Status(http.StatusNotFound)
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(uiPlaceholder))
})
return
}
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if isAPIPath(path) {
c.Status(http.StatusNotFound)
return
}
// Serve real file when one exists for the requested path.
// filepath.Clean blocks `..` traversal; the join still pins
// the result inside uiDir even with shenanigans.
clean := filepath.Clean(path)
if !strings.HasPrefix(clean, "/") {
clean = "/" + clean
}
full := filepath.Join(uiDir, clean)
if !strings.HasPrefix(full, uiDir) {
c.Status(http.StatusForbidden)
return
}
if info, err := os.Stat(full); err == nil && !info.IsDir() {
c.File(full)
return
}
// SPA fallback — React Router renders the right page.
c.File(indexPath)
})
}
// isAPIPath returns true for paths the API owns; UI serves
// everything else. /healthz and /api/health are technically API
// surfaces but don't need to fall through to index.html either.
func isAPIPath(p string) bool {
return strings.HasPrefix(p, "/api/") || p == "/healthz" || p == "/api/health"
}
const uiPlaceholder = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>EdgeGuard</title></head>
<body style="font-family: -apple-system, sans-serif; max-width: 640px; margin: 4em auto; line-height: 1.5;">
<h1>EdgeGuard</h1>
<p>The management UI has not been built yet. From the project root, run:</p>
<pre style="background:#f4f4f4;padding:12px;border-radius:4px;">cd management-ui &amp;&amp; bun install &amp;&amp; bun run build</pre>
<p>Then the same URL will serve the React SPA. The REST API is fully functional at
<code>/api/v1/*</code> regardless.</p>
</body></html>`
// openDBBestEffort opens the pool with a 3s timeout. Returns the
// non-nil error so callers can decide whether to register CRUD or
// degrade gracefully.