feat(firewall): Regeln nach Zone-Pair gruppieren — v1.2.65
- Regeln werden standardmäßig nach Zone-Pair (src→dst) gruppiert - Jede Gruppe hat einen farbigen Header mit Zone-Badges + Regelanzahl - Toggle-Button in der Filter-Bar: Gruppen-Ansicht ↔ flache Liste - Move-up/down bleibt global korrekt (Priority über alle Gruppen) - CSS: .fw-zone-section* mit nahtlosem Header → Tabelle Übergang - i18n EN + DE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -154,7 +154,6 @@
|
||||
"emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen.",
|
||||
"logEnabled": "Logging aktiv — gematchte Pakete werden ins Firewall-Log geschrieben",
|
||||
"ruleDisabled": "Regel deaktiviert",
|
||||
"enabled": "Aktiv",
|
||||
"unnamed": "(kein Name)",
|
||||
"zeroHitHint": "Keine Treffer seit dem letzten Neustart — möglicherweise ungenutzte oder überlagerte Regel"
|
||||
},
|
||||
@@ -175,7 +174,10 @@
|
||||
"allActions": "Alle Aktionen",
|
||||
"allZones": "Alle Zonen",
|
||||
"noResults": "Keine Regeln entsprechen dem Filter",
|
||||
"noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen."
|
||||
"noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen.",
|
||||
"groupView": "Nach Zone gruppieren",
|
||||
"flatView": "Flache Liste",
|
||||
"rules": "Regeln"
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name",
|
||||
@@ -1758,4 +1760,4 @@
|
||||
"confirmRemove": "Collection {{name}} wirklich entfernen?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
"rule": {
|
||||
"name": "Name",
|
||||
"priority": "Priority",
|
||||
"enabled": "Enabled",
|
||||
"enabled": "Active",
|
||||
"log": "Log",
|
||||
"action": "Action",
|
||||
"src": "Source",
|
||||
@@ -154,7 +154,6 @@
|
||||
"emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards.",
|
||||
"logEnabled": "Logging active — matched packets are written to the firewall log",
|
||||
"ruleDisabled": "Rule disabled",
|
||||
"enabled": "Active",
|
||||
"unnamed": "(unnamed)",
|
||||
"zeroHitHint": "No hits since last restart — possibly unused or shadowed rule"
|
||||
},
|
||||
@@ -175,7 +174,10 @@
|
||||
"allActions": "All actions",
|
||||
"allZones": "All zones",
|
||||
"noResults": "No rules match the filter",
|
||||
"noResultsHint": "Clear the filter to see all rules."
|
||||
"noResultsHint": "Clear the filter to see all rules.",
|
||||
"groupView": "Group by zone",
|
||||
"flatView": "Flat list",
|
||||
"rules": "rules"
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name",
|
||||
@@ -1758,4 +1760,4 @@
|
||||
"confirmRemove": "Really remove collection {{name}}?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +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, DeleteOutlined, EditOutlined,
|
||||
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
||||
AppstoreOutlined, ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
||||
EyeOutlined, FireOutlined, PlusOutlined, UnorderedListOutlined, WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { InlineNote, InlineLabels } from './InlineEditors'
|
||||
|
||||
@@ -189,6 +189,7 @@ export default function RulesTab() {
|
||||
const [filterAction, setFilterAction] = useState<string>('')
|
||||
const [filterZone, setFilterZone] = useState<string>('')
|
||||
|
||||
const [groupByZone, setGroupByZone] = useState(true)
|
||||
const [editing, setEditing] = useState<FwRule | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
@@ -198,6 +199,25 @@ export default function RulesTab() {
|
||||
[rules],
|
||||
)
|
||||
|
||||
const groupedSections = useMemo(() => {
|
||||
const map = new Map<string, { srcZone: string; dstZone: string; rules: FwRule[] }>()
|
||||
for (const r of filteredRules) {
|
||||
const key = `${r.src_zone}→${r.dst_zone}`
|
||||
if (!map.has(key)) map.set(key, { srcZone: r.src_zone, dstZone: r.dst_zone, rules: [] })
|
||||
map.get(key)!.rules.push(r)
|
||||
}
|
||||
return Array.from(map.entries()).map(([key, v]) => ({ key, ...v }))
|
||||
}, [filteredRules])
|
||||
|
||||
const rowClassFn = (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(' ')
|
||||
}
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
let r = sortedRules
|
||||
if (searchText) {
|
||||
@@ -518,6 +538,14 @@ export default function RulesTab() {
|
||||
</Text>
|
||||
)}
|
||||
<div className="fw-filter-bar-right">
|
||||
<Tooltip title={groupByZone ? t('fw.filter.flatView') : t('fw.filter.groupView')}>
|
||||
<Button
|
||||
type={groupByZone ? 'default' : 'text'}
|
||||
size="small"
|
||||
icon={groupByZone ? <AppstoreOutlined /> : <UnorderedListOutlined />}
|
||||
onClick={() => setGroupByZone(v => !v)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
||||
{t('fw.rule.add')}
|
||||
@@ -526,20 +554,8 @@ export default function RulesTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={filteredRules}
|
||||
columns={columns}
|
||||
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={
|
||||
{groupByZone ? (
|
||||
groupedSections.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FireOutlined />}
|
||||
title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')}
|
||||
@@ -554,8 +570,53 @@ export default function RulesTab() {
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
groupedSections.map(({ key, srcZone, dstZone, rules: groupRules }) => (
|
||||
<div key={key} className="fw-zone-section">
|
||||
<div className="fw-zone-section-header">
|
||||
<ZoneBadge zone={srcZone} />
|
||||
<span className="fw-zone-section-arrow">→</span>
|
||||
<ZoneBadge zone={dstZone} />
|
||||
<span className="fw-zone-section-count">
|
||||
{groupRules.length} {t('fw.filter.rules')}
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={groupRules}
|
||||
columns={columns}
|
||||
rowClassName={rowClassFn}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={filteredRules}
|
||||
columns={columns}
|
||||
rowClassName={rowClassFn}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<FireOutlined />}
|
||||
title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')}
|
||||
description={activeFilters ? t('fw.filter.noResultsHint') : t('fw.rule.emptyDesc')}
|
||||
action={
|
||||
!activeFilters ? (
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
||||
{t('fw.rule.add')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={editing ? t('fw.rule.edit') : t('fw.rule.add')}
|
||||
|
||||
@@ -3023,6 +3023,40 @@ h1, h2, h3, h4, h5, h6 {
|
||||
border-bottom-color: #E2E8F0 !important;
|
||||
}
|
||||
|
||||
/* ── Firewall Zone-Sections ──────────────────────────────────────────── */
|
||||
.fw-zone-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.fw-zone-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #F8FAFC;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
.fw-zone-section-arrow {
|
||||
color: #94A3B8;
|
||||
font-size: 13px;
|
||||
}
|
||||
.fw-zone-section-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #94A3B8;
|
||||
}
|
||||
.fw-zone-section .ant-table-wrapper {
|
||||
border-radius: 0 0 8px 8px !important;
|
||||
}
|
||||
.fw-zone-section .ant-table {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Firewall Filter-Bar ─────────────────────────────────────────────── */
|
||||
.fw-filter-bar {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user