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,25 @@
import { Layout } from 'antd'
import { Outlet } from 'react-router-dom'
import Header from './Header'
import Sidebar from './Sidebar'
import UpdateBanner from '../UpdateBanner'
const { Sider, Content } = Layout
export default function AppLayout() {
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider width={220} theme="dark">
<Sidebar />
</Sider>
<Layout>
<Header />
<UpdateBanner />
<Content style={{ padding: '24px', background: '#f5f5f5' }}>
<Outlet />
</Content>
</Layout>
</Layout>
)
}

View File

@@ -0,0 +1,44 @@
import { LogoutOutlined, UserOutlined } from '@ant-design/icons'
import { Dropdown, Layout, Space, Typography } from 'antd'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient from '../../api/client'
import { useAuthStore } from '../../stores/auth'
const { Header: AntHeader } = Layout
export default function Header() {
const { t } = useTranslation()
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const clear = useAuthStore((s) => s.clear)
const onLogout = async () => {
try { await apiClient.post('/auth/logout') } catch { /* ignore */ }
clear()
navigate('/login', { replace: true })
}
return (
<AntHeader style={{ background: '#fff', padding: '0 24px', display: 'flex', justifyContent: 'flex-end' }}>
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: t('auth.logout'),
onClick: onLogout,
},
],
}}
>
<Space style={{ cursor: 'pointer' }}>
<UserOutlined />
<Typography.Text>{user?.actor ?? '—'}</Typography.Text>
</Space>
</Dropdown>
</AntHeader>
)
}

View File

@@ -0,0 +1,30 @@
import { DashboardOutlined, GlobalOutlined } from '@ant-design/icons'
import { Menu, Typography } from 'antd'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
export default function Sidebar() {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const items = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: t('nav.dashboard') },
{ key: '/domains', icon: <GlobalOutlined />, label: t('nav.domains') },
]
return (
<div>
<Typography.Title level={4} style={{ color: '#fff', padding: '16px', margin: 0 }}>
{t('app.title')}
</Typography.Title>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={items}
onClick={(e) => navigate(e.key)}
/>
</div>
)
}