feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)
Backend: - Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date) - NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle) - System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler) - Domain-Response-Headers + Rate-Limit (Migration 0024) - Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out - apt-Service für Update-Banner (apt-get update + Versionsprüfung) - Backup-Retry mit exponential backoff (retry_apt 3×) - publish.sh fail-fast + cleanup-old.sh (max 10 Versionen) Frontend: - Audit-Log-Page (/audit) mit Filter + Pagination - ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+) - Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix) - EmptyState-Komponente überall ausgerollt - SSL: Aggregate-Karte (total/expiring/expired/errors) - Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now - NTP: Sync-Status-Karte (chronyc tracking live) - Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats - Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN) - Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler) - Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention - Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint - System-Regeln im Firewall als eigener Tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Select, Switch, Tag, message } from 'antd'
|
||||
import { Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { GlobalOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, EditOutlined, GlobalOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
@@ -18,6 +19,14 @@ interface Domain {
|
||||
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
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -28,10 +37,26 @@ interface DomainFormValues {
|
||||
active: boolean
|
||||
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
|
||||
primary_backend_id?: number | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface ResponseHeader {
|
||||
id: number
|
||||
domain_id: number
|
||||
name: string
|
||||
value: string
|
||||
position: number
|
||||
}
|
||||
|
||||
async function listDomains(): Promise<Domain[]> {
|
||||
const r = await apiClient.get('/domains')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
@@ -52,6 +77,26 @@ async function listBackends(): Promise<BackendLite[]> {
|
||||
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
|
||||
}
|
||||
|
||||
interface TLSCertLite {
|
||||
domain: string
|
||||
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
|
||||
not_after?: string | null
|
||||
}
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
interface HAProxyStat { backend: string; server: string; status: string }
|
||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
||||
try {
|
||||
const r = await apiClient.get('/haproxy/stats')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -61,11 +106,62 @@ export default function DomainsPage() {
|
||||
queryFn: listDomains,
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||
// TLS-Certs nebenher laden, damit wir pro Domain den Cert-Status
|
||||
// (vorhanden / gültig / ablaufend / fehlt) als Spalte zeigen können.
|
||||
// Operator sieht so auf einen Blick welche Domains noch self-signed
|
||||
// sind und welche bereits ein gültiges ACME-Cert haben.
|
||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||
const { data: haproxyStats } = useQuery({
|
||||
queryKey: ['haproxy', 'stats'],
|
||||
queryFn: listHAProxyStats,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
|
||||
const backendById = (id?: number | null) => backends?.find(b => b.id === id)
|
||||
|
||||
// Gibt 'UP', 'DOWN' oder null zurück. HAProxy-Backend heißt eg_backend_<id>.
|
||||
// Wir aggregieren alle Server: wenn mind. einer UP → UP, sonst DOWN.
|
||||
const backendHealth = (id?: number | null): 'UP' | 'DOWN' | null => {
|
||||
if (!id || !haproxyStats?.length) return null
|
||||
const name = `eg_backend_${id}`
|
||||
const servers = haproxyStats.filter(s => s.backend === name)
|
||||
if (!servers.length) return null
|
||||
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 }>()
|
||||
|
||||
const quickCreateBackend = useMutation({
|
||||
mutationFn: async (v: { name: string; scheme: 'http' | 'https'; address: string; port: number }) => {
|
||||
// 1) Backend anlegen
|
||||
const bRes = await apiClient.post('/backends', {
|
||||
name: v.name, scheme: v.scheme, lb_algorithm: 'roundrobin',
|
||||
websocket: false, active: true,
|
||||
})
|
||||
const bId = (bRes.data?.data as { id?: number })?.id
|
||||
if (!bId) throw new Error('backend id missing in response')
|
||||
// 2) Ersten Server reinhängen
|
||||
await apiClient.post(`/backends/${bId}/servers`, {
|
||||
backend_id: bId, name: v.address.replace(/[^a-zA-Z0-9-]/g, '-'),
|
||||
address: v.address, port: v.port, weight: 100, active: true,
|
||||
})
|
||||
return bId
|
||||
},
|
||||
onSuccess: (bId) => {
|
||||
message.success(t('domains.quickBackendCreated'))
|
||||
void qc.invalidateQueries({ queryKey: ['backends'] })
|
||||
// Frisch erstellten Backend automatisch im Domain-Form selektieren.
|
||||
form.setFieldsValue({ primary_backend_id: bId })
|
||||
setQuickBackendOpen(false)
|
||||
quickBackendForm.resetFields()
|
||||
},
|
||||
onError: (e: Error) => message.error(t('domains.quickBackendFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (v: DomainFormValues) => {
|
||||
@@ -109,36 +205,112 @@ export default function DomainsPage() {
|
||||
render: (id?: number | null) => {
|
||||
if (!id) return <Tag>{t('domains.noBackend')}</Tag>
|
||||
const b = backendById(id)
|
||||
return b
|
||||
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
||||
: <Tag color="orange">#{id}</Tag>
|
||||
const health = backendHealth(id)
|
||||
return (
|
||||
<Space size={4}>
|
||||
{b
|
||||
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
||||
: <Tag color="orange">#{id}</Tag>}
|
||||
{health === 'UP' && <Tag color="green" style={{ margin: 0 }}>UP</Tag>}
|
||||
{health === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>DOWN</Tag>}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => <StatusDot active={v} activeLabel="HTTPS" inactiveLabel="HTTP" /> },
|
||||
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('domains.tlsCert'),
|
||||
key: 'tlsCert',
|
||||
width: 120,
|
||||
render: (_, row) => {
|
||||
const cert = certByDomain.get(row.name)
|
||||
if (!cert) {
|
||||
return (
|
||||
<Tooltip title={t('domains.tlsCertNoneHint')}>
|
||||
<Tag color="default">{t('domains.tlsCertNone')}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
if (cert.status === 'expired') {
|
||||
return <Tag color="red">{t('domains.tlsCertExpired')}</Tag>
|
||||
}
|
||||
if (cert.status === 'error') {
|
||||
return <Tag color="red">{t('domains.tlsCertError')}</Tag>
|
||||
}
|
||||
// Days remaining
|
||||
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 color="orange">{t('domains.tlsCertExpiring', { days })}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tooltip title={cert.not_after}>
|
||||
<Tag color="green">{t('domains.tlsCertValid')}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
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 ?? '',
|
||||
})
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
|
||||
/>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// openCreate: reused von extraActions-Button und EmptyState-Action.
|
||||
// Setzt die Defaults für die Create-Modal-Form.
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
active: true,
|
||||
http_to_https: true,
|
||||
hsts_enabled: false,
|
||||
hsts_max_age: 31536000,
|
||||
hsts_subdomains: false,
|
||||
hsts_preload: false,
|
||||
maintenance_mode: false,
|
||||
maintenance_message: '',
|
||||
www_redirect: '',
|
||||
rate_limit_rps: 0,
|
||||
max_body_kb: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -152,13 +324,22 @@ export default function DomainsPage() {
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('domains.addDomain')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<GlobalOutlined />}
|
||||
title={t('domains.emptyTitle')}
|
||||
description={t('domains.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('domains.addDomain')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
|
||||
@@ -183,16 +364,24 @@ export default function DomainsPage() {
|
||||
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} (${b.address}:${b.port})`,
|
||||
}))}
|
||||
/>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="primary_backend_id" noStyle>
|
||||
<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})`,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button onClick={() => setQuickBackendOpen(true)} title={t('domains.quickBackendBtnHint')}>
|
||||
{t('domains.quickBackendBtn')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
@@ -200,14 +389,309 @@ 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>
|
||||
</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). */}
|
||||
<Modal
|
||||
title={t('domains.quickBackendTitle')}
|
||||
open={quickBackendOpen}
|
||||
onCancel={() => { setQuickBackendOpen(false); quickBackendForm.resetFields() }}
|
||||
onOk={() => { void quickBackendForm.submit() }}
|
||||
confirmLoading={quickCreateBackend.isPending}
|
||||
width={520}
|
||||
>
|
||||
<Form
|
||||
form={quickBackendForm}
|
||||
layout="vertical"
|
||||
initialValues={{ scheme: 'http', port: 80 }}
|
||||
onFinish={(v) => quickCreateBackend.mutate(v)}
|
||||
>
|
||||
<Form.Item label={t('domains.quickBackendName')} name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="app1" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.quickBackendScheme')} name="scheme">
|
||||
<Select options={[
|
||||
{ value: 'http', label: 'http' },
|
||||
{ value: 'https', label: 'https' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.quickBackendAddress')} name="address" rules={[{ required: true }]}>
|
||||
<Input placeholder="10.0.0.10" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.quickBackendPort')} name="port" rules={[{ required: true, type: 'number', min: 1, max: 65535 }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</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