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:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 Zeile — wir 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user