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:
168
management-ui/src/pages/Domains/index.tsx
Normal file
168
management-ui/src/pages/Domains/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user