feat: Zonen als first-class Entity + Domain↔Backend-Verknüpfung sichtbar
* Migration 0012: firewall_zones (id, name UNIQUE, description, builtin),
Seed wan/lan/dmz/mgmt/cluster als builtin. CHECK-Constraints auf
network_interfaces.role + firewall_rules.{src,dst}_zone +
firewall_nat_rules.{in,out}_zone gedroppt — Validation lebt jetzt
app-side (Handler prüft Existenz in firewall_zones).
* Backend: firewall.ZonesRepo (CRUD + Exists + References-Lookup),
/api/v1/firewall/zones, builtin geschützt (Name nicht änderbar,
Delete blockiert), Rename eines Custom-Zone aktuell ohne Cascade
(Handler-Sorge bei Rules/NAT/Networks).
* Handler-Validation in CreateRule/UpdateRule/CreateNAT/UpdateNAT +
NetworksHandler: Zone-Existence-Check pro Mutation, 400 bei Tippfehler.
* Frontend: Firewall-Tab "Zonen" (CRUD mit builtin-Schutz). Networks-
Form lädt Rollen aus /firewall/zones (statt hardcoded Liste); Rules-
und NAT-Forms ziehen die Zone-Auswahl ebenfalls aus der API.
* Domain-Form bekommt Primary-Backend-Picker (Field war im Modell,
fehlte im UI). Backends-Tabelle zeigt umgekehrt welche Domains
darauf zeigen — bidirektionale Sicht ohne Schemaänderung.
* HAProxy-Renderer: safeID-FuncMap escaped Server-Namen mit Whitespace
("Control Master 1" → "Control_Master_1"). Vorher ist haproxy beim
Reload an Spaces im Backend-Namen kaputt gegangen.
* Version 1.0.3 → 1.0.6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography, message } from 'antd'
|
||||
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -35,6 +35,19 @@ async function listDomains(): Promise<Domain[]> {
|
||||
return payload.domains ?? []
|
||||
}
|
||||
|
||||
interface BackendLite {
|
||||
id: number
|
||||
name: string
|
||||
address: string
|
||||
port: number
|
||||
active: boolean
|
||||
}
|
||||
async function listBackends(): Promise<BackendLite[]> {
|
||||
const r = await apiClient.get('/backends')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -43,6 +56,8 @@ export default function DomainsPage() {
|
||||
queryKey: ['domains'],
|
||||
queryFn: listDomains,
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||
const backendById = (id?: number | null) => backends?.find(b => b.id === id)
|
||||
|
||||
const [editing, setEditing] = useState<Domain | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -85,6 +100,16 @@ export default function DomainsPage() {
|
||||
|
||||
const columns: ColumnsType<Domain> = [
|
||||
{ title: t('domains.name'), dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id',
|
||||
render: (id?: number | null) => {
|
||||
if (!id) return <Tag color="default">{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>
|
||||
},
|
||||
},
|
||||
{ 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') },
|
||||
@@ -150,6 +175,22 @@ export default function DomainsPage() {
|
||||
<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')}
|
||||
>
|
||||
<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})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user