feat(firewall): Enterprise-UI-Redesign — KPI-Strip, Zone-Badges, Filter-Bar (v1.1.136)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.135"
|
var version = "1.1.136"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.135"
|
var version = "1.1.136"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.135"
|
var version = "1.1.136"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -112,7 +112,28 @@
|
|||||||
"moveUp": "Nach oben (höhere Priorität)",
|
"moveUp": "Nach oben (höhere Priorität)",
|
||||||
"moveDown": "Nach unten (niedrigere Priorität)",
|
"moveDown": "Nach unten (niedrigere Priorität)",
|
||||||
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
|
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
|
||||||
"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."
|
"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"
|
||||||
|
},
|
||||||
|
"kpi": {
|
||||||
|
"policyRules": "Policy-Regeln",
|
||||||
|
"active": "aktiv",
|
||||||
|
"disabled": "deaktiviert",
|
||||||
|
"natRules": "NAT-Regeln",
|
||||||
|
"natHint": "DNAT / SNAT / Masquerade",
|
||||||
|
"zones": "Zonen",
|
||||||
|
"zonesHint": "Netzwerksegmente",
|
||||||
|
"defaultPolicy": "Default-Policy",
|
||||||
|
"nftActive": "nftables aktiv",
|
||||||
|
"nftInactive": "nftables inaktiv"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"search": "Name / Kommentar suchen…",
|
||||||
|
"allActions": "Alle Aktionen",
|
||||||
|
"allZones": "Alle Zonen",
|
||||||
|
"noResults": "Keine Regeln entsprechen dem Filter",
|
||||||
|
"noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen."
|
||||||
},
|
},
|
||||||
"nat": {
|
"nat": {
|
||||||
"name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv",
|
"name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv",
|
||||||
|
|||||||
@@ -112,7 +112,28 @@
|
|||||||
"moveUp": "Move up (higher priority)",
|
"moveUp": "Move up (higher priority)",
|
||||||
"moveDown": "Move down (lower priority)",
|
"moveDown": "Move down (lower priority)",
|
||||||
"emptyTitle": "No custom firewall rules yet.",
|
"emptyTitle": "No custom firewall rules yet.",
|
||||||
"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."
|
"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"
|
||||||
|
},
|
||||||
|
"kpi": {
|
||||||
|
"policyRules": "Policy Rules",
|
||||||
|
"active": "active",
|
||||||
|
"disabled": "disabled",
|
||||||
|
"natRules": "NAT Rules",
|
||||||
|
"natHint": "DNAT / SNAT / Masquerade",
|
||||||
|
"zones": "Zones",
|
||||||
|
"zonesHint": "Network segments",
|
||||||
|
"defaultPolicy": "Default Policy",
|
||||||
|
"nftActive": "nftables active",
|
||||||
|
"nftInactive": "nftables inactive"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"search": "Search name / comment…",
|
||||||
|
"allActions": "All actions",
|
||||||
|
"allZones": "All zones",
|
||||||
|
"noResults": "No rules match the filter",
|
||||||
|
"noResultsHint": "Clear the filter to see all rules."
|
||||||
},
|
},
|
||||||
"nat": {
|
"nat": {
|
||||||
"name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled",
|
"name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled",
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, 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, FireOutlined } from '@ant-design/icons'
|
import {
|
||||||
|
ArrowDownOutlined, ArrowUpOutlined, EyeOutlined, FireOutlined, PlusOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -37,12 +42,6 @@ interface FormValues {
|
|||||||
comment?: string
|
comment?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_COLORS: Record<FwRule['action'], string> = {
|
|
||||||
accept: 'green',
|
|
||||||
drop: 'red',
|
|
||||||
reject: 'orange',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RuleCounter { rule_id: number; packets: number; bytes: number }
|
interface RuleCounter { rule_id: number; packets: number; bytes: number }
|
||||||
|
|
||||||
async function listCounters(): Promise<RuleCounter[]> {
|
async function listCounters(): Promise<RuleCounter[]> {
|
||||||
@@ -110,10 +109,28 @@ function buildPayload(v: FormValues) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Zone badge with semantic colors ──────────────────────────────
|
||||||
|
const ZONE_CLASSES: Record<string, string> = {
|
||||||
|
wan: 'fw-zone-badge--wan', lan: 'fw-zone-badge--lan',
|
||||||
|
dmz: 'fw-zone-badge--dmz', mgmt: 'fw-zone-badge--mgmt',
|
||||||
|
cluster: 'fw-zone-badge--cluster', vpn: 'fw-zone-badge--vpn',
|
||||||
|
any: 'fw-zone-badge--any',
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZoneBadge({ zone }: { zone: string }) {
|
||||||
|
const cls = ZONE_CLASSES[zone] ?? 'fw-zone-badge--default'
|
||||||
|
return <span className={`fw-zone-badge ${cls}`}>{zone}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionBadge({ action }: { action: FwRule['action'] }) {
|
||||||
|
return <span className={`fw-action-badge fw-action-badge--${action}`}>{action.toUpperCase()}</span>
|
||||||
|
}
|
||||||
|
|
||||||
export default function RulesTab() {
|
export default function RulesTab() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||||
|
|
||||||
const { data: rules, isLoading } = useQuery({ queryKey: ['fw', 'rules'], queryFn: listRules })
|
const { data: rules, isLoading } = useQuery({ queryKey: ['fw', 'rules'], queryFn: listRules })
|
||||||
const { data: aos } = useQuery({ queryKey: ['fw', 'addr-obj'], queryFn: listAO })
|
const { data: aos } = useQuery({ queryKey: ['fw', 'addr-obj'], queryFn: listAO })
|
||||||
const { data: ags } = useQuery({ queryKey: ['fw', 'addr-grp'], queryFn: listAG })
|
const { data: ags } = useQuery({ queryKey: ['fw', 'addr-grp'], queryFn: listAG })
|
||||||
@@ -127,8 +144,6 @@ export default function RulesTab() {
|
|||||||
})
|
})
|
||||||
const counterByID = new Map((counters ?? []).map(c => [c.rule_id, c]))
|
const counterByID = new Map((counters ?? []).map(c => [c.rule_id, c]))
|
||||||
|
|
||||||
// Picker options: 'any' (special) + every zone the operator has
|
|
||||||
// declared. Fallback to the seed list while the query is loading.
|
|
||||||
const zoneOptions: Zone[] = zones && zones.length > 0
|
const zoneOptions: Zone[] = zones && zones.length > 0
|
||||||
? ['any', ...zones.map((z) => z.name)]
|
? ['any', ...zones.map((z) => z.name)]
|
||||||
: ZONES_FALLBACK
|
: ZONES_FALLBACK
|
||||||
@@ -138,22 +153,46 @@ export default function RulesTab() {
|
|||||||
const svLabel = (id?: number | null) => svs?.find(s => s.id === id)?.name ?? `#${id}`
|
const svLabel = (id?: number | null) => svs?.find(s => s.id === id)?.name ?? `#${id}`
|
||||||
const sgLabel = (id?: number | null) => sgs?.find(g => g.id === id)?.name ?? `#${id}`
|
const sgLabel = (id?: number | null) => sgs?.find(g => g.id === id)?.name ?? `#${id}`
|
||||||
|
|
||||||
const renderSide = (objID?: number | null, grpID?: number | null, cidr?: string | null) => {
|
const renderAddrCompact = (objID?: number | null, grpID?: number | null, cidr?: string | null): string => {
|
||||||
if (objID) return <Tag>obj:{aoLabel(objID)}</Tag>
|
if (objID) return aoLabel(objID)
|
||||||
if (grpID) return <Tag color="purple">grp:{agLabel(grpID)}</Tag>
|
if (grpID) return `⊂ ${agLabel(grpID)}`
|
||||||
if (cidr) return <code>{cidr}</code>
|
if (cidr) return cidr
|
||||||
return <Tag>any</Tag>
|
return 'any'
|
||||||
}
|
}
|
||||||
const renderService = (objID?: number | null, grpID?: number | null) => {
|
const renderService = (objID?: number | null, grpID?: number | null) => {
|
||||||
if (objID) return <Tag>{svLabel(objID)}</Tag>
|
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
|
||||||
if (grpID) return <Tag color="purple">grp:{sgLabel(grpID)}</Tag>
|
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}>⊂ {sgLabel(grpID)}</Tag>
|
||||||
return <Tag>any</Tag>
|
return <Tag style={{ fontSize: 11, color: '#94A3B8' }}>any</Tag>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Filter state ─────────────────────────────────────────────
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const [filterAction, setFilterAction] = useState<string>('')
|
||||||
|
const [filterZone, setFilterZone] = useState<string>('')
|
||||||
|
|
||||||
const [editing, setEditing] = useState<FwRule | null>(null)
|
const [editing, setEditing] = useState<FwRule | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [form] = Form.useForm<FormValues>()
|
const [form] = Form.useForm<FormValues>()
|
||||||
|
|
||||||
|
const sortedRules = useMemo(
|
||||||
|
() => [...(rules ?? [])].sort((a, b) => a.priority - b.priority),
|
||||||
|
[rules],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredRules = useMemo(() => {
|
||||||
|
let r = sortedRules
|
||||||
|
if (searchText) {
|
||||||
|
const q = searchText.toLowerCase()
|
||||||
|
r = r.filter(rule =>
|
||||||
|
(rule.name ?? '').toLowerCase().includes(q) ||
|
||||||
|
(rule.comment ?? '').toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filterAction) r = r.filter(rule => rule.action === filterAction)
|
||||||
|
if (filterZone) r = r.filter(rule => rule.src_zone === filterZone || rule.dst_zone === filterZone)
|
||||||
|
return r
|
||||||
|
}, [sortedRules, searchText, filterAction, filterZone])
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/rules', buildPayload(v)) },
|
mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/rules', buildPayload(v)) },
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -163,7 +202,9 @@ export default function RulesTab() {
|
|||||||
onError: (e: Error) => message.error(e.message),
|
onError: (e: Error) => message.error(e.message),
|
||||||
})
|
})
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/rules/${id}`, buildPayload(v)) },
|
mutationFn: async ({ id, v }: { id: number; v: FormValues }) => {
|
||||||
|
await apiClient.put(`/firewall/rules/${id}`, buildPayload(v))
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success(t('common.save')); setEditing(null); form.resetFields()
|
message.success(t('common.save')); setEditing(null); form.resetFields()
|
||||||
void qc.invalidateQueries({ queryKey: ['fw', 'rules'] })
|
void qc.invalidateQueries({ queryKey: ['fw', 'rules'] })
|
||||||
@@ -183,11 +224,6 @@ export default function RulesTab() {
|
|||||||
onError: (e: Error) => message.error(e.message),
|
onError: (e: Error) => message.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedRules = useMemo(
|
|
||||||
() => [...(rules ?? [])].sort((a, b) => a.priority - b.priority),
|
|
||||||
[rules],
|
|
||||||
)
|
|
||||||
|
|
||||||
const swap = useMutation({
|
const swap = useMutation({
|
||||||
mutationFn: async ({ a, b }: { a: FwRule; b: FwRule }) => {
|
mutationFn: async ({ a, b }: { a: FwRule; b: FwRule }) => {
|
||||||
await apiClient.put(`/firewall/rules/${a.id}`, { ...a, priority: b.priority })
|
await apiClient.put(`/firewall/rules/${a.id}`, { ...a, priority: b.priority })
|
||||||
@@ -218,25 +254,96 @@ export default function RulesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<FwRule> = [
|
const columns: ColumnsType<FwRule> = [
|
||||||
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
|
|
||||||
{
|
{
|
||||||
title: t('fw.rule.action'), dataIndex: 'action', key: 'action',
|
title: '', key: 'dot', width: 28,
|
||||||
render: (a: FwRule['action']) => <Tag color={ACTION_COLORS[a]}>{a.toUpperCase()}</Tag>,
|
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.rule.action'), key: 'action', width: 90,
|
||||||
|
render: (_, r) => <ActionBadge action={r.action} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('fw.rule.src'), key: 'src',
|
title: t('fw.rule.src'), key: 'src',
|
||||||
render: (_, r) => <Space size={4}><Tag>{r.src_zone}</Tag>{renderSide(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}</Space>,
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '', key: 'arrow', width: 24, align: 'center' as const,
|
||||||
|
render: () => <span className="fw-flow-arrow">→</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('fw.rule.dst'), key: 'dst',
|
title: t('fw.rule.dst'), key: 'dst',
|
||||||
render: (_, r) => <Space size={4}><Tag>{r.dst_zone}</Tag>{renderSide(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}</Space>,
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('fw.rule.service'), key: 'svc',
|
title: t('fw.rule.service'), key: 'svc', width: 130,
|
||||||
render: (_, r) => renderService(r.service_object_id, r.service_group_id),
|
render: (_, r) => renderService(r.service_object_id, r.service_group_id),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
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>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
return (
|
||||||
|
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
|
||||||
|
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
|
||||||
|
{c.packets >= 1_000_000
|
||||||
|
? `${(c.packets / 1_000_000).toFixed(1)}M`
|
||||||
|
: c.packets >= 1_000
|
||||||
|
? `${(c.packets / 1_000).toFixed(1)}k`
|
||||||
|
: c.packets.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Log', key: 'log', width: 38, align: 'center' as const,
|
||||||
|
render: (_, r) => r.log
|
||||||
|
? <Tooltip title={t('fw.rule.logEnabled')}><EyeOutlined style={{ color: '#0EA5E9', fontSize: 13 }} /></Tooltip>
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', width: 68,
|
||||||
render: (v: boolean, row: FwRule) => (
|
render: (v: boolean, row: FwRule) => (
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
@@ -247,52 +354,35 @@ export default function RulesTab() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: t('fw.rule.name'), dataIndex: 'name', key: 'name', render: (v?: string) => v ?? '—' },
|
|
||||||
{
|
{
|
||||||
title: t('fw.rule.hits'), key: 'hits', width: 90,
|
title: '', key: 'move', width: 60,
|
||||||
render: (_, r) => {
|
|
||||||
const c = counterByID.get(r.id)
|
|
||||||
if (!c) return <Text type="secondary" style={{ fontSize: 11 }}>—</Text>
|
|
||||||
return (
|
|
||||||
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
|
|
||||||
<Text style={{ fontSize: 11 }}>{c.packets.toLocaleString()}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '', key: 'move', width: 64,
|
|
||||||
render: (_, row) => {
|
render: (_, row) => {
|
||||||
const idx = sortedRules.findIndex(r => r.id === row.id)
|
const idx = sortedRules.findIndex(r => r.id === row.id)
|
||||||
const swapping = swap.isPending
|
const swapping = swap.isPending
|
||||||
return (
|
return (
|
||||||
<Space size={2}>
|
<Space size={2}>
|
||||||
<Tooltip title={t('fw.rule.moveUp')}>
|
<Tooltip title={t('fw.rule.moveUp')}>
|
||||||
<Button
|
<Button size="small" icon={<ArrowUpOutlined />}
|
||||||
size="small"
|
|
||||||
icon={<ArrowUpOutlined />}
|
|
||||||
disabled={isViewer || idx <= 0 || swapping}
|
disabled={isViewer || idx <= 0 || swapping}
|
||||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })}
|
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('fw.rule.moveDown')}>
|
<Tooltip title={t('fw.rule.moveDown')}>
|
||||||
<Button
|
<Button size="small" icon={<ArrowDownOutlined />}
|
||||||
size="small"
|
|
||||||
icon={<ArrowDownOutlined />}
|
|
||||||
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
|
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
|
||||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })}
|
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('common.edit'), key: 'actions',
|
title: '', key: 'actions', width: 100,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<Space size={4}>
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
|
||||||
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>{t('common.edit')}</Button>
|
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isViewer ? (
|
{isViewer ? (
|
||||||
<Tooltip title={t('auth.viewerBadge')}>
|
<Tooltip title={t('auth.viewerBadge')}>
|
||||||
@@ -317,31 +407,78 @@ export default function RulesTab() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeFilters = !!(searchText || filterAction || filterZone)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* ── Filter bar ─────────────────────────────────────── */}
|
||||||
|
<div className="fw-filter-bar">
|
||||||
|
<Input.Search
|
||||||
|
placeholder={t('fw.filter.search')}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 240 }}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
onSearch={v => setSearchText(v)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 150 }}
|
||||||
|
placeholder={t('fw.filter.allActions')}
|
||||||
|
allowClear
|
||||||
|
value={filterAction || undefined}
|
||||||
|
onChange={v => setFilterAction(v ?? '')}
|
||||||
|
options={[
|
||||||
|
{ value: 'accept', label: 'ACCEPT' },
|
||||||
|
{ value: 'drop', label: 'DROP' },
|
||||||
|
{ value: 'reject', label: 'REJECT' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 150 }}
|
||||||
|
placeholder={t('fw.filter.allZones')}
|
||||||
|
allowClear
|
||||||
|
value={filterZone || undefined}
|
||||||
|
onChange={v => setFilterZone(v ?? '')}
|
||||||
|
options={zoneOptions.filter(z => z !== 'any').map(z => ({ value: z, label: z }))}
|
||||||
|
/>
|
||||||
|
{activeFilters && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{filteredRules.length} / {sortedRules.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div className="fw-filter-bar-right">
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||||
<Button type="primary" className="mb-16" disabled={isViewer} onClick={openCreate}>
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
||||||
{t('fw.rule.add')}
|
{t('fw.rule.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
dataSource={rules ?? []}
|
dataSource={filteredRules}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
rowClassName={(row: FwRule) => row.enabled ? '' : 'fw-rule-row--disabled'}
|
||||||
emptyContent={
|
emptyContent={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FireOutlined />}
|
icon={<FireOutlined />}
|
||||||
title={t('fw.rule.emptyTitle')}
|
title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')}
|
||||||
description={t('fw.rule.emptyDesc')}
|
description={activeFilters ? t('fw.filter.noResultsHint') : t('fw.rule.emptyDesc')}
|
||||||
action={
|
action={
|
||||||
|
!activeFilters ? (
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||||
<Button type="primary" disabled={isViewer} onClick={openCreate}>{t('fw.rule.add')}</Button>
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
||||||
|
{t('fw.rule.add')}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? t('fw.rule.edit') : t('fw.rule.add')}
|
title={editing ? t('fw.rule.edit') : t('fw.rule.add')}
|
||||||
open={editing !== null || creating}
|
open={editing !== null || creating}
|
||||||
@@ -349,6 +486,7 @@ export default function RulesTab() {
|
|||||||
onOk={() => { void form.submit() }}
|
onOk={() => { void form.submit() }}
|
||||||
confirmLoading={create.isPending || update.isPending}
|
confirmLoading={create.isPending || update.isPending}
|
||||||
width={620}
|
width={620}
|
||||||
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Space, Tag, Tabs } from 'antd'
|
import { Space, Tag, Tabs } from 'antd'
|
||||||
import { CheckCircleOutlined, CloseCircleOutlined, FireOutlined } from '@ant-design/icons'
|
import { CheckCircleOutlined, CloseCircleOutlined, FireOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
@@ -13,9 +13,75 @@ import RulesTab from './Rules'
|
|||||||
import NATRulesTab from './NATRules'
|
import NATRulesTab from './NATRules'
|
||||||
import SystemRulesCard from './SystemRules'
|
import SystemRulesCard from './SystemRules'
|
||||||
import ZonesTab from './Zones'
|
import ZonesTab from './Zones'
|
||||||
|
import type { FwRule, FwZone, NATRule } from './types'
|
||||||
|
|
||||||
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
||||||
|
|
||||||
|
async function listRulesCount(): Promise<FwRule[]> {
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get('/firewall/rules')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { rules?: FwRule[] }).rules ?? []
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
async function listNATCount(): Promise<NATRule[]> {
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get('/firewall/nat-rules')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? []
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
async function listZonesCount(): Promise<FwZone[]> {
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get('/firewall/zones')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { zones?: FwZone[] }).zones ?? []
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function FirewallKPIStrip({ nftablesActive }: { nftablesActive: boolean | undefined }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { data: rules } = useQuery({ queryKey: ['fw', 'rules'], queryFn: listRulesCount, staleTime: 30_000 })
|
||||||
|
const { data: natRules } = useQuery({ queryKey: ['fw', 'nat-rules'], queryFn: listNATCount, staleTime: 30_000 })
|
||||||
|
const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZonesCount, staleTime: 30_000 })
|
||||||
|
|
||||||
|
const totalRules = rules?.length ?? 0
|
||||||
|
const activeRules = rules?.filter(r => r.enabled).length ?? 0
|
||||||
|
const totalNAT = natRules?.length ?? 0
|
||||||
|
const totalZones = zones?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fw-kpi-strip">
|
||||||
|
<div className="fw-kpi-card">
|
||||||
|
<div className="fw-kpi-label">{t('fw.kpi.policyRules')}</div>
|
||||||
|
<div className="fw-kpi-value">{totalRules}</div>
|
||||||
|
<div className="fw-kpi-sub">
|
||||||
|
{activeRules} {t('fw.kpi.active')} · {totalRules - activeRules} {t('fw.kpi.disabled')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fw-kpi-card">
|
||||||
|
<div className="fw-kpi-label">{t('fw.kpi.natRules')}</div>
|
||||||
|
<div className="fw-kpi-value">{totalNAT}</div>
|
||||||
|
<div className="fw-kpi-sub">{t('fw.kpi.natHint')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="fw-kpi-card">
|
||||||
|
<div className="fw-kpi-label">{t('fw.kpi.zones')}</div>
|
||||||
|
<div className="fw-kpi-value">{totalZones}</div>
|
||||||
|
<div className="fw-kpi-sub">{t('fw.kpi.zonesHint')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="fw-kpi-card fw-kpi-card--policy">
|
||||||
|
<div className="fw-kpi-label">{t('fw.kpi.defaultPolicy')}</div>
|
||||||
|
<div className="fw-kpi-value--mono">INPUT DROP</div>
|
||||||
|
<div className="fw-kpi-sub" style={{ marginTop: 4 }}>
|
||||||
|
{nftablesActive === true && <span style={{ color: '#15803D' }}>● {t('fw.kpi.nftActive')}</span>}
|
||||||
|
{nftablesActive === false && <span style={{ color: '#B91C1C' }}>● {t('fw.kpi.nftInactive')}</span>}
|
||||||
|
{nftablesActive === undefined && <span style={{ color: '#94A3B8' }}>···</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FirewallPage() {
|
export default function FirewallPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -30,7 +96,7 @@ export default function FirewallPage() {
|
|||||||
const nftables = services?.find(s => s.unit === 'nftables.service' || s.unit === 'nftables')
|
const nftables = services?.find(s => s.unit === 'nftables.service' || s.unit === 'nftables')
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> },
|
{ key: 'rules', label: <span><SafetyOutlined /> {t('fw.tabs.rules')}</span>, children: <RulesTab /> },
|
||||||
{ key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> },
|
{ key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> },
|
||||||
{ key: 'zones', label: t('fw.tabs.zones'), children: <ZonesTab /> },
|
{ key: 'zones', label: t('fw.tabs.zones'), children: <ZonesTab /> },
|
||||||
{ key: 'addrObj', label: t('fw.tabs.addrObj'), children: <AddressObjectsTab /> },
|
{ key: 'addrObj', label: t('fw.tabs.addrObj'), children: <AddressObjectsTab /> },
|
||||||
@@ -46,11 +112,7 @@ export default function FirewallPage() {
|
|||||||
icon={<FireOutlined />}
|
icon={<FireOutlined />}
|
||||||
title={t('fw.title')}
|
title={t('fw.title')}
|
||||||
subtitle={t('fw.intro')}
|
subtitle={t('fw.intro')}
|
||||||
/>
|
extra={nftables && (
|
||||||
<Tabs
|
|
||||||
items={tabs}
|
|
||||||
defaultActiveKey="rules"
|
|
||||||
tabBarExtraContent={nftables && (
|
|
||||||
<Tag
|
<Tag
|
||||||
icon={nftables.active ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
icon={nftables.active ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||||
color={nftables.active ? 'green' : 'red'}
|
color={nftables.active ? 'green' : 'red'}
|
||||||
@@ -62,6 +124,11 @@ export default function FirewallPage() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FirewallKPIStrip nftablesActive={nftables?.active} />
|
||||||
|
<Tabs
|
||||||
|
items={tabs}
|
||||||
|
defaultActiveKey="rules"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2764,3 +2764,131 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Firewall Enterprise Redesign ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/* KPI strip */
|
||||||
|
.fw-kpi-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.fw-kpi-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.fw-kpi-card--policy {
|
||||||
|
border-left: 3px solid #EF4444;
|
||||||
|
}
|
||||||
|
.fw-kpi-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748B;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
}
|
||||||
|
.fw-kpi-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0F172A;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.fw-kpi-value--mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #B91C1C;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.fw-kpi-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action badge */
|
||||||
|
.fw-action-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fw-action-badge--accept { background: #DCFCE7; color: #15803D; }
|
||||||
|
.fw-action-badge--drop { background: #FEE2E2; color: #B91C1C; }
|
||||||
|
.fw-action-badge--reject { background: #FEF3C7; color: #B45309; }
|
||||||
|
|
||||||
|
/* Zone badge */
|
||||||
|
.fw-zone-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fw-zone-badge--wan { background: #FEE2E2; color: #B91C1C; border: 1px solid #FECACA; }
|
||||||
|
.fw-zone-badge--lan { background: #DCFCE7; color: #15803D; border: 1px solid #BBF7D0; }
|
||||||
|
.fw-zone-badge--dmz { background: #FEF3C7; color: #B45309; border: 1px solid #FDE68A; }
|
||||||
|
.fw-zone-badge--mgmt { background: #DBEAFE; color: #1D4ED8; border: 1px solid #BFDBFE; }
|
||||||
|
.fw-zone-badge--cluster { background: #F3E8FF; color: #7E22CE; border: 1px solid #E9D5FF; }
|
||||||
|
.fw-zone-badge--vpn { background: #E0F2FE; color: #0369A1; border: 1px solid #BAE6FD; }
|
||||||
|
.fw-zone-badge--any { background: #F1F5F9; color: #475569; border: 1px solid #CBD5E1; }
|
||||||
|
.fw-zone-badge--default { background: #F1F5F9; color: #475569; border: 1px solid #CBD5E1; }
|
||||||
|
|
||||||
|
/* Rule row */
|
||||||
|
.fw-rule-row--disabled td {
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
.fw-rule-row--disabled td:first-child {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enabled status dot */
|
||||||
|
.fw-rule-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fw-rule-dot--on { background: #22C55E; box-shadow: 0 0 0 2px #DCFCE7; }
|
||||||
|
.fw-rule-dot--off { background: #CBD5E1; }
|
||||||
|
|
||||||
|
/* Flow arrow */
|
||||||
|
.fw-flow-arrow {
|
||||||
|
color: #94A3B8;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter bar */
|
||||||
|
.fw-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.fw-filter-bar-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user