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:
@@ -15,6 +15,7 @@ const ResetPasswordPage = lazy(() => import('./pages/ResetPassword'))
|
|||||||
const SetupPage = lazy(() => import('./pages/Setup'))
|
const SetupPage = lazy(() => import('./pages/Setup'))
|
||||||
const DashboardPage = lazy(() => import('./pages/Dashboard'))
|
const DashboardPage = lazy(() => import('./pages/Dashboard'))
|
||||||
const DomainsPage = lazy(() => import('./pages/Domains'))
|
const DomainsPage = lazy(() => import('./pages/Domains'))
|
||||||
|
const DomainDetailPage = lazy(() => import('./pages/Domains/Detail'))
|
||||||
const BackendsPage = lazy(() => import('./pages/Backends'))
|
const BackendsPage = lazy(() => import('./pages/Backends'))
|
||||||
const BackendDetailPage = lazy(() => import('./pages/Backends/Detail'))
|
const BackendDetailPage = lazy(() => import('./pages/Backends/Detail'))
|
||||||
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
|
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
|
||||||
@@ -107,6 +108,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/domains" element={<DomainsPage />} />
|
<Route path="/domains" element={<DomainsPage />} />
|
||||||
|
<Route path="/domains/:id" element={<DomainDetailPage />} />
|
||||||
<Route path="/backends" element={<BackendsPage />} />
|
<Route path="/backends" element={<BackendsPage />} />
|
||||||
<Route path="/backends/:id" element={<BackendDetailPage />} />
|
<Route path="/backends/:id" element={<BackendDetailPage />} />
|
||||||
<Route path="/routing-rules" element={<RoutingRulesPage />} />
|
<Route path="/routing-rules" element={<RoutingRulesPage />} />
|
||||||
|
|||||||
@@ -242,6 +242,9 @@
|
|||||||
"intro": "Verwalte FQDNs, die HAProxy terminiert. Optionales Primary-Backend als Catch-all; Pfad-Routing via Routing-Regeln.",
|
"intro": "Verwalte FQDNs, die HAProxy terminiert. Optionales Primary-Backend als Catch-all; Pfad-Routing via Routing-Regeln.",
|
||||||
"addDomain": "Domain hinzufügen",
|
"addDomain": "Domain hinzufügen",
|
||||||
"editDomain": "Domain bearbeiten",
|
"editDomain": "Domain bearbeiten",
|
||||||
|
"backToList": "Zurück zur Übersicht",
|
||||||
|
"settingsCard": "Domain-Einstellungen",
|
||||||
|
"moreOnDetailPage": "HSTS, Rate-Limit, Wartungsmodus und mehr auf der Domain-Detailseite nach dem Speichern.",
|
||||||
"emptyTitle": "Noch keine Domains.",
|
"emptyTitle": "Noch keine Domains.",
|
||||||
"emptyDesc": "Lege deine erste Domain an — HAProxy terminiert dann TLS für diesen Hostnamen und routet an das gewählte Backend.",
|
"emptyDesc": "Lege deine erste Domain an — HAProxy terminiert dann TLS für diesen Hostnamen und routet an das gewählte Backend.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@@ -242,6 +242,9 @@
|
|||||||
"intro": "Manage FQDNs that HAProxy terminates. Optional primary backend as catch-all; path-based routing via routing rules.",
|
"intro": "Manage FQDNs that HAProxy terminates. Optional primary backend as catch-all; path-based routing via routing rules.",
|
||||||
"addDomain": "Add domain",
|
"addDomain": "Add domain",
|
||||||
"editDomain": "Edit domain",
|
"editDomain": "Edit domain",
|
||||||
|
"backToList": "Back to domains",
|
||||||
|
"settingsCard": "Domain settings",
|
||||||
|
"moreOnDetailPage": "HSTS, rate-limit, maintenance and more on the domain detail page after saving.",
|
||||||
"emptyTitle": "No domains yet.",
|
"emptyTitle": "No domains yet.",
|
||||||
"emptyDesc": "Add your first domain — HAProxy will then terminate TLS for that hostname and route to the chosen backend.",
|
"emptyDesc": "Add your first domain — HAProxy will then terminate TLS for that hostname and route to the chosen backend.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
389
management-ui/src/pages/Domains/Detail.tsx
Normal file
389
management-ui/src/pages/Domains/Detail.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Button, Card, Col, Divider, Form, Input, InputNumber, Modal,
|
||||||
|
Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message,
|
||||||
|
} from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined, DeleteOutlined, EditOutlined, GlobalOutlined,
|
||||||
|
LockOutlined, PlusOutlined, SafetyCertificateOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
import StatusDot from '../../components/StatusDot'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface Domain {
|
||||||
|
id: number; name: string; active: boolean
|
||||||
|
primary_backend_id?: number | null
|
||||||
|
http_to_https: boolean
|
||||||
|
hsts_enabled: boolean; hsts_max_age: number
|
||||||
|
hsts_subdomains: boolean; hsts_preload: boolean
|
||||||
|
maintenance_mode: boolean; maintenance_message?: string | null
|
||||||
|
www_redirect: '' | 'to-naked' | 'to-www'
|
||||||
|
rate_limit_rps: number; max_body_kb: number
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainFormValues {
|
||||||
|
name: string; active: boolean
|
||||||
|
primary_backend_id?: number | null
|
||||||
|
http_to_https: boolean
|
||||||
|
hsts_enabled: boolean; hsts_max_age: number
|
||||||
|
hsts_subdomains: boolean; hsts_preload: boolean
|
||||||
|
maintenance_mode: boolean; maintenance_message?: string
|
||||||
|
www_redirect: '' | 'to-naked' | 'to-www'
|
||||||
|
rate_limit_rps: number; max_body_kb: number
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendLite { id: number; name: string; active: boolean }
|
||||||
|
|
||||||
|
interface ResponseHeader {
|
||||||
|
id: number; domain_id: number; name: string; value: string; position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TLSCertLite {
|
||||||
|
domain: string
|
||||||
|
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
|
||||||
|
not_after?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDomain(id: number): Promise<Domain | null> {
|
||||||
|
const r = await apiClient.get(`/domains/${id}`)
|
||||||
|
if (!isEnvelope(r.data)) return null
|
||||||
|
return r.data.data as Domain
|
||||||
|
}
|
||||||
|
async function listBackends(): Promise<BackendLite[]> {
|
||||||
|
const r = await apiClient.get('/backends')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
|
||||||
|
}
|
||||||
|
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 ?? []
|
||||||
|
}
|
||||||
|
async function listCerts(): Promise<TLSCertLite[]> {
|
||||||
|
const r = await apiClient.get('/tls-certs')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DomainDetailPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const domainID = Number(id)
|
||||||
|
|
||||||
|
const { data: domain, isLoading } = useQuery({
|
||||||
|
queryKey: ['domain', domainID],
|
||||||
|
queryFn: () => getDomain(domainID),
|
||||||
|
enabled: !isNaN(domainID),
|
||||||
|
})
|
||||||
|
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||||
|
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||||
|
|
||||||
|
const [form] = Form.useForm<DomainFormValues>()
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: async (v: DomainFormValues) => {
|
||||||
|
await apiClient.put(`/domains/${domainID}`, v)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
void qc.invalidateQueries({ queryKey: ['domains'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['domain', domainID] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading || !domain) return null
|
||||||
|
|
||||||
|
const cert = certs?.find(c => c.domain === domain.name)
|
||||||
|
|
||||||
|
const certBadge = () => {
|
||||||
|
if (!cert) return <Tag icon={<LockOutlined />} color="default">{t('domains.tlsCertNone')}</Tag>
|
||||||
|
if (cert.status === 'expired') return <Tag icon={<LockOutlined />} color="red">{t('domains.tlsCertExpired')}</Tag>
|
||||||
|
if (cert.status === 'error') return <Tag icon={<LockOutlined />} color="red">{t('domains.tlsCertError')}</Tag>
|
||||||
|
const days = cert.not_after
|
||||||
|
? Math.round((new Date(cert.not_after).getTime() - Date.now()) / 86_400_000)
|
||||||
|
: null
|
||||||
|
if (days != null && days < 30) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={cert.not_after}>
|
||||||
|
<Tag icon={<SafetyCertificateOutlined />} color="orange">
|
||||||
|
{t('domains.tlsCertExpiring', { days })}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip title={cert.not_after}>
|
||||||
|
<Tag icon={<SafetyCertificateOutlined />} color="green">{t('domains.tlsCertValid')}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={<GlobalOutlined />}
|
||||||
|
title={domain.name}
|
||||||
|
subtitle={<Space size={6}><StatusDot active={domain.active} />{certBadge()}</Space>}
|
||||||
|
extra={
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')}>
|
||||||
|
{t('domains.backToList')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Row gutter={24}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card size="small" title={t('domains.settingsCard')} className="mb-16">
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
name: domain.name,
|
||||||
|
active: domain.active,
|
||||||
|
primary_backend_id: domain.primary_backend_id ?? null,
|
||||||
|
http_to_https: domain.http_to_https,
|
||||||
|
hsts_enabled: domain.hsts_enabled,
|
||||||
|
hsts_max_age: domain.hsts_max_age || 31536000,
|
||||||
|
hsts_subdomains: domain.hsts_subdomains,
|
||||||
|
hsts_preload: domain.hsts_preload,
|
||||||
|
maintenance_mode: domain.maintenance_mode,
|
||||||
|
maintenance_message: domain.maintenance_message ?? '',
|
||||||
|
www_redirect: domain.www_redirect ?? '',
|
||||||
|
rate_limit_rps: domain.rate_limit_rps ?? 0,
|
||||||
|
max_body_kb: domain.max_body_kb ?? 0,
|
||||||
|
notes: domain.notes ?? '',
|
||||||
|
}}
|
||||||
|
onFinish={(v) => update.mutate(v)}
|
||||||
|
>
|
||||||
|
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('domains.primaryBackend')} name="primary_backend_id"
|
||||||
|
extra={t('domains.primaryBackendHint')}>
|
||||||
|
<Select
|
||||||
|
allowClear showSearch optionFilterProp="label"
|
||||||
|
placeholder={t('domains.selectBackend')}
|
||||||
|
options={(backends ?? []).filter(b => b.active).map(b => ({
|
||||||
|
value: b.id, label: b.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<Divider plain>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('domains.settingsSection')}</Text>
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<Form.Item label={t('domains.hsts')} name="hsts_enabled" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(p, c) => p.hsts_enabled !== c.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 }]}>
|
||||||
|
<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={(p, c) => p.maintenance_mode !== c.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 }]}>
|
||||||
|
<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 }]}>
|
||||||
|
<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.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={update.isPending}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<HeadersPanel domainID={domainID} domainName={domain.name} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response Headers Panel ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function HeadersPanel({ domainID, domainName }: { domainID: number; domainName: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<ResponseHeader | null>(null)
|
||||||
|
const [hForm] = Form.useForm<{ name: string; value: string; position: number }>()
|
||||||
|
|
||||||
|
const { data: headers, isLoading } = useQuery({
|
||||||
|
queryKey: ['domain-headers', domainID],
|
||||||
|
queryFn: () => listHeaders(domainID),
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = () => void qc.invalidateQueries({ queryKey: ['domain-headers', domainID] })
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async (v: { name: string; value: string; position: number }) =>
|
||||||
|
apiClient.post(`/domains/${domainID}/headers`, v),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
setOpen(false); hForm.resetFields()
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: async ({ id, v }: { id: number; v: { name: string; value: string; position: number } }) =>
|
||||||
|
apiClient.put(`/domains/${domainID}/headers/${id}`, v),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
setEditing(null); hForm.resetFields()
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: number) => apiClient.delete(`/domains/${domainID}/headers/${id}`),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ColumnsType<ResponseHeader> = [
|
||||||
|
{ title: t('domains.headerName'), dataIndex: 'name', key: 'name',
|
||||||
|
render: (s: string) => <code style={{ fontSize: 12 }}>{s}</code> },
|
||||||
|
{ title: t('domains.headerValue'), dataIndex: 'value', key: 'value', ellipsis: true },
|
||||||
|
{ title: '#', dataIndex: 'position', key: 'pos', width: 50 },
|
||||||
|
{
|
||||||
|
title: t('common.actions'), key: 'a', width: 80,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r)
|
||||||
|
hForm.setFieldsValue({ name: r.name, value: r.value, position: r.position })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title={t('domains.headerDeleteConfirm', { name: r.name })}
|
||||||
|
okText={t('common.yes')} cancelText={t('common.no')}
|
||||||
|
onConfirm={() => del.mutate(r.id)}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={t('domains.headersTitle', { name: domainName })}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setOpen(true); hForm.resetFields()
|
||||||
|
hForm.setFieldsValue({ name: '', value: '', position: headers?.length ?? 0 })
|
||||||
|
}}>
|
||||||
|
{t('domains.addHeader')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
||||||
|
{t('domains.headersHint')}
|
||||||
|
</Text>
|
||||||
|
<Table
|
||||||
|
rowKey="id" size="small" loading={isLoading}
|
||||||
|
dataSource={headers ?? []} columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: t('domains.headersEmpty') }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? t('domains.editHeader') : t('domains.addHeader')}
|
||||||
|
open={open || editing !== null}
|
||||||
|
onCancel={() => { setOpen(false); setEditing(null); hForm.resetFields() }}
|
||||||
|
onOk={() => { void hForm.submit() }}
|
||||||
|
confirmLoading={create.isPending || update.isPending}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={hForm} layout="vertical"
|
||||||
|
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react'
|
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 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DataTable from '../../components/DataTable'
|
import DataTable from '../../components/DataTable'
|
||||||
import EmptyState from '../../components/EmptyState'
|
import EmptyState from '../../components/EmptyState'
|
||||||
@@ -100,6 +101,7 @@ async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
|||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['domains'],
|
queryKey: ['domains'],
|
||||||
@@ -129,9 +131,7 @@ export default function DomainsPage() {
|
|||||||
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
|
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
|
||||||
}
|
}
|
||||||
|
|
||||||
const [editing, setEditing] = useState<Domain | null>(null)
|
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [headersFor, setHeadersFor] = useState<Domain | null>(null)
|
|
||||||
const [quickBackendOpen, setQuickBackendOpen] = useState(false)
|
const [quickBackendOpen, setQuickBackendOpen] = useState(false)
|
||||||
const [form] = Form.useForm<DomainFormValues>()
|
const [form] = Form.useForm<DomainFormValues>()
|
||||||
const [quickBackendForm] = Form.useForm<{ name: string; scheme: 'http' | 'https'; address: string; port: number }>()
|
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({
|
const del = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
await apiClient.delete(`/domains/${id}`)
|
await apiClient.delete(`/domains/${id}`)
|
||||||
@@ -260,34 +247,11 @@ export default function DomainsPage() {
|
|||||||
{
|
{
|
||||||
title: t('common.actions'), key: 'actions',
|
title: t('common.actions'), key: 'actions',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4}>
|
<ActionButtons
|
||||||
<Button size="small" onClick={() => setHeadersFor(row)}>
|
onEdit={() => navigate(`/domains/${row.id}`)}
|
||||||
{t('domains.headersBtn')}
|
onDelete={() => del.mutate(row.id)}
|
||||||
</Button>
|
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
|
||||||
<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>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -342,38 +306,24 @@ export default function DomainsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
|
title={t('domains.addDomain')}
|
||||||
open={editing !== null || creating}
|
open={creating}
|
||||||
onCancel={() => { setEditing(null); setCreating(false) }}
|
onCancel={() => setCreating(false)}
|
||||||
onOk={() => { void form.submit() }}
|
onOk={() => { void form.submit() }}
|
||||||
confirmLoading={create.isPending || update.isPending}
|
confirmLoading={create.isPending}
|
||||||
>
|
>
|
||||||
<Form
|
<Form form={form} layout="vertical" onFinish={(v) => create.mutate(v)}>
|
||||||
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 }]}>
|
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
|
||||||
<Input placeholder="example.com" />
|
<Input placeholder="example.com" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item label={t('domains.primaryBackend')} name="primary_backend_id"
|
||||||
label={t('domains.primaryBackend')}
|
extra={t('domains.primaryBackendHint')}>
|
||||||
name="primary_backend_id"
|
|
||||||
extra={t('domains.primaryBackendHint')}
|
|
||||||
>
|
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
<Form.Item name="primary_backend_id" noStyle>
|
<Form.Item name="primary_backend_id" noStyle>
|
||||||
<Select
|
<Select allowClear showSearch optionFilterProp="label"
|
||||||
allowClear
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder={t('domains.selectBackend')}
|
placeholder={t('domains.selectBackend')}
|
||||||
options={(backends ?? []).filter(b => b.active).map(b => ({
|
options={(backends ?? []).filter(b => b.active).map(b => ({
|
||||||
value: b.id,
|
value: b.id, label: b.name,
|
||||||
label: `${b.name} (${b.address}:${b.port})`,
|
|
||||||
}))}
|
}))}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
@@ -389,116 +339,14 @@ export default function DomainsPage() {
|
|||||||
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
|
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Divider plain>
|
<Divider plain>
|
||||||
<Typography.Text type="secondary">{t('domains.settingsSection')}</Typography.Text>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('domains.moreOnDetailPage')}
|
||||||
|
</Typography.Text>
|
||||||
</Divider>
|
</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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{headersFor && (
|
|
||||||
<HeadersModal
|
|
||||||
domain={headersFor}
|
|
||||||
onClose={() => setHeadersFor(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick-Add-Backend: schmales Sub-Modal direkt aus dem Domain-
|
{/* Quick-Add-Backend: schmales Sub-Modal direkt aus dem Domain-
|
||||||
Anlegen-Flow heraus. Spart drei Navigations-Klicks (Backends-
|
Anlegen-Flow heraus. Spart drei Navigations-Klicks (Backends-
|
||||||
Seite öffnen → Backend anlegen → Server anlegen → zurück). */}
|
Seite öffnen → Backend anlegen → Server anlegen → zurück). */}
|
||||||
@@ -536,162 +384,3 @@ export default function DomainsPage() {
|
|||||||
</div>
|
</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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user