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 { 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 type { ColumnsType } from 'antd/es/table'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 DataTable from '../../components/DataTable'
|
||||||
import EmptyState from '../../components/EmptyState'
|
import EmptyState from '../../components/EmptyState'
|
||||||
@@ -86,6 +88,23 @@ export default function NATRulesTab() {
|
|||||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
||||||
onError: (e: Error) => message.error(e.message),
|
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({
|
const quickToggle = useMutation({
|
||||||
mutationFn: async ({ id, row, checked }: { id: number; row: NATRule; checked: boolean }) => {
|
mutationFn: async ({ id, row, checked }: { id: number; row: NATRule; checked: boolean }) => {
|
||||||
await apiClient.put(`/firewall/nat-rules/${id}`, { ...row, enabled: checked })
|
await apiClient.put(`/firewall/nat-rules/${id}`, { ...row, enabled: checked })
|
||||||
@@ -114,72 +133,7 @@ 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>
|
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 columns: ColumnsType<NATRule> = [
|
const openEdit = (row: 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,
|
|
||||||
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>}
|
|
||||||
</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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
|
|
||||||
{
|
|
||||||
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
|
||||||
render: (v: boolean, row: NATRule) => (
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={v}
|
|
||||||
disabled={isViewer}
|
|
||||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
|
||||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '', key: 'move', width: 64,
|
|
||||||
render: (_, row) => {
|
|
||||||
const idx = sortedNAT.findIndex(r => r.id === row.id)
|
|
||||||
const swapping = swapNAT.isPending
|
|
||||||
return (
|
|
||||||
<Space size={2}>
|
|
||||||
<Tooltip title={t('fw.rule.moveUp')}>
|
|
||||||
<Button 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 />}
|
|
||||||
disabled={isViewer || idx >= sortedNAT.length - 1 || swapping}
|
|
||||||
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx + 1] })} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('common.edit'), key: 'actions',
|
|
||||||
render: (_, row) => (
|
|
||||||
<Space>
|
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
||||||
<Button size="small" disabled={isViewer} onClick={() => {
|
|
||||||
setEditing(row)
|
setEditing(row)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: row.name ?? undefined,
|
name: row.name ?? undefined,
|
||||||
@@ -195,15 +149,108 @@ export default function NATRulesTab() {
|
|||||||
target_port_end: row.target_port_end ?? undefined,
|
target_port_end: row.target_port_end ?? undefined,
|
||||||
comment: row.comment ?? undefined,
|
comment: row.comment ?? undefined,
|
||||||
})
|
})
|
||||||
}}>{t('common.edit')}</Button>
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<NATRule> = [
|
||||||
|
{
|
||||||
|
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 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} 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: 68,
|
||||||
|
render: (v: boolean, row: NATRule) => (
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={v}
|
||||||
|
disabled={isViewer}
|
||||||
|
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||||
|
onChange={(checked) => 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 (
|
||||||
|
<Space size={1} className="fw-row-actions">
|
||||||
|
<Tooltip title={t('fw.rule.moveUp')}>
|
||||||
|
<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 type="text" size="small" icon={<ArrowDownOutlined />}
|
||||||
|
disabled={isViewer || idx >= sortedNAT.length - 1 || swapping}
|
||||||
|
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx + 1] })} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '', key: 'actions', width: 88,
|
||||||
|
render: (_, row) => (
|
||||||
|
<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>
|
</Tooltip>
|
||||||
{isViewer ? (
|
{isViewer ? (
|
||||||
<Tooltip title={t('auth.viewerBadge')}>
|
<Tooltip title={t('auth.viewerBadge')}>
|
||||||
<Button size="small" danger disabled>{t('common.delete')}</Button>
|
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Popconfirm title={t('fw.nat.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
|
<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>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -228,6 +275,7 @@ export default function NATRulesTab() {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
dataSource={sortedNAT}
|
dataSource={sortedNAT}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
rowClassName={(row: NATRule) => !row.enabled ? 'fw-rule-row--disabled' : ''}
|
||||||
emptyContent={
|
emptyContent={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<BranchesOutlined />}
|
icon={<BranchesOutlined />}
|
||||||
|
|||||||
Reference in New Issue
Block a user