feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -116,7 +116,17 @@ export default function NATRulesTab() {
|
||||
|
||||
const columns: ColumnsType<NATRule> = [
|
||||
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
|
||||
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
|
||||
{ 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) => (
|
||||
@@ -126,11 +136,11 @@ export default function NATRulesTab() {
|
||||
{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_start ? `-${r.match_dport_end}` : ''}</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', render: (_, r) => renderTarget(r) },
|
||||
{ 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) => (
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, EyeOutlined, FireOutlined, PlusOutlined,
|
||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
||||
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
const { Text } = Typography
|
||||
@@ -159,10 +160,27 @@ export default function RulesTab() {
|
||||
if (cidr) return cidr
|
||||
return 'any'
|
||||
}
|
||||
|
||||
// Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS"
|
||||
const autoDescription = (r: FwRule): string => {
|
||||
const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)
|
||||
const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)
|
||||
const svc = r.service_object_id ? svLabel(r.service_object_id)
|
||||
: r.service_group_id ? sgLabel(r.service_group_id)
|
||||
: 'any'
|
||||
return `${r.src_zone}/${src} → ${r.dst_zone}/${dst} · ${svc}`
|
||||
}
|
||||
|
||||
const renderService = (objID?: number | null, grpID?: number | null) => {
|
||||
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
|
||||
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}>⊂ {sgLabel(grpID)}</Tag>
|
||||
return <Tag style={{ fontSize: 11, color: '#94A3B8' }}>any</Tag>
|
||||
return <span className="fw-addr-any">any</span>
|
||||
}
|
||||
|
||||
const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => {
|
||||
const label = renderAddrCompact(objID, grpID, cidr)
|
||||
if (label === 'any') return <span className="fw-addr-any">any</span>
|
||||
return <Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>{label}</Text>
|
||||
}
|
||||
|
||||
// ── Filter state ─────────────────────────────────────────────
|
||||
@@ -294,11 +312,7 @@ export default function RulesTab() {
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<ZoneBadge zone={r.src_zone} />
|
||||
{(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && (
|
||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
||||
{renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
||||
</Text>
|
||||
)}
|
||||
{renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -311,11 +325,7 @@ export default function RulesTab() {
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<ZoneBadge zone={r.dst_zone} />
|
||||
{(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && (
|
||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
||||
{renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
||||
</Text>
|
||||
)}
|
||||
{renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -327,11 +337,16 @@ export default function RulesTab() {
|
||||
title: t('fw.rule.name'), key: 'name', ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
{r.name && <div style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>}
|
||||
{r.comment && (
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 1 }}>{r.comment}</div>
|
||||
)}
|
||||
{!r.name && !r.comment && <Text type="secondary" style={{ fontSize: 11 }}>—</Text>}
|
||||
{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 className="fw-rule-desc">{autoDescription(r)}</div>
|
||||
}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -339,7 +354,13 @@ export default function RulesTab() {
|
||||
title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const,
|
||||
render: (_, r) => {
|
||||
const c = counterByID.get(r.id)
|
||||
if (!c || c.packets === 0) return <Text type="secondary" style={{ fontSize: 11 }}>—</Text>
|
||||
if (!c || c.packets === 0) return (
|
||||
<Tooltip title={r.enabled ? t('fw.rule.zeroHitHint') : undefined}>
|
||||
<span style={{ fontSize: 11, color: r.enabled ? '#FAAD14' : '#CBD5E1' }}>
|
||||
{r.enabled ? <><WarningOutlined style={{ fontSize: 10, marginRight: 2 }} />0</> : '—'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
return (
|
||||
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
|
||||
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
|
||||
@@ -372,19 +393,19 @@ export default function RulesTab() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '', key: 'move', width: 60,
|
||||
title: '', key: 'move', width: 52,
|
||||
render: (_, row) => {
|
||||
const idx = sortedRules.findIndex(r => r.id === row.id)
|
||||
const swapping = swap.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={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('fw.rule.moveDown')}>
|
||||
<Button size="small" icon={<ArrowDownOutlined />}
|
||||
<Button type="text" size="small" icon={<ArrowDownOutlined />}
|
||||
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
|
||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
|
||||
</Tooltip>
|
||||
@@ -393,30 +414,27 @@ export default function RulesTab() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '', key: 'actions', width: 120,
|
||||
title: '', key: 'actions', width: 88,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Space size={0} className="fw-row-actions">
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
|
||||
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<EditOutlined />}
|
||||
disabled={isViewer}
|
||||
onClick={() => editFromRow(row)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
<Button type="text" size="small" icon={<CopyOutlined />}
|
||||
disabled={isViewer}
|
||||
loading={duplicate.isPending && duplicate.variables?.id === row.id}
|
||||
onClick={() => duplicate.mutate(row)}
|
||||
/>
|
||||
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.rule.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
@@ -486,7 +504,14 @@ export default function RulesTab() {
|
||||
loading={isLoading}
|
||||
dataSource={filteredRules}
|
||||
columns={columns}
|
||||
rowClassName={(row: FwRule) => row.enabled ? '' : 'fw-rule-row--disabled'}
|
||||
rowClassName={(row: FwRule) => {
|
||||
const c = counterByID.get(row.id)
|
||||
const zeroHit = row.enabled && (!c || c.packets === 0)
|
||||
return [
|
||||
!row.enabled ? 'fw-rule-row--disabled' : '',
|
||||
zeroHit ? 'fw-rule-row--zero-hit' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
}}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<FireOutlined />}
|
||||
|
||||
Reference in New Issue
Block a user