feat(domains): Domain-Detailseite + Response-Headers-Panel

Statt aller 15+ Felder in einem Modal gibt es jetzt eine eigene Seite
unter /domains/:id. Der Create-Flow bleibt im Modal (Name, Backend, Active,
HTTP→HTTPS), ein Hinweistext führt nach dem Speichern auf die Detailseite
weiter.

Die Detailseite enthält links das vollständige Settings-Formular (HSTS,
Rate-Limit, Maintenance, Max-Body, Notes, WWW-Redirect) und rechts das
HeadersPanel zum Verwalten custom HTTP-Response-Header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-21 09:04:41 +02:00
parent f4d178cb38
commit 711e9446e6
6 changed files with 419 additions and 333 deletions

View File

@@ -1,8 +1,9 @@
import { useState } from 'react'
import { Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd'
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { DeleteOutlined, EditOutlined, GlobalOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import { GlobalOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
@@ -100,6 +101,7 @@ async function listHAProxyStats(): Promise<HAProxyStat[]> {
export default function DomainsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const navigate = useNavigate()
const { data, isLoading } = useQuery({
queryKey: ['domains'],
@@ -129,9 +131,7 @@ export default function DomainsPage() {
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
}
const [editing, setEditing] = useState<Domain | null>(null)
const [creating, setCreating] = useState(false)
const [headersFor, setHeadersFor] = useState<Domain | null>(null)
const [quickBackendOpen, setQuickBackendOpen] = useState(false)
const [form] = Form.useForm<DomainFormValues>()
const [quickBackendForm] = Form.useForm<{ name: string; scheme: 'http' | 'https'; address: string; port: number }>()
@@ -176,19 +176,6 @@ export default function DomainsPage() {
},
})
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}`)
@@ -260,34 +247,11 @@ export default function DomainsPage() {
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<Space size={4}>
<Button size="small" onClick={() => setHeadersFor(row)}>
{t('domains.headersBtn')}
</Button>
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
active: row.active,
http_to_https: row.http_to_https,
hsts_enabled: row.hsts_enabled,
hsts_max_age: row.hsts_max_age || 31536000,
hsts_subdomains: row.hsts_subdomains,
hsts_preload: row.hsts_preload,
maintenance_mode: row.maintenance_mode,
maintenance_message: row.maintenance_message ?? '',
www_redirect: row.www_redirect ?? '',
rate_limit_rps: row.rate_limit_rps ?? 0,
max_body_kb: row.max_body_kb ?? 0,
primary_backend_id: row.primary_backend_id ?? null,
notes: row.notes ?? '',
})
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
/>
</Space>
<ActionButtons
onEdit={() => navigate(`/domains/${row.id}`)}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
/>
),
},
]
@@ -342,38 +306,24 @@ export default function DomainsPage() {
}
/>
<Modal
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false) }}
title={t('domains.addDomain')}
open={creating}
onCancel={() => setCreating(false)}
onOk={() => { void form.submit() }}
confirmLoading={create.isPending || update.isPending}
confirmLoading={create.isPending}
>
<Form
form={form}
layout="vertical"
onFinish={(v) => {
if (editing) update.mutate({ id: editing.id, v })
else create.mutate(v)
}}
>
<Form form={form} layout="vertical" onFinish={(v) => 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.primaryBackend')}
name="primary_backend_id"
extra={t('domains.primaryBackendHint')}
>
<Form.Item label={t('domains.primaryBackend')} name="primary_backend_id"
extra={t('domains.primaryBackendHint')}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="primary_backend_id" noStyle>
<Select
allowClear
showSearch
optionFilterProp="label"
<Select allowClear showSearch optionFilterProp="label"
placeholder={t('domains.selectBackend')}
options={(backends ?? []).filter(b => b.active).map(b => ({
value: b.id,
label: `${b.name} (${b.address}:${b.port})`,
value: b.id, label: b.name,
}))}
style={{ width: '100%' }}
/>
@@ -389,116 +339,14 @@ export default function DomainsPage() {
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
<Switch />
</Form.Item>
<Divider plain>
<Typography.Text type="secondary">{t('domains.settingsSection')}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('domains.moreOnDetailPage')}
</Typography.Text>
</Divider>
<Form.Item label={t('domains.hsts')} name="hsts_enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.hsts_enabled !== curr.hsts_enabled}
>
{({ getFieldValue }) => getFieldValue('hsts_enabled') ? (
<>
<Form.Item
label={t('domains.hstsMaxAge')}
name="hsts_max_age"
extra={t('domains.hstsMaxAgeHint')}
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
>
<InputNumber min={0} step={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('domains.hstsSubdomains')}
name="hsts_subdomains"
valuePropName="checked"
extra={t('domains.hstsSubdomainsHint')}
>
<Switch />
</Form.Item>
<Form.Item
label={t('domains.hstsPreload')}
name="hsts_preload"
valuePropName="checked"
extra={t('domains.hstsPreloadHint')}
>
<Switch />
</Form.Item>
</>
) : null}
</Form.Item>
<Form.Item
label={t('domains.wwwRedirect')}
name="www_redirect"
extra={t('domains.wwwRedirectHint')}
>
<Select
options={[
{ value: '', label: t('domains.wwwRedirectNone') },
{ value: 'to-naked', label: t('domains.wwwRedirectToNaked') },
{ value: 'to-www', label: t('domains.wwwRedirectToWWW') },
]}
/>
</Form.Item>
<Form.Item
label={t('domains.maintenance')}
name="maintenance_mode"
valuePropName="checked"
extra={t('domains.maintenanceHint')}
>
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.maintenance_mode !== curr.maintenance_mode}
>
{({ getFieldValue }) => getFieldValue('maintenance_mode') ? (
<Form.Item label={t('domains.maintenanceMessage')} name="maintenance_message">
<Input.TextArea
rows={2}
placeholder={t('domains.maintenanceMessagePlaceholder')}
maxLength={300}
/>
</Form.Item>
) : null}
</Form.Item>
<Form.Item
label={t('domains.rateLimit')}
name="rate_limit_rps"
extra={t('domains.rateLimitHint')}
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
>
<InputNumber min={0} step={10} style={{ width: '100%' }} addonAfter="req/s" />
</Form.Item>
<Form.Item
label={t('domains.maxBody')}
name="max_body_kb"
extra={t('domains.maxBodyHint')}
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
>
<InputNumber min={0} step={64} style={{ width: '100%' }} addonAfter="KiB" />
</Form.Item>
<Form.Item label={t('domains.notes')} name="notes">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{headersFor && (
<HeadersModal
domain={headersFor}
onClose={() => setHeadersFor(null)}
/>
)}
{/* Quick-Add-Backend: schmales Sub-Modal direkt aus dem Domain-
Anlegen-Flow heraus. Spart drei Navigations-Klicks (Backends-
Seite öffnen → Backend anlegen → Server anlegen → zurück). */}
@@ -536,162 +384,3 @@ export default function DomainsPage() {
</div>
)
}
// ── Response-Headers Modal ────────────────────────────────────────────
//
// Eigenes Modal weil Headers eine 1:n-Relation sind die nicht in das
// Domain-Form passt. Lädt /domains/:id/headers, erlaubt Inline-Add via
// kleinem Sub-Form, Inline-Edit per Modal pro Row und Delete.
interface HeadersModalProps {
domain: Domain
onClose: () => void
}
async function listHeaders(domainID: number): Promise<ResponseHeader[]> {
const r = await apiClient.get(`/domains/${domainID}/headers`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { headers?: ResponseHeader[] }).headers ?? []
}
function HeadersModal({ domain, onClose }: HeadersModalProps) {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isFetching } = useQuery({
queryKey: ['domain-headers', domain.id],
queryFn: () => listHeaders(domain.id),
})
const [editing, setEditing] = useState<ResponseHeader | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [hForm] = Form.useForm<{ name: string; value: string; position: number }>()
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['domain-headers', domain.id] })
}
const create = useMutation({
mutationFn: async (v: { name: string; value: string; position: number }) =>
apiClient.post(`/domains/${domain.id}/headers`, v),
onSuccess: () => {
message.success(t('common.save'))
setAddOpen(false); hForm.resetFields()
invalidate()
},
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: { name: string; value: string; position: number } }) =>
apiClient.put(`/domains/${domain.id}/headers/${id}`, v),
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); hForm.resetFields()
invalidate()
},
})
const del = useMutation({
mutationFn: async (id: number) =>
apiClient.delete(`/domains/${domain.id}/headers/${id}`),
onSuccess: invalidate,
})
const columns: ColumnsType<ResponseHeader> = [
{ title: t('domains.headerName'), dataIndex: 'name', key: 'name' },
{ title: t('domains.headerValue'), dataIndex: 'value', key: 'value', ellipsis: true },
{
title: t('common.actions'), key: 'actions', width: 100,
render: (_, row) => (
<Space size={4}>
<Button
type="text" size="small" icon={<EditOutlined />}
onClick={() => {
setEditing(row)
hForm.setFieldsValue({ name: row.name, value: row.value, position: row.position })
}}
/>
<Popconfirm
title={t('domains.headerDeleteConfirm', { name: row.name })}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(row.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<>
<Modal
open
title={t('domains.headersTitle', { name: domain.name })}
onCancel={onClose}
footer={
<Space>
<Button icon={<ReloadOutlined />} onClick={invalidate}>{t('common.refresh')}</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setAddOpen(true); hForm.resetFields()
hForm.setFieldsValue({ name: '', value: '', position: (data?.length ?? 0) })
}}>
{t('domains.addHeader')}
</Button>
<Button onClick={onClose}>{t('common.close')}</Button>
</Space>
}
width={720}
>
<Typography.Paragraph type="secondary">
{t('domains.headersHint')}
</Typography.Paragraph>
<Table
rowKey="id"
size="small"
loading={isFetching}
dataSource={data ?? []}
columns={columns}
pagination={false}
locale={{ emptyText: t('domains.headersEmpty') }}
/>
</Modal>
<Modal
open={addOpen || editing !== null}
title={editing ? t('domains.editHeader') : t('domains.addHeader')}
onCancel={() => { setAddOpen(false); setEditing(null); hForm.resetFields() }}
onOk={() => { void hForm.submit() }}
confirmLoading={create.isPending || update.isPending}
>
<Form
form={hForm}
layout="vertical"
onFinish={(v) => {
if (editing) update.mutate({ id: editing.id, v })
else create.mutate(v)
}}
>
<Form.Item
label={t('domains.headerName')}
name="name"
extra={t('domains.headerNameHint')}
rules={[
{ required: true },
{ pattern: /^[A-Za-z0-9-]+$/, message: t('domains.headerNamePattern') },
]}
>
<Input placeholder="X-Frame-Options" />
</Form.Item>
<Form.Item
label={t('domains.headerValue')}
name="value"
rules={[{ required: true }]}
>
<Input.TextArea rows={2} placeholder="DENY" />
</Form.Item>
<Form.Item label={t('domains.headerPosition')} name="position" initialValue={0}>
<InputNumber min={0} step={1} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</>
)
}