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 (
} title={t('dhcp.title')} subtitle={t('dhcp.intro')} /> }, { key: 'subnets', label: t('dhcp.tabs.subnets'), children: }, ]} />
) } // ── 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() 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 ( {msgCtx} form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> ) } // ── 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() const [editing, setEditing] = useState(null) const [open, setOpen] = useState(false) const [resvFor, setResvFor] = useState(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) setOpen(true) } const openEdit = (s: DHCPSubnet) => { setEditing(s) form.setFieldsValue(s) setOpen(true) } const cols: ColumnsType = [ { 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) => {v ? t('common.active') : t('common.inactive')}, }, { title: t('common.actions'), key: 'actions', width: 280, render: (_, r) => ( {!isViewer && } {!isViewer && ( del.mutate(r.id)}> )} ), }, ] return ( {msgCtx} rowKey="id" size="small" columns={cols} dataSource={subnets ?? []} pagination={false} /> setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose > form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> {resvFor && ( setResvFor(null)} /> )} ) } // ── 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() const [editing, setEditing] = useState(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 = [ { 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) => ( {!isViewer && } {!isViewer && ( del.mutate(r.id)}> )} ), }, ] return ( {msgCtx} rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} /> setFormOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose > form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> {subnet.subnet_cidr} ) }