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:
25
management-ui/src/components/Layout/AppLayout.tsx
Normal file
25
management-ui/src/components/Layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
management-ui/src/components/Layout/Header.tsx
Normal file
44
management-ui/src/components/Layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
management-ui/src/components/Layout/Sidebar.tsx
Normal file
30
management-ui/src/components/Layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
management-ui/src/components/UpdateBanner.tsx
Normal file
71
management-ui/src/components/UpdateBanner.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user