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,71 @@
import { Alert, Button, message } from 'antd'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import apiClient, { isEnvelope } from '../api/client'
interface PackageVersions {
[key: string]: string
}
// hasUpdate compares an *_installed value against its *_available
// counterpart. Returns the package base name + both versions when an
// update is pending; null otherwise. We pick the first package that
// has a real upgrade — matching enconf's "show one upgrade hint at
// a time" UX.
function pickUpdate(v: PackageVersions): { pkg: string; installed: string; available: string } | null {
for (const key of Object.keys(v)) {
if (!key.endsWith('_installed')) continue
const pkg = key.replace('_installed', '')
const installed = v[key]
const available = v[`${pkg}_available`]
if (installed && available && installed !== available) {
return { pkg, installed, available }
}
}
return null
}
export default function UpdateBanner() {
const { t } = useTranslation()
const [applying, setApplying] = useState(false)
const { data } = useQuery({
queryKey: ['system', 'package-versions'],
queryFn: async () => {
const r = await apiClient.get('/system/package-versions')
if (isEnvelope(r.data)) return r.data.data as PackageVersions
return {} as PackageVersions
},
refetchInterval: 5 * 60 * 1000, // 5 min poll matches enconf cadence
})
const upgrade = useMutation({
mutationFn: async () => {
await apiClient.post('/system/upgrade')
},
onSuccess: () => {
setApplying(true)
message.success(t('update.started'))
},
})
if (!data) return null
const u = pickUpdate(data)
if (!u) return null
return (
<Alert
type="warning"
banner
showIcon
message={t('update.available', u)}
action={
<Button size="small" type="primary" loading={applying} onClick={() => upgrade.mutate()}>
{applying ? t('update.applying') : t('update.applyNow')}
</Button>
}
/>
)
}