feat(firewall): NAT-Regeln enterprise-Design + Duplicate

- Status-Dot, monospace Priorität, icon-only Hover-Actions
- Duplicate-Button (CopyOutlined) — disabled=false copy + priority+1
- rowClassName für deaktivierte NAT-Regeln (fw-rule-row--disabled)
- Spaltenheader bereinigt (kein 'Edit'-Text mehr)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-31 18:27:28 +02:00
parent 1d06b28064
commit 13cb9a8fc4
2 changed files with 91 additions and 43 deletions

View File

@@ -1 +1 @@
1.2.47
1.2.48

View File

@@ -1,9 +1,11 @@
import { useMemo, useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, message } from 'antd'
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 } from '@ant-design/icons'
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
const { Text } = Typography
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
@@ -86,6 +88,23 @@ export default function NATRulesTab() {
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 })
@@ -114,35 +133,71 @@ export default function NATRulesTab() {
return <code>{r.target_addr}{r.target_port_start ? `:${r.target_port_start}${r.target_port_end !== r.target_port_start ? `-${r.target_port_end}` : ''}` : ''}</code>
}
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<NATRule> = [
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 100, render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
{
title: t('fw.nat.name'), key: 'name', width: 200,
title: '', key: 'dot', width: 28,
render: (_, row) => (
<Tooltip title={row.enabled ? t('fw.rule.enabled') : t('fw.rule.ruleDisabled')}>
<span className={`fw-rule-dot fw-rule-dot--${row.enabled ? 'on' : 'off'}`} />
</Tooltip>
),
},
{
title: '#', dataIndex: 'priority', key: 'priority', width: 52,
render: (v: number) => (
<Text style={{ fontFamily: 'monospace', fontSize: 12, color: '#475569' }}>{v}</Text>
),
},
{
title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 110,
render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag>,
},
{
title: t('fw.nat.name'), key: 'name',
render: (_, r) => (
<div>
{r.name && <div style={{ fontWeight: 500 }}>{r.name}</div>}
{r.comment && <div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{r.comment}</div>}
{!r.name && !r.comment && <span style={{ color: 'var(--ant-color-text-quaternary)' }}></span>}
{r.name
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</div>
}
{r.comment && <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>}
</div>
),
},
{
title: t('fw.nat.match'), key: 'match',
render: (_, r) => (
<Space size={4}>
{r.in_zone && <Tag>in:{r.in_zone}</Tag>}
{r.out_zone && <Tag>out:{r.out_zone}</Tag>}
{r.proto && <Tag>{r.proto}</Tag>}
{r.match_src_cidr && <code>src={r.match_src_cidr}</code>}
{r.match_dst_cidr && <code>dst={r.match_dst_cidr}</code>}
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
<Space size={4} wrap>
{r.in_zone && <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>in:{r.in_zone}</Tag>}
{r.out_zone && <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>out:{r.out_zone}</Tag>}
{r.proto && <Tag style={{ fontSize: 11 }}>{r.proto}</Tag>}
{r.match_src_cidr && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>src={r.match_src_cidr}</Text>}
{r.match_dst_cidr && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>dst={r.match_dst_cidr}</Text>}
{r.match_dport_start && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>:{r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</Text>}
</Space>
),
},
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
{
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 68,
render: (v: boolean, row: NATRule) => (
<Switch
size="small"
@@ -154,19 +209,19 @@ export default function NATRulesTab() {
),
},
{
title: '', key: 'move', width: 64,
title: '', key: 'move', width: 52,
render: (_, row) => {
const idx = sortedNAT.findIndex(r => r.id === row.id)
const swapping = swapNAT.isPending
return (
<Space size={2}>
<Space size={1} className="fw-row-actions">
<Tooltip title={t('fw.rule.moveUp')}>
<Button size="small" icon={<ArrowUpOutlined />}
<Button type="text" size="small" icon={<ArrowUpOutlined />}
disabled={isViewer || idx <= 0 || swapping}
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx - 1] })} />
</Tooltip>
<Tooltip title={t('fw.rule.moveDown')}>
<Button size="small" icon={<ArrowDownOutlined />}
<Button type="text" size="small" icon={<ArrowDownOutlined />}
disabled={isViewer || idx >= sortedNAT.length - 1 || swapping}
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx + 1] })} />
</Tooltip>
@@ -175,35 +230,27 @@ export default function NATRulesTab() {
},
},
{
title: t('common.edit'), key: 'actions',
title: '', key: 'actions', width: 88,
render: (_, row) => (
<Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" disabled={isViewer} onClick={() => {
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,
})
}}>{t('common.edit')}</Button>
<Space size={0} className="fw-row-actions">
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => openEdit(row)} />
</Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
<Button type="text" size="small" icon={<CopyOutlined />}
disabled={isViewer}
loading={duplicate.isPending && duplicate.variables?.id === row.id}
onClick={() => duplicate.mutate(row)} />
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button size="small" danger disabled>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
</Tooltip>
) : (
<Popconfirm title={t('fw.nat.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
</Popconfirm>
)}
</Space>
@@ -228,6 +275,7 @@ export default function NATRulesTab() {
loading={isLoading}
dataSource={sortedNAT}
columns={columns}
rowClassName={(row: NATRule) => !row.enabled ? 'fw-rule-row--disabled' : ''}
emptyContent={
<EmptyState
icon={<BranchesOutlined />}