import { useMemo, useState } from 'react' import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons' const { Text } = Typography import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import apiClient, { isEnvelope } from '../../api/client' import { useAuthStore } from '../../stores/auth' import type { FwZone, NATRule } from './types' interface FormValues { name?: string priority: number enabled: boolean kind: NATRule['kind'] in_zone?: string out_zone?: string proto?: 'tcp' | 'udp' | 'any' match_src_cidr?: string match_dst_cidr?: string match_dport_start?: number match_dport_end?: number target_addr?: string target_port_start?: number target_port_end?: number comment?: string } async function listNAT(): Promise { const r = await apiClient.get('/firewall/nat-rules') if (!isEnvelope(r.data)) return [] return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? [] } async function listZones(): Promise { const r = await apiClient.get('/firewall/zones') if (!isEnvelope(r.data)) return [] return (r.data.data as { zones?: FwZone[] }).zones ?? [] } const KIND_COLORS: Record = { dnat: 'blue', snat: 'purple', masquerade: 'gold', } export default function NATRulesTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data, isLoading } = useQuery({ queryKey: ['fw', 'nat'], queryFn: listNAT }) const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones }) // NAT zones don't accept "any" — the renderer needs a concrete // iface group to attach DNAT/SNAT/masq chains to. Fallback to the // seed list while loading. const zoneOptions: string[] = zones && zones.length > 0 ? zones.map((z) => z.name) : ['wan', 'lan', 'dmz', 'mgmt', 'cluster'] const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const create = useMutation({ mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/nat-rules', v) }, onSuccess: () => { message.success(t('common.save')); setCreating(false); form.resetFields() void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) }, onError: (e: Error) => message.error(e.message), }) const update = useMutation({ mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/nat-rules/${id}`, v) }, onSuccess: () => { message.success(t('common.save')); setEditing(null); form.resetFields() void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/firewall/nat-rules/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) }, onError: (e: Error) => message.error(e.message), }) const duplicate = useMutation({ mutationFn: async (r: NATRule) => { const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as NATRule & { created_at?: unknown; updated_at?: unknown } await apiClient.post('/firewall/nat-rules', { ...rest, name: r.name ? `${r.name} (copy)` : undefined, priority: r.priority + 1, enabled: false, }) }, onSuccess: () => { message.success(t('fw.rule.duplicated')) void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) }, onError: (e: Error) => message.error(e.message), }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: NATRule; checked: boolean }) => { await apiClient.put(`/firewall/nat-rules/${id}`, { ...row, enabled: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) }, onError: (e: Error) => message.error(e.message), }) const sortedNAT = useMemo( () => [...(data ?? [])].sort((a, b) => a.priority - b.priority), [data], ) const swapNAT = useMutation({ mutationFn: async ({ a, b }: { a: NATRule; b: NATRule }) => { await apiClient.put(`/firewall/nat-rules/${a.id}`, { ...a, priority: b.priority }) await apiClient.put(`/firewall/nat-rules/${b.id}`, { ...b, priority: a.priority }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) }, onError: (e: Error) => message.error(e.message), }) const renderTarget = (r: NATRule) => { if (r.kind === 'masquerade') return {r.out_zone ?? '?'}-iface IP if (!r.target_addr) return '—' return {r.target_addr}{r.target_port_start ? `:${r.target_port_start}${r.target_port_end !== r.target_port_start ? `-${r.target_port_end}` : ''}` : ''} } const openEdit = (row: NATRule) => { setEditing(row) form.setFieldsValue({ name: row.name ?? undefined, priority: row.priority, enabled: row.enabled, kind: row.kind, in_zone: row.in_zone ?? undefined, out_zone: row.out_zone ?? undefined, proto: row.proto ?? undefined, match_src_cidr: row.match_src_cidr ?? undefined, match_dst_cidr: row.match_dst_cidr ?? undefined, match_dport_start: row.match_dport_start ?? undefined, match_dport_end: row.match_dport_end ?? undefined, target_addr: row.target_addr ?? undefined, target_port_start: row.target_port_start ?? undefined, target_port_end: row.target_port_end ?? undefined, comment: row.comment ?? undefined, }) } const columns: ColumnsType = [ { title: '', key: 'dot', width: 28, render: (_, row) => ( ), }, { title: '#', dataIndex: 'priority', key: 'priority', width: 52, render: (v: number) => ( {v} ), }, { title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 110, render: (k: NATRule['kind']) => {k.toUpperCase()}, }, { title: t('fw.nat.name'), key: 'name', render: (_, r) => (
{r.name ?
{r.name}
:
{t('fw.rule.unnamed')}
} {r.comment &&
{r.comment}
}
), }, { title: t('fw.nat.match'), key: 'match', render: (_, r) => ( {r.in_zone && in:{r.in_zone}} {r.out_zone && out:{r.out_zone}} {r.proto && {r.proto}} {r.match_src_cidr && src={r.match_src_cidr}} {r.match_dst_cidr && dst={r.match_dst_cidr}} {r.match_dport_start && :{r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}} ), }, { title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) }, { title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 68, render: (v: boolean, row: NATRule) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: '', key: 'move', width: 52, render: (_, row) => { const idx = sortedNAT.findIndex(r => r.id === row.id) const swapping = swapNAT.isPending return ( !row.enabled ? 'fw-rule-row--disabled' : ''} emptyContent={ } title={t('fw.nat.emptyTitle')} description={t('fw.nat.emptyDesc')} action={ } /> } /> { setEditing(null); setCreating(false) }} onOk={() => { void form.submit() }} confirmLoading={create.isPending || update.isPending} destroyOnHidden width={560} >
{ if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }} > ({ value: z, label: z }))} /> )} {(kind === 'snat' || kind === 'masquerade') && ( ({ value: p, label: p }))} /> {kind === 'dnat' && ( <> ({ validator(_, val) { if (!val) return Promise.resolve() return val >= getFieldValue('match_dport_start') ? Promise.resolve() : Promise.reject(new Error(t('fw.nat.portRangeError'))) }, })]} > )} {kind !== 'masquerade' && ( <> {kind === 'dnat' && ( ({ validator(_, val) { if (!val) return Promise.resolve() return val >= getFieldValue('target_port_start') ? Promise.resolve() : Promise.reject(new Error(t('fw.nat.portRangeError'))) }, })]} > )} )} ) }}
) }