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,38 @@
import { Card, Col, Row, Statistic, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
export default function DashboardPage() {
const { t } = useTranslation()
const { data: health } = useQuery({
queryKey: ['system', 'health'],
queryFn: async () => {
const r = await apiClient.get('/system/health')
if (isEnvelope(r.data)) return r.data.data as { status: string; version: string }
return null
},
refetchInterval: 30_000,
})
return (
<div>
<Typography.Title level={3}>{t('dashboard.title')}</Typography.Title>
<Typography.Paragraph type="secondary">{t('dashboard.welcomeHint')}</Typography.Paragraph>
<Row gutter={16}>
<Col span={8}>
<Card>
<Statistic title="API status" value={health?.status ?? '—'} />
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="Version" value={health?.version ?? '—'} />
</Card>
</Col>
</Row>
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Table, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
interface Domain {
id: number
name: string
active: boolean
primary_backend_id?: number | null
http_to_https: boolean
hsts_enabled: boolean
notes?: string
created_at: string
updated_at: string
}
interface DomainFormValues {
name: string
active: boolean
http_to_https: boolean
hsts_enabled: boolean
primary_backend_id?: number | null
notes?: string
}
async function listDomains(): Promise<Domain[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
const payload = r.data.data as { domains?: Domain[] }
return payload.domains ?? []
}
export default function DomainsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['domains'],
queryFn: listDomains,
})
const [editing, setEditing] = useState<Domain | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<DomainFormValues>()
const create = useMutation({
mutationFn: async (v: DomainFormValues) => {
const r = await apiClient.post('/domains', v)
return r.data
},
onSuccess: () => {
message.success(t('common.save'))
setCreating(false)
form.resetFields()
void qc.invalidateQueries({ queryKey: ['domains'] })
},
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: DomainFormValues }) => {
const r = await apiClient.put(`/domains/${id}`, v)
return r.data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null)
form.resetFields()
void qc.invalidateQueries({ queryKey: ['domains'] })
},
})
const del = useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/domains/${id}`)
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['domains'] })
},
})
const columns: ColumnsType<Domain> = [
{ title: t('domains.name'), dataIndex: 'name', key: 'name' },
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
{
title: t('domains.actions'),
key: 'actions',
render: (_, row) => (
<Space>
<Button size="small" onClick={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
active: row.active,
http_to_https: row.http_to_https,
hsts_enabled: row.hsts_enabled,
primary_backend_id: row.primary_backend_id ?? null,
notes: row.notes ?? '',
})
}}>{t('domains.edit')}</Button>
<Popconfirm
title={t('domains.deleteConfirm', { name: row.name })}
onConfirm={() => del.mutate(row.id)}
>
<Button size="small" danger>{t('domains.delete')}</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
<Typography.Title level={3}>{t('domains.title')}</Typography.Title>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
setCreating(true)
form.resetFields()
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
}}>
{t('domains.addDomain')}
</Button>
<Table
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={columns}
pagination={false}
/>
<Modal
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false) }}
onOk={() => { void form.submit() }}
confirmLoading={create.isPending || update.isPending}
>
<Form
form={form}
layout="vertical"
onFinish={(v) => {
if (editing) update.mutate({ id: editing.id, v })
else create.mutate(v)
}}
>
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="example.com" />
</Form.Item>
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('domains.hsts')} name="hsts_enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('domains.notes')} name="notes">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import type { SessionUser } from '../../stores/auth'
interface Props {
onLogin: (u: SessionUser) => void
}
interface LoginValues {
email: string
password: string
}
export default function LoginPage({ onLogin }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const onFinish = async (vals: LoginValues) => {
try {
const r = await apiClient.post('/auth/login', vals)
if (isEnvelope(r.data)) {
const u = r.data.data as SessionUser
onLogin(u)
navigate('/dashboard', { replace: true })
return
}
} catch (e: unknown) {
const err = e as { message?: string; status?: number }
if (err.status === 503) {
// setup-mode → drop to wizard
navigate('/setup', { replace: true })
return
}
message.error(err.message ?? t('auth.loginFailed'))
}
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 400 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
{t('app.title')}
</Typography.Title>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item
label={t('auth.email')}
name="email"
rules={[{ required: true, type: 'email' }]}
>
<Input autoComplete="email" autoFocus />
</Form.Item>
<Form.Item
label={t('auth.password')}
name="password"
rules={[{ required: true }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
{t('auth.login')}
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient from '../../api/client'
import type { SessionUser } from '../../stores/auth'
interface Props {
onComplete: (u: SessionUser) => void
}
interface SetupValues {
admin_email: string
admin_password: string
fqdn: string
acme_email: string
license_key?: string
}
export default function SetupPage({ onComplete: _onComplete }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const onFinish = async (vals: SetupValues) => {
try {
await apiClient.post('/setup/complete', vals)
message.success(t('setup.successTitle'))
// Setup doesn't issue a session — the operator must log in.
navigate('/login', { replace: true })
} catch (e: unknown) {
const err = e as { message?: string }
message.error(err.message ?? t('common.error'))
}
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 520 }}>
<Typography.Title level={3}>{t('setup.title')}</Typography.Title>
<Typography.Paragraph type="secondary">{t('setup.intro')}</Typography.Paragraph>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item
label={t('setup.adminEmail')}
name="admin_email"
rules={[{ required: true, type: 'email' }]}
>
<Input autoComplete="email" autoFocus />
</Form.Item>
<Form.Item
label={t('setup.adminPassword')}
name="admin_password"
rules={[{ required: true, min: 12, message: t('setup.passwordRule') }]}
help={t('setup.passwordRule')}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
label={t('setup.fqdn')}
name="fqdn"
rules={[{ required: true }]}
>
<Input placeholder="eg.example.com" />
</Form.Item>
<Form.Item
label={t('setup.acmeEmail')}
name="acme_email"
rules={[{ required: true, type: 'email' }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('setup.licenseKey')}
name="license_key"
>
<Input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
{t('setup.submit')}
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}