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

@@ -0,0 +1,66 @@
import axios, { type AxiosError } from 'axios'
import { useAuthStore } from '../stores/auth'
// edgeguard-api lives at /api/v1 relative to whichever vhost the UI is
// being served from (HAProxy proxies /api/* through to 127.0.0.1:9443).
// In dev, vite's proxy points /api → http://127.0.0.1:9443.
const apiClient = axios.create({
baseURL: '/api/v1',
headers: { 'Content-Type': 'application/json' },
// Cookies carry the session — credentials must travel by default
// even on same-origin (vite proxy is technically a different origin).
withCredentials: true,
})
// Envelope unwrap helper for response.data.data callers. The backend
// always wraps payloads as {data, error, message}; this strips the
// envelope so callers can `.data.<field>` directly.
export function isEnvelope(v: unknown): v is { data: unknown; error: string | null; message: string } {
return (
typeof v === 'object' &&
v !== null &&
'data' in v &&
'error' in v &&
'message' in v
)
}
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const path = window.location.pathname
// 401 → drop local session, kick to /login (unless we're already
// there; setup-mode is a separate gate).
if (error.response?.status === 401 && !path.startsWith('/login') && !path.startsWith('/setup')) {
useAuthStore.getState().clear()
window.location.replace('/login')
}
// 503 setup_required → kick to /setup so the wizard takes over.
if (
error.response?.status === 503 &&
isEnvelope(error.response.data) &&
error.response.data.error === 'setup_required' &&
!path.startsWith('/setup')
) {
window.location.replace('/setup')
}
// Translate envelope errors into a plain Error with .message + .status
// so callers don't have to inspect axios-shape responses.
if (error.response && isEnvelope(error.response.data)) {
const env = error.response.data
const msg = env.message || env.error || error.message
const wrapped = new Error(msg) as Error & { status?: number; response?: typeof error.response }
wrapped.status = error.response.status
wrapped.response = error.response
return Promise.reject(wrapped)
}
return Promise.reject(error)
},
)
export default apiClient