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:
Debian
2026-06-02 14:04:56 +02:00
parent 9580070a50
commit d425b696f1
5 changed files with 125 additions and 26 deletions

View File

@@ -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?"
}
}
}
}

View File

@@ -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}}?"
}
}
}
}

View File

@@ -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')}

View File

@@ -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;