refactor(fwlog): Live-Log als Firewall-Tab, default-aus, Start-Button

UI-Restruktur nach User-Feedback:
- Sidebar-Eintrag „Firewall-Live" entfernt — gehört thematisch unter
  Firewall, kein Top-Level-Item. Standalone-Page /firewall-live raus.
- Neuer Firewall-Tab „Live-Log" zwischen NAT und Zonen.
- Default = AUS: zeigt Empty-State mit Start-Button. WebSocket
  verbindet erst nach Klick. Stop-Button schließt explizit.
- Filter-Inputs (src/dst/rule_id) jetzt 300ms debounced — vorher
  triggerte jeder Tastendruck einen WS-Reconnect.

Server-Pipeline „wirklich live" gepinnt:
- ulogd.conf NFLOG-Plugin bekommt qthreshold=1 + qtimeout=1. Default
  des Kernels batched Pakete bis 1024 oder 1s; mit 1/1 fließt jedes
  Paket sofort. Critical für die Wahrnehmung „live" statt „bursty".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-12 21:50:52 +02:00
parent 827c364335
commit 9642a6adfe
12 changed files with 182 additions and 142 deletions

View File

@@ -1 +1 @@
1.0.62 1.0.63

View File

@@ -50,7 +50,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
) )
var version = "1.0.62" var version = "1.0.63"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
) )
var version = "1.0.62" var version = "1.0.63"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -24,7 +24,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.0.62" var version = "1.0.63"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -25,7 +25,6 @@ const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
const DNSPage = lazy(() => import('./pages/DNS')) const DNSPage = lazy(() => import('./pages/DNS'))
const NTPPage = lazy(() => import('./pages/NTP')) const NTPPage = lazy(() => import('./pages/NTP'))
const ClusterPage = lazy(() => import('./pages/Cluster')) const ClusterPage = lazy(() => import('./pages/Cluster'))
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
const LogsPage = lazy(() => import('./pages/Logs')) const LogsPage = lazy(() => import('./pages/Logs'))
const LicensePage = lazy(() => import('./pages/License')) const LicensePage = lazy(() => import('./pages/License'))
const SettingsPage = lazy(() => import('./pages/Settings')) const SettingsPage = lazy(() => import('./pages/Settings'))
@@ -111,7 +110,6 @@ export default function App() {
<Route path="/dns" element={<DNSPage />} /> <Route path="/dns" element={<DNSPage />} />
<Route path="/ntp" element={<NTPPage />} /> <Route path="/ntp" element={<NTPPage />} />
<Route path="/cluster" element={<ClusterPage />} /> <Route path="/cluster" element={<ClusterPage />} />
<Route path="/firewall-live" element={<FirewallLivePage />} />
<Route path="/logs" element={<LogsPage />} /> <Route path="/logs" element={<LogsPage />} />
<Route path="/license" element={<LicensePage />} /> <Route path="/license" element={<LicensePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />

View File

@@ -16,7 +16,6 @@ const PAGE_TITLES: Record<string, string> = {
'/routing-rules': 'nav.routing', '/routing-rules': 'nav.routing',
'/networks': 'nav.networks', '/networks': 'nav.networks',
'/ip-addresses': 'nav.ipAddresses', '/ip-addresses': 'nav.ipAddresses',
'/firewall-live': 'nav.firewallLive',
'/cluster': 'nav.cluster', '/cluster': 'nav.cluster',
'/logs': 'nav.logs', '/logs': 'nav.logs',
'/license': 'nav.license', '/license': 'nav.license',

View File

@@ -7,7 +7,6 @@ import {
ClusterOutlined, ClusterOutlined,
CrownOutlined, CrownOutlined,
DashboardOutlined, DashboardOutlined,
EyeOutlined,
FileSearchOutlined, FileSearchOutlined,
DatabaseOutlined, DatabaseOutlined,
FireOutlined, FireOutlined,
@@ -63,7 +62,6 @@ const NAV: NavSection[] = [
labelKey: 'nav.section.security', labelKey: 'nav.section.security',
items: [ items: [
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> }, { path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
{ path: '/firewall-live', labelKey: 'nav.firewallLive', icon: <EyeOutlined /> },
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> }, { path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> }, { path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
], ],
@@ -79,7 +77,7 @@ const NAV: NavSection[] = [
}, },
] ]
const VERSION = '1.0.62' const VERSION = '1.0.63'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent // - <nav> als root, dunkler Gradient + Teal/Blue-Accent

View File

@@ -36,6 +36,7 @@
"tabs": { "tabs": {
"rules": "Regeln", "rules": "Regeln",
"nat": "NAT", "nat": "NAT",
"live": "Live-Log",
"zones": "Zonen", "zones": "Zonen",
"addrObj": "Adress-Objekte", "addrObj": "Adress-Objekte",
"addrGrp": "Adress-Gruppen", "addrGrp": "Adress-Gruppen",
@@ -648,8 +649,12 @@
"fwlog": { "fwlog": {
"title": "Firewall-Log (Live)", "title": "Firewall-Log (Live)",
"intro": "Pakete, die in nft-Regeln mit aktivem Log-Flag matchen, fließen via NFLOG → ulogd2 → JSONL hierher. WebSocket-Stream zeigt Live-Events; Ring-Buffer (1000) hält die letzten Treffer auch nach Reconnect.", "intro": "Pakete, die in nft-Regeln mit aktivem Log-Flag matchen, fließen via NFLOG → ulogd2 → JSONL hierher. WebSocket-Stream zeigt Live-Events; Ring-Buffer (1000) hält die letzten Treffer auch nach Reconnect.",
"start": "Live-Log starten",
"stop": "Stop",
"notStartedTitle": "Live-Log ist aus",
"notStartedDesc": "Standardmäßig pausiert — Klick zum Verbinden lässt Events live einfließen.",
"live": "Live", "live": "Live",
"disconnected": "getrennt", "disconnected": "verbinde …",
"pause": "Pause", "pause": "Pause",
"resume": "Fortsetzen", "resume": "Fortsetzen",
"queued": "wartend", "queued": "wartend",

View File

@@ -36,6 +36,7 @@
"tabs": { "tabs": {
"rules": "Rules", "rules": "Rules",
"nat": "NAT", "nat": "NAT",
"live": "Live log",
"zones": "Zones", "zones": "Zones",
"addrObj": "Address objects", "addrObj": "Address objects",
"addrGrp": "Address groups", "addrGrp": "Address groups",
@@ -648,8 +649,12 @@
"fwlog": { "fwlog": {
"title": "Firewall log (live)", "title": "Firewall log (live)",
"intro": "Packets matching nft rules with the log flag enabled flow via NFLOG → ulogd2 → JSONL into this view. WebSocket stream shows live events; ring buffer (1000) keeps recent hits across reconnects.", "intro": "Packets matching nft rules with the log flag enabled flow via NFLOG → ulogd2 → JSONL into this view. WebSocket stream shows live events; ring buffer (1000) keeps recent hits across reconnects.",
"start": "Start live log",
"stop": "Stop",
"notStartedTitle": "Live log is off",
"notStartedDesc": "Paused by default — click to connect and see events flowing in.",
"live": "Live", "live": "Live",
"disconnected": "disconnected", "disconnected": "connecting …",
"pause": "Pause", "pause": "Pause",
"resume": "Resume", "resume": "Resume",
"queued": "queued", "queued": "queued",

View File

@@ -1,24 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Alert, Button, Card, Input, Select, Space, Table, Tag, Tooltip, Typography, message, Alert, Button, Card, Empty, Input, Select, Space, Table, Tag, Tooltip, Typography, message,
} from 'antd' } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { import {
AlertOutlined, AlertOutlined,
ClearOutlined,
DownloadOutlined, DownloadOutlined,
PauseCircleOutlined, PauseCircleOutlined,
PlayCircleOutlined, PlayCircleOutlined,
ClearOutlined, PoweroffOutlined,
FireOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography const { Text } = Typography
// Entry-Shape kommt vom Backend (internal/services/firewalllog/reader.go).
// Bewusst dünn — alles was die Linie aus ulogd2-JSON lesen kann.
interface Entry { interface Entry {
timestamp: string timestamp: string
rule_id?: string rule_id?: string
@@ -34,13 +30,8 @@ interface Entry {
action?: string action?: string
} }
// Ring-Buffer im UI — auch wir cappen auf 1000 damit die Tabelle
// nicht das DOM in die Knie zwingt.
const UI_RING = 1000 const UI_RING = 1000
// wsURL baut wss://<host>/api/v1/firewall/log/live aus dem aktuellen
// Document. Beim Dev-Vite-Server läuft's über das proxy-passthrough auf
// edgeguard-api (Vite-Config hat /api → 127.0.0.1:9443).
function wsURL(query: string): string { function wsURL(query: string): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const base = `${proto}//${window.location.host}/api/v1/firewall/log/live` const base = `${proto}//${window.location.host}/api/v1/firewall/log/live`
@@ -57,20 +48,16 @@ interface Filters {
function buildQuery(f: Filters): string { function buildQuery(f: Filters): string {
const p = new URLSearchParams() const p = new URLSearchParams()
if (f.action) p.set('action', f.action) if (f.action) p.set('action', f.action)
if (f.proto) p.set('proto', f.proto) if (f.proto) p.set('proto', f.proto)
if (f.src) p.set('src', f.src) if (f.src) p.set('src', f.src)
if (f.dst) p.set('dst', f.dst) if (f.dst) p.set('dst', f.dst)
if (f.rule_id) p.set('rule_id', f.rule_id) if (f.rule_id) p.set('rule_id', f.rule_id)
p.set('limit', '1000') p.set('limit', '1000')
return p.toString() return p.toString()
} }
function actionTag(action: string | undefined, prefix: string | undefined) { function actionTag(action: string | undefined, prefix: string | undefined) {
// ulogd setzt "action" pauschal auf "blocked" — der Operator
// möchte aber wissen ob's accept/drop/reject war. Da nft das
// im Prefix nicht direkt mitschickt (nur die Rule-ID), zeigen
// wir den prefix-Text als Sekundär-Info.
const a = (action || '').toLowerCase() const a = (action || '').toLowerCase()
let color: string = 'default' let color: string = 'default'
let label = action || '—' let label = action || '—'
@@ -90,8 +77,8 @@ function protoTag(p?: string) {
if (!p) return <Tag></Tag> if (!p) return <Tag></Tag>
const lower = p.toLowerCase() const lower = p.toLowerCase()
const color = const color =
lower === 'tcp' ? 'geekblue' : lower === 'tcp' ? 'geekblue' :
lower === 'udp' ? 'cyan' : lower === 'udp' ? 'cyan' :
lower.startsWith('icmp') ? 'purple' : 'default' lower.startsWith('icmp') ? 'purple' : 'default'
return <Tag color={color}>{lower}</Tag> return <Tag color={color}>{lower}</Tag>
} }
@@ -114,34 +101,55 @@ function toCSV(rows: Entry[]): string {
return lines.join('\n') return lines.join('\n')
} }
export default function FirewallLivePage() { // LiveLog Tab — sitzt im Firewall-Page-Tab-Stack. Standardmäßig
// DISCONNECTED — der WebSocket wird erst beim Klick auf „Start"
// aufgebaut. Stop schließt explizit, Filter-Änderungen reconnecten
// nur wenn aktiv.
export default function LiveLogTab() {
const { t } = useTranslation() const { t } = useTranslation()
const [entries, setEntries] = useState<Entry[]>([]) const [active, setActive] = useState(false) // Start/Stop master switch
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [entries, setEntries] = useState<Entry[]>([])
const [filters, setFilters] = useState<Filters>({ const [filters, setFilters] = useState<Filters>({
action: '', proto: '', src: '', dst: '', rule_id: '', action: '', proto: '', src: '', dst: '', rule_id: '',
}) })
// Debounced Filter-Mirror — Text-Inputs schreiben in `filters`,
// ein useEffect spiegelt sie 300ms später in `appliedFilters`, was
// den WS-Reconnect triggert. So spammt nicht jeder Tastendruck
// einen Reconnect.
const [appliedFilters, setAppliedFilters] = useState<Filters>(filters)
// useRef'd damit der WS-Reconnect-Effekt nicht bei jeder Pause-
// Änderung re-runt.
const pausedRef = useRef(paused) const pausedRef = useRef(paused)
pausedRef.current = paused pausedRef.current = paused
const pendingDuringPauseRef = useRef<Entry[]>([]) const pendingDuringPauseRef = useRef<Entry[]>([])
const wsRef = useRef<WebSocket | null>(null)
const query = useMemo(() => buildQuery(filters), [filters])
// WS-Lifecycle — reconnect bei query-Änderung oder nach Drop.
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setAppliedFilters(filters), 300)
return () => clearTimeout(t)
}, [filters])
const query = useMemo(() => buildQuery(appliedFilters), [appliedFilters])
// WS-Lifecycle — nur connecten wenn `active`. Filter-/query-
// Änderung reconnected only when active.
useEffect(() => {
if (!active) {
// Sicherheits-Cleanup wenn Tab inaktiv wird.
if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
setConnected(false)
return
}
let cancelled = false let cancelled = false
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const connect = () => { const connect = () => {
if (cancelled) return if (cancelled || !active) return
setError(null) setError(null)
let ws: WebSocket
try { try {
ws = new WebSocket(wsURL(query)) ws = new WebSocket(wsURL(query))
} catch (e) { } catch (e) {
@@ -149,33 +157,31 @@ export default function FirewallLivePage() {
scheduleReconnect() scheduleReconnect()
return return
} }
wsRef.current = ws
ws.onopen = () => setConnected(true) ws.onopen = () => setConnected(true)
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
try { try {
const e: Entry = JSON.parse(ev.data as string) const e: Entry = JSON.parse(ev.data as string)
if (pausedRef.current) { if (pausedRef.current) {
// während Pause: nicht ins UI rendern, aber buffern damit
// beim Resume die zwischenzeitlichen Events da sind.
pendingDuringPauseRef.current.push(e) pendingDuringPauseRef.current.push(e)
if (pendingDuringPauseRef.current.length > UI_RING) { if (pendingDuringPauseRef.current.length > UI_RING) {
pendingDuringPauseRef.current = pendingDuringPauseRef.current.slice(-UI_RING) pendingDuringPauseRef.current =
pendingDuringPauseRef.current.slice(-UI_RING)
} }
return return
} }
setEntries((prev) => { setEntries((prev) => {
const next = prev.length >= UI_RING return prev.length >= UI_RING
? [...prev.slice(prev.length - UI_RING + 1), e] ? [...prev.slice(prev.length - UI_RING + 1), e]
: [...prev, e] : [...prev, e]
return next
}) })
} catch { } catch {
// Parse-Fehler einer Zeilewir verlieren den Event aber // line parse fail — schluck, broken nicht die Pipeline
// brechen die Pipeline nicht ab.
} }
} }
ws.onclose = () => { ws.onclose = () => {
setConnected(false) setConnected(false)
if (!cancelled) scheduleReconnect() if (!cancelled && active) scheduleReconnect()
} }
ws.onerror = () => { ws.onerror = () => {
setError(t('fwlog.connError')) setError(t('fwlog.connError'))
@@ -187,18 +193,17 @@ export default function FirewallLivePage() {
reconnectTimer = setTimeout(connect, 2000) reconnectTimer = setTimeout(connect, 2000)
} }
setEntries([]) // bei filter-change Tabelle leeren (server schickt fresh snapshot) setEntries([]) // bei active/query-change frischer Slate
connect() connect()
return () => { return () => {
cancelled = true cancelled = true
if (reconnectTimer) clearTimeout(reconnectTimer) if (reconnectTimer) clearTimeout(reconnectTimer)
if (ws) ws.close() if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
} }
}, [query, t]) }, [active, query, t])
// Bei Resume die während-pause gebufferten Events in die Tabelle // Resume: gebufferte Events in die Tabelle mergen.
// schieben.
useEffect(() => { useEffect(() => {
if (!paused && pendingDuringPauseRef.current.length > 0) { if (!paused && pendingDuringPauseRef.current.length > 0) {
const flushed = pendingDuringPauseRef.current const flushed = pendingDuringPauseRef.current
@@ -282,98 +287,121 @@ export default function FirewallLivePage() {
return ( return (
<div> <div>
<PageHeader {!active ? (
icon={<FireOutlined />} <Card style={{ textAlign: 'center', padding: '32px 16px' }}>
title={t('fwlog.title')} <Empty
subtitle={t('fwlog.intro')} image={<PoweroffOutlined style={{ fontSize: 48, color: '#94A3B8' }} />}
extra={ description={
<Space> <Space direction="vertical" size={4}>
<Tag color={connected ? 'green' : 'red'} icon={<AlertOutlined />}> <Text strong>{t('fwlog.notStartedTitle')}</Text>
{connected ? t('fwlog.live') : t('fwlog.disconnected')} <Text type="secondary">{t('fwlog.notStartedDesc')}</Text>
</Tag> </Space>
}
>
<Button <Button
icon={paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />} type="primary"
onClick={() => setPaused((p) => !p)} size="large"
icon={<PlayCircleOutlined />}
onClick={() => setActive(true)}
style={{ marginTop: 16 }}
> >
{paused ? t('fwlog.resume') : t('fwlog.pause')} {t('fwlog.start')}
</Button> </Button>
<Tooltip title={t('fwlog.clearTooltip')}> </Empty>
<Button icon={<ClearOutlined />} onClick={clear}>{t('fwlog.clear')}</Button> </Card>
</Tooltip> ) : (
<Tooltip title={t('fwlog.exportTooltip')}> <>
<Button icon={<DownloadOutlined />} onClick={exportCSV}>{t('fwlog.export')}</Button> {error && <Alert type="warning" showIcon message={error} className="mb-16" closable />}
</Tooltip>
</Space>
}
/>
{error && <Alert type="warning" showIcon message={error} className="mb-16" closable />} <Card size="small" className="mb-16">
<Space wrap>
<Tag color={connected ? 'green' : 'red'} icon={<AlertOutlined />}>
{connected ? t('fwlog.live') : t('fwlog.disconnected')}
</Tag>
<Button
danger
icon={<PoweroffOutlined />}
onClick={() => setActive(false)}
>
{t('fwlog.stop')}
</Button>
<Button
icon={paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={() => setPaused((p) => !p)}
>
{paused ? t('fwlog.resume') : t('fwlog.pause')}
</Button>
<Tooltip title={t('fwlog.clearTooltip')}>
<Button icon={<ClearOutlined />} onClick={clear}>{t('fwlog.clear')}</Button>
</Tooltip>
<Tooltip title={t('fwlog.exportTooltip')}>
<Button icon={<DownloadOutlined />} onClick={exportCSV}>{t('fwlog.export')}</Button>
</Tooltip>
<Select
placeholder={t('fwlog.filter.action')}
allowClear
style={{ width: 140 }}
value={filters.action || undefined}
onChange={(v) => setFilters((f) => ({ ...f, action: v || '' }))}
options={[
{ value: 'accept', label: 'accept' },
{ value: 'drop', label: 'drop' },
{ value: 'reject', label: 'reject' },
{ value: 'blocked', label: 'blocked' },
]}
/>
<Select
placeholder={t('fwlog.filter.proto')}
allowClear
style={{ width: 120 }}
value={filters.proto || undefined}
onChange={(v) => setFilters((f) => ({ ...f, proto: v || '' }))}
options={[
{ value: 'tcp', label: 'tcp' },
{ value: 'udp', label: 'udp' },
{ value: 'icmp', label: 'icmp' },
{ value: 'icmpv6', label: 'icmpv6' },
]}
/>
<Input
placeholder={t('fwlog.filter.src')}
allowClear
style={{ width: 160 }}
value={filters.src}
onChange={(e) => setFilters((f) => ({ ...f, src: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.dst')}
allowClear
style={{ width: 160 }}
value={filters.dst}
onChange={(e) => setFilters((f) => ({ ...f, dst: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.rule')}
allowClear
style={{ width: 100 }}
value={filters.rule_id}
onChange={(e) => setFilters((f) => ({ ...f, rule_id: e.target.value }))}
/>
<Text type="secondary">
{entries.length}{paused && pendingDuringPauseRef.current.length > 0
? ` (+${pendingDuringPauseRef.current.length} ${t('fwlog.queued')})`
: ''}
</Text>
</Space>
</Card>
<Card size="small" className="mb-16"> <Table
<Space wrap> rowKey={(r) => `${r.timestamp}-${r.src_ip}-${r.dst_ip}-${r.src_port}-${r.dst_port}-${r.pkt_len}`}
<Select size="small"
placeholder={t('fwlog.filter.action')} dataSource={[...entries].reverse()}
allowClear columns={columns}
style={{ width: 140 }} pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
value={filters.action || undefined} locale={{ emptyText: connected ? t('fwlog.empty') : t('fwlog.connecting') }}
onChange={(v) => setFilters((f) => ({ ...f, action: v || '' }))}
options={[
{ value: 'accept', label: 'accept' },
{ value: 'drop', label: 'drop' },
{ value: 'reject', label: 'reject' },
{ value: 'blocked', label: 'blocked' },
]}
/> />
<Select </>
placeholder={t('fwlog.filter.proto')} )}
allowClear
style={{ width: 120 }}
value={filters.proto || undefined}
onChange={(v) => setFilters((f) => ({ ...f, proto: v || '' }))}
options={[
{ value: 'tcp', label: 'tcp' },
{ value: 'udp', label: 'udp' },
{ value: 'icmp', label: 'icmp' },
{ value: 'icmpv6', label: 'icmpv6' },
]}
/>
<Input
placeholder={t('fwlog.filter.src')}
allowClear
style={{ width: 160 }}
value={filters.src}
onChange={(e) => setFilters((f) => ({ ...f, src: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.dst')}
allowClear
style={{ width: 160 }}
value={filters.dst}
onChange={(e) => setFilters((f) => ({ ...f, dst: e.target.value }))}
/>
<Input
placeholder={t('fwlog.filter.rule')}
allowClear
style={{ width: 100 }}
value={filters.rule_id}
onChange={(e) => setFilters((f) => ({ ...f, rule_id: e.target.value }))}
/>
<Text type="secondary">
{entries.length}{paused && pendingDuringPauseRef.current.length > 0
? ` (+${pendingDuringPauseRef.current.length} ${t('fwlog.queued')})`
: ''}
</Text>
</Space>
</Card>
<Table
rowKey={(r) => `${r.timestamp}-${r.src_ip}-${r.dst_ip}-${r.src_port}-${r.dst_port}-${r.pkt_len}`}
size="small"
dataSource={[...entries].reverse()}
columns={columns}
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
locale={{ emptyText: connected ? t('fwlog.empty') : t('fwlog.connecting') }}
/>
</div> </div>
) )
} }

View File

@@ -10,6 +10,7 @@ import ServiceGroupsTab from './ServiceGroups'
import RulesTab from './Rules' import RulesTab from './Rules'
import NATRulesTab from './NATRules' import NATRulesTab from './NATRules'
import ZonesTab from './Zones' import ZonesTab from './Zones'
import LiveLogTab from './LiveLog'
export default function FirewallPage() { export default function FirewallPage() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -17,6 +18,7 @@ export default function FirewallPage() {
const tabs = [ const tabs = [
{ key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> }, { key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> },
{ key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> }, { key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> },
{ key: 'live', label: t('fw.tabs.live'), children: <LiveLogTab /> },
{ 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 /> },
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> }, { key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },

View File

@@ -243,6 +243,11 @@ stack=fw1:NFLOG,base1:BASE,ifi1:IFINDEX,ip2str1:IP2STR,mac2str1:HWHDR,json1:JSON
[fw1] [fw1]
group=0 group=0
# qthreshold=1 + qtimeout=1: Kernel batched sonst Pakete bis 1024 oder
# bis 1s. Mit 1/1 → jedes Paket SOFORT raus, kein Sammeln. Wichtig
# damit die Live-Log-UI in real-time fließt statt in Bursts.
qthreshold=1
qtimeout=1
[json1] [json1]
sync=1 sync=1