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:
@@ -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 && bun install && 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.
|
||||
|
||||
Reference in New Issue
Block a user