feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92
Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony. - Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations. - internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK). - internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists). - configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables). - main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable). - UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'. - Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ const FirewallPage = lazy(() => import('./pages/Firewall'))
|
||||
const WireguardPage = lazy(() => import('./pages/Wireguard'))
|
||||
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const DHCPPage = lazy(() => import('./pages/DHCP'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
|
||||
@@ -161,6 +162,7 @@ export default function App() {
|
||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/dhcp" element={<DHCPPage />} />
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
|
||||
@@ -19,6 +19,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/ssl': 'nav.ssl',
|
||||
'/dns': 'nav.dns',
|
||||
'/dhcp': 'nav.dhcp',
|
||||
'/ntp': 'nav.ntp',
|
||||
'/vpn/wireguard': 'nav.wireguard',
|
||||
'/forward-proxy': 'nav.forwardProxy',
|
||||
|
||||
@@ -67,6 +67,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
|
||||
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
|
||||
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
|
||||
{ path: '/dhcp', labelKey: 'nav.dhcp', icon: <CloudServerOutlined /> },
|
||||
{ path: '/ntp', labelKey: 'nav.ntp', icon: <ClockCircleOutlined /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward-Proxy",
|
||||
"dns": "DNS",
|
||||
"dhcp": "DHCP",
|
||||
"ntp": "Zeit (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"firewallLive": "Firewall-Log",
|
||||
@@ -1075,6 +1076,52 @@
|
||||
"failed": "Import fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"dhcp": {
|
||||
"title": "DHCP (Kea)",
|
||||
"intro": "DHCPv4-Server: globale Optionen, pro-Subnet-Pools und statische Reservierungen.",
|
||||
"csvHint": "Komma-getrennt.",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"deleted": "Gelöscht",
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"reservations": "Reservierungen",
|
||||
"haWarnTitle": "Nur auf EINER Node aktivieren",
|
||||
"haWarnDesc": "Zwei DHCP-Server im selben LAN führen zu Adresskonflikten. Diese Einstellung ist node-lokal — aktiviere DHCP nur auf einer Cluster-Node.",
|
||||
"tabs": {
|
||||
"settings": "Einstellungen",
|
||||
"subnets": "Subnets"
|
||||
},
|
||||
"settings": {
|
||||
"enabled": "DHCP auf dieser Node aktiv",
|
||||
"defaultLease": "Standard-Lease-Zeit (Sekunden)",
|
||||
"maxLease": "Max-Lease-Zeit (Sekunden)",
|
||||
"domainName": "Domain-Name",
|
||||
"dnsServers": "DNS-Server (global)"
|
||||
},
|
||||
"subnet": {
|
||||
"name": "Name",
|
||||
"interface": "Interface",
|
||||
"cidr": "Subnetz (CIDR)",
|
||||
"pool": "Pool",
|
||||
"poolStart": "Pool-Start",
|
||||
"poolEnd": "Pool-Ende",
|
||||
"gateway": "Gateway",
|
||||
"dnsServers": "DNS-Server",
|
||||
"active": "Aktiv",
|
||||
"add": "Subnet hinzufügen",
|
||||
"edit": "Subnet bearbeiten",
|
||||
"deleteConfirm": "Subnet \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"reservation": {
|
||||
"name": "Name",
|
||||
"mac": "MAC-Adresse",
|
||||
"ip": "IP-Adresse",
|
||||
"hostname": "Hostname",
|
||||
"add": "Reservierung hinzufügen",
|
||||
"edit": "Reservierung bearbeiten",
|
||||
"deleteConfirm": "Reservierung wirklich löschen?"
|
||||
}
|
||||
},
|
||||
"ntp": {
|
||||
"title": "Zeitserver (Chrony)",
|
||||
"intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward proxy",
|
||||
"dns": "DNS",
|
||||
"dhcp": "DHCP",
|
||||
"ntp": "Time (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"firewallLive": "Firewall log",
|
||||
@@ -1075,6 +1076,52 @@
|
||||
"failed": "Import failed"
|
||||
}
|
||||
},
|
||||
"dhcp": {
|
||||
"title": "DHCP (Kea)",
|
||||
"intro": "DHCPv4 server: global options, per-subnet pools and static reservations.",
|
||||
"csvHint": "Comma-separated.",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Save failed",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Delete failed",
|
||||
"reservations": "Reservations",
|
||||
"haWarnTitle": "Enable on ONE node only",
|
||||
"haWarnDesc": "Two DHCP servers on the same LAN cause address conflicts. This setting is node-local — enable DHCP on a single cluster node only.",
|
||||
"tabs": {
|
||||
"settings": "Settings",
|
||||
"subnets": "Subnets"
|
||||
},
|
||||
"settings": {
|
||||
"enabled": "DHCP active on this node",
|
||||
"defaultLease": "Default lease time (seconds)",
|
||||
"maxLease": "Max lease time (seconds)",
|
||||
"domainName": "Domain name",
|
||||
"dnsServers": "DNS servers (global)"
|
||||
},
|
||||
"subnet": {
|
||||
"name": "Name",
|
||||
"interface": "Interface",
|
||||
"cidr": "Subnet (CIDR)",
|
||||
"pool": "Pool",
|
||||
"poolStart": "Pool start",
|
||||
"poolEnd": "Pool end",
|
||||
"gateway": "Gateway",
|
||||
"dnsServers": "DNS servers",
|
||||
"active": "Active",
|
||||
"add": "Add subnet",
|
||||
"edit": "Edit subnet",
|
||||
"deleteConfirm": "Really delete subnet \"{{name}}\"?"
|
||||
},
|
||||
"reservation": {
|
||||
"name": "Name",
|
||||
"mac": "MAC address",
|
||||
"ip": "IP address",
|
||||
"hostname": "Hostname",
|
||||
"add": "Add reservation",
|
||||
"edit": "Edit reservation",
|
||||
"deleteConfirm": "Really delete this reservation?"
|
||||
}
|
||||
},
|
||||
"ntp": {
|
||||
"title": "Time server (Chrony)",
|
||||
"intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.",
|
||||
|
||||
385
management-ui/src/pages/DHCP/index.tsx
Normal file
385
management-ui/src/pages/DHCP/index.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Space,
|
||||
Switch, Table, Tabs, Tag, Tooltip, Typography, message, Select,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { CloudServerOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface DHCPSettings {
|
||||
id: number
|
||||
enabled: boolean
|
||||
default_lease: number
|
||||
max_lease: number
|
||||
domain_name: string
|
||||
dns_servers: string
|
||||
}
|
||||
interface DHCPSubnet {
|
||||
id: number
|
||||
name: string
|
||||
interface_name: string
|
||||
subnet_cidr: string
|
||||
pool_start: string
|
||||
pool_end: string
|
||||
gateway: string
|
||||
dns_servers: string
|
||||
lease_time?: number | null
|
||||
active: boolean
|
||||
description: string
|
||||
}
|
||||
interface DHCPReservation {
|
||||
id: number
|
||||
subnet_id: number
|
||||
name: string
|
||||
mac_address: string
|
||||
ip_address: string
|
||||
hostname: string
|
||||
active: boolean
|
||||
}
|
||||
interface NetIface { name: string; role: string }
|
||||
|
||||
export default function DHCPPage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<PageHeader icon={<CloudServerOutlined />} title={t('dhcp.title')} subtitle={t('dhcp.intro')} />
|
||||
<Tabs
|
||||
defaultActiveKey="settings"
|
||||
items={[
|
||||
{ key: 'settings', label: t('dhcp.tabs.settings'), children: <SettingsTab /> },
|
||||
{ key: 'subnets', label: t('dhcp.tabs.subnets'), children: <SubnetsTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
function SettingsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<DHCPSettings>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['dhcp', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/dhcp/settings')
|
||||
return isEnvelope(r.data) ? (r.data.data as DHCPSettings) : null
|
||||
},
|
||||
})
|
||||
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: DHCPSettings) => apiClient.put('/dhcp/settings', v),
|
||||
onSuccess: () => {
|
||||
msg.success(t('dhcp.saved'))
|
||||
void qc.invalidateQueries({ queryKey: ['dhcp', 'settings'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-16"
|
||||
message={t('dhcp.haWarnTitle')}
|
||||
description={t('dhcp.haWarnDesc')}
|
||||
/>
|
||||
<Form<DHCPSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('dhcp.settings.enabled')} name="enabled" valuePropName="checked">
|
||||
<Switch disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.settings.defaultLease')} name="default_lease">
|
||||
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.settings.maxLease')} name="max_lease">
|
||||
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.settings.domainName')} name="domain_name">
|
||||
<Input placeholder="lan" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.settings.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
|
||||
<Input placeholder="1.1.1.1, 8.8.8.8" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Subnets ──────────────────────────────────────────────────────────
|
||||
|
||||
function SubnetsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<DHCPSubnet>()
|
||||
const [editing, setEditing] = useState<DHCPSubnet | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [resvFor, setResvFor] = useState<DHCPSubnet | null>(null)
|
||||
|
||||
const { data: subnets } = useQuery({
|
||||
queryKey: ['dhcp', 'subnets'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/dhcp/subnets')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { subnets?: DHCPSubnet[] }).subnets ?? []) : []
|
||||
},
|
||||
})
|
||||
const { data: ifaces } = useQuery({
|
||||
queryKey: ['network-interfaces'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/network-interfaces')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { interfaces?: NetIface[] }).interfaces ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: DHCPSubnet) => {
|
||||
if (editing) return apiClient.put(`/dhcp/subnets/${editing.id}`, v)
|
||||
return apiClient.post('/dhcp/subnets', v)
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('dhcp.saved'))
|
||||
setOpen(false); setEditing(null)
|
||||
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/dhcp/subnets/${id}`),
|
||||
onSuccess: () => {
|
||||
msg.success(t('dhcp.deleted'))
|
||||
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ active: true } as Partial<DHCPSubnet>)
|
||||
setOpen(true)
|
||||
}
|
||||
const openEdit = (s: DHCPSubnet) => {
|
||||
setEditing(s)
|
||||
form.setFieldsValue(s)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const cols: ColumnsType<DHCPSubnet> = [
|
||||
{ title: t('dhcp.subnet.name'), dataIndex: 'name' },
|
||||
{ title: t('dhcp.subnet.interface'), dataIndex: 'interface_name' },
|
||||
{ title: t('dhcp.subnet.cidr'), dataIndex: 'subnet_cidr' },
|
||||
{
|
||||
title: t('dhcp.subnet.pool'), key: 'pool',
|
||||
render: (_, r) => (r.pool_start && r.pool_end ? `${r.pool_start} – ${r.pool_end}` : '—'),
|
||||
},
|
||||
{ title: t('dhcp.subnet.gateway'), dataIndex: 'gateway', render: (v) => v || '—' },
|
||||
{
|
||||
title: t('common.status'), dataIndex: 'active',
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions', width: 280,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setResvFor(r)}>{t('dhcp.reservations')}</Button>
|
||||
{!isViewer && <Button size="small" onClick={() => openEdit(r)}>{t('common.edit')}</Button>}
|
||||
{!isViewer && (
|
||||
<Popconfirm title={t('dhcp.subnet.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} disabled={isViewer}>
|
||||
{t('dhcp.subnet.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<DHCPSubnet> rowKey="id" size="small" columns={cols} dataSource={subnets ?? []} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editing ? t('dhcp.subnet.edit') : t('dhcp.subnet.add')}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={save.isPending}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form<DHCPSubnet> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('dhcp.subnet.name')} name="name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.subnet.interface')} name="interface_name" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={(ifaces ?? []).map((i) => ({ value: i.name, label: `${i.name} (${i.role})` }))}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.subnet.cidr')} name="subnet_cidr" rules={[{ required: true }]}>
|
||||
<Input placeholder="10.0.0.0/24" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item label={t('dhcp.subnet.poolStart')} name="pool_start">
|
||||
<Input placeholder="10.0.0.100" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.subnet.poolEnd')} name="pool_end">
|
||||
<Input placeholder="10.0.0.200" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item label={t('dhcp.subnet.gateway')} name="gateway">
|
||||
<Input placeholder="10.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.subnet.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
|
||||
<Input placeholder="1.1.1.1, 8.8.8.8" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{resvFor && (
|
||||
<ReservationsModal subnet={resvFor} onClose={() => setResvFor(null)} />
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Reservations (per subnet) ────────────────────────────────────────
|
||||
|
||||
function ReservationsModal({ subnet, onClose }: { subnet: DHCPSubnet; onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<DHCPReservation>()
|
||||
const [editing, setEditing] = useState<DHCPReservation | null>(null)
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
|
||||
const key = ['dhcp', 'reservations', subnet.id]
|
||||
const { data } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get(`/dhcp/subnets/${subnet.id}/reservations`)
|
||||
return isEnvelope(r.data) ? ((r.data.data as { reservations?: DHCPReservation[] }).reservations ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: DHCPReservation) => {
|
||||
if (editing) return apiClient.put(`/dhcp/reservations/${editing.id}`, v)
|
||||
return apiClient.post(`/dhcp/subnets/${subnet.id}/reservations`, v)
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('dhcp.saved'))
|
||||
setFormOpen(false); setEditing(null)
|
||||
void qc.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/dhcp/reservations/${id}`),
|
||||
onSuccess: () => {
|
||||
msg.success(t('dhcp.deleted'))
|
||||
void qc.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<DHCPReservation> = [
|
||||
{ title: t('dhcp.reservation.mac'), dataIndex: 'mac_address' },
|
||||
{ title: t('dhcp.reservation.ip'), dataIndex: 'ip_address' },
|
||||
{ title: t('dhcp.reservation.hostname'), dataIndex: 'hostname', render: (v) => v || '—' },
|
||||
{
|
||||
title: t('common.actions'), key: 'actions', width: 180,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue(r); setFormOpen(true) }}>{t('common.edit')}</Button>}
|
||||
{!isViewer && (
|
||||
<Popconfirm title={t('dhcp.reservation.deleteConfirm')} onConfirm={() => del.mutate(r.id)}>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${t('dhcp.reservations')} — ${subnet.name}`}
|
||||
open
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={640}
|
||||
>
|
||||
{msgCtx}
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type="primary" icon={<PlusOutlined />} disabled={isViewer}
|
||||
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<DHCPReservation>); setFormOpen(true) }}
|
||||
>
|
||||
{t('dhcp.reservation.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<DHCPReservation> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editing ? t('dhcp.reservation.edit') : t('dhcp.reservation.add')}
|
||||
open={formOpen}
|
||||
onCancel={() => setFormOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={save.isPending}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form<DHCPReservation> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('dhcp.reservation.name')} name="name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.reservation.mac')} name="mac_address" rules={[{ required: true }]}>
|
||||
<Input placeholder="aa:bb:cc:dd:ee:ff" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.reservation.ip')} name="ip_address" rules={[{ required: true }]}>
|
||||
<Input placeholder="10.0.0.50" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.reservation.hostname')} name="hostname">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{subnet.subnet_cidr}</Text>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user