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:
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user