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`
@@ -67,10 +58,6 @@ function buildQuery(f: Filters): string {
} }
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 || '—'
@@ -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,15 +287,44 @@ 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}>
<Text strong>{t('fwlog.notStartedTitle')}</Text>
<Text type="secondary">{t('fwlog.notStartedDesc')}</Text>
</Space>
}
>
<Button
type="primary"
size="large"
icon={<PlayCircleOutlined />}
onClick={() => setActive(true)}
style={{ marginTop: 16 }}
>
{t('fwlog.start')}
</Button>
</Empty>
</Card>
) : (
<>
{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 />}> <Tag color={connected ? 'green' : 'red'} icon={<AlertOutlined />}>
{connected ? t('fwlog.live') : t('fwlog.disconnected')} {connected ? t('fwlog.live') : t('fwlog.disconnected')}
</Tag> </Tag>
<Button
danger
icon={<PoweroffOutlined />}
onClick={() => setActive(false)}
>
{t('fwlog.stop')}
</Button>
<Button <Button
icon={paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />} icon={paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={() => setPaused((p) => !p)} onClick={() => setPaused((p) => !p)}
@@ -303,14 +337,6 @@ export default function FirewallLivePage() {
<Tooltip title={t('fwlog.exportTooltip')}> <Tooltip title={t('fwlog.exportTooltip')}>
<Button icon={<DownloadOutlined />} onClick={exportCSV}>{t('fwlog.export')}</Button> <Button icon={<DownloadOutlined />} onClick={exportCSV}>{t('fwlog.export')}</Button>
</Tooltip> </Tooltip>
</Space>
}
/>
{error && <Alert type="warning" showIcon message={error} className="mb-16" closable />}
<Card size="small" className="mb-16">
<Space wrap>
<Select <Select
placeholder={t('fwlog.filter.action')} placeholder={t('fwlog.filter.action')}
allowClear allowClear
@@ -374,6 +400,8 @@ export default function FirewallLivePage() {
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }} pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
locale={{ emptyText: connected ? t('fwlog.empty') : t('fwlog.connecting') }} 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