feat(firewall-log): Phase 3 — UI /firewall-live mit WS-Stream + Filter + CSV
Neue Page management-ui/src/pages/FirewallLive — Live-Tail der NFLOG- Events aus /api/v1/firewall/log/live (WebSocket). Features: - Status-Indicator (Live/getrennt), Auto-Reconnect alle 2s nach Drop - Filter-Bar (action/proto/src/dst/rule_id) — bei Änderung wird der WS neu verbunden, Server schickt frischen Snapshot - Pause-Toggle: während Pause werden Events gebuffert (max 1000), beim Resume in die Tabelle gemerged - CSV-Export der aktuellen Tabelle (timestamp/rule/action/proto/src/ dst/iface/size) - Color-coded Action-Tags (ACCEPT=grün, DROP=rot, REJECT=orange) - Ring-Buffer 1000 im UI damit die DOM-Last bei Hochlast bleibt - Sidebar-Eintrag "Firewall-Log" unter Sicherheit (Eye-Icon) - DE/EN i18n haproxy: backend api_backend bekommt `timeout tunnel 1h` damit der WebSocket-Stream nicht nach `timeout server 60s` ohne Events stirbt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,7 @@ import (
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
)
|
||||
|
||||
var version = "1.0.60"
|
||||
var version = "1.0.61"
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.60"
|
||||
var version = "1.0.61"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
var version = "1.0.60"
|
||||
var version = "1.0.61"
|
||||
|
||||
const (
|
||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||
|
||||
@@ -83,7 +83,11 @@ frontend internal_stats
|
||||
# ── Backends ───────────────────────────────────────────────────────────
|
||||
|
||||
# edgeguard-api itself: management UI, REST API, ACME webroot.
|
||||
# timeout tunnel 1h: /api/v1/firewall/log/live ist ein langlebiger
|
||||
# WebSocket-Stream — ohne Tunnel-Override fällt er nach `timeout
|
||||
# server 60s` ohne Events um.
|
||||
backend api_backend
|
||||
timeout tunnel 1h
|
||||
server api1 127.0.0.1:9443 check
|
||||
|
||||
{{- range $b := .Backends}}
|
||||
|
||||
@@ -25,6 +25,7 @@ const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
|
||||
const LicensePage = lazy(() => import('./pages/License'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
|
||||
@@ -109,6 +110,7 @@ export default function App() {
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/firewall-live" element={<FirewallLivePage />} />
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -16,6 +16,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/routing-rules': 'nav.routing',
|
||||
'/networks': 'nav.networks',
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/firewall-live': 'nav.firewallLive',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/license': 'nav.license',
|
||||
'/settings': 'nav.settings',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ClusterOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
EyeOutlined,
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
@@ -61,6 +62,7 @@ const NAV: NavSection[] = [
|
||||
labelKey: 'nav.section.security',
|
||||
items: [
|
||||
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
|
||||
{ path: '/firewall-live', labelKey: 'nav.firewallLive', icon: <EyeOutlined /> },
|
||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||
],
|
||||
@@ -75,7 +77,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.60'
|
||||
const VERSION = '1.0.61'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dns": "DNS",
|
||||
"ntp": "Zeit (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"firewallLive": "Firewall-Log",
|
||||
"cluster": "Cluster",
|
||||
"license": "Lizenz",
|
||||
"settings": "Einstellungen",
|
||||
@@ -617,5 +618,39 @@
|
||||
"verifyFailed": "Lizenz-Verifizierung fehlgeschlagen",
|
||||
"cta": "Jetzt aktivieren →",
|
||||
"openPage": "Lizenz-Seite öffnen →"
|
||||
},
|
||||
"fwlog": {
|
||||
"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.",
|
||||
"live": "Live",
|
||||
"disconnected": "getrennt",
|
||||
"pause": "Pause",
|
||||
"resume": "Fortsetzen",
|
||||
"queued": "wartend",
|
||||
"clear": "Leeren",
|
||||
"clearTooltip": "Tabelle leeren (Server-Ringbuffer bleibt unverändert)",
|
||||
"export": "CSV",
|
||||
"exportTooltip": "Aktuelle Tabelle als CSV exportieren",
|
||||
"exportEmpty": "Keine Events zum Exportieren",
|
||||
"connError": "WebSocket-Fehler — versuche erneut",
|
||||
"empty": "Noch keine Events. Aktiviere bei einer Firewall-Regel den Log-Schalter, dann fließen Treffer hier hinein.",
|
||||
"connecting": "Verbinde …",
|
||||
"col": {
|
||||
"time": "Zeit",
|
||||
"action": "Aktion",
|
||||
"rule": "Rule",
|
||||
"proto": "Proto",
|
||||
"src": "Quelle",
|
||||
"dst": "Ziel",
|
||||
"iface": "Interface",
|
||||
"size": "Größe"
|
||||
},
|
||||
"filter": {
|
||||
"action": "Action filtern",
|
||||
"proto": "Proto filtern",
|
||||
"src": "Quell-IP",
|
||||
"dst": "Ziel-IP",
|
||||
"rule": "Rule-ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dns": "DNS",
|
||||
"ntp": "Time (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"firewallLive": "Firewall log",
|
||||
"cluster": "Cluster",
|
||||
"license": "License",
|
||||
"settings": "Settings",
|
||||
@@ -617,5 +618,39 @@
|
||||
"verifyFailed": "License verification failed",
|
||||
"cta": "Activate now →",
|
||||
"openPage": "Open license page →"
|
||||
},
|
||||
"fwlog": {
|
||||
"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.",
|
||||
"live": "Live",
|
||||
"disconnected": "disconnected",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"queued": "queued",
|
||||
"clear": "Clear",
|
||||
"clearTooltip": "Clear the table (server ring buffer is unaffected)",
|
||||
"export": "CSV",
|
||||
"exportTooltip": "Export current table as CSV",
|
||||
"exportEmpty": "No events to export",
|
||||
"connError": "WebSocket error — retrying",
|
||||
"empty": "No events yet. Enable the log toggle on a firewall rule — matches will show up here.",
|
||||
"connecting": "Connecting …",
|
||||
"col": {
|
||||
"time": "Time",
|
||||
"action": "Action",
|
||||
"rule": "Rule",
|
||||
"proto": "Proto",
|
||||
"src": "Source",
|
||||
"dst": "Destination",
|
||||
"iface": "Interface",
|
||||
"size": "Size"
|
||||
},
|
||||
"filter": {
|
||||
"action": "Filter action",
|
||||
"proto": "Filter proto",
|
||||
"src": "Source IP",
|
||||
"dst": "Dest IP",
|
||||
"rule": "Rule ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
379
management-ui/src/pages/FirewallLive/index.tsx
Normal file
379
management-ui/src/pages/FirewallLive/index.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Input, Select, Space, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
AlertOutlined,
|
||||
DownloadOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
ClearOutlined,
|
||||
FireOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
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 {
|
||||
timestamp: string
|
||||
rule_id?: string
|
||||
prefix?: string
|
||||
src_ip?: string
|
||||
dst_ip?: string
|
||||
src_port?: number
|
||||
dst_port?: number
|
||||
proto?: string
|
||||
if_in?: string
|
||||
if_out?: string
|
||||
pkt_len?: number
|
||||
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
|
||||
|
||||
// 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 {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const base = `${proto}//${window.location.host}/api/v1/firewall/log/live`
|
||||
return query ? `${base}?${query}` : base
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
action: string
|
||||
proto: string
|
||||
src: string
|
||||
dst: string
|
||||
rule_id: string
|
||||
}
|
||||
|
||||
function buildQuery(f: Filters): string {
|
||||
const p = new URLSearchParams()
|
||||
if (f.action) p.set('action', f.action)
|
||||
if (f.proto) p.set('proto', f.proto)
|
||||
if (f.src) p.set('src', f.src)
|
||||
if (f.dst) p.set('dst', f.dst)
|
||||
if (f.rule_id) p.set('rule_id', f.rule_id)
|
||||
p.set('limit', '1000')
|
||||
return p.toString()
|
||||
}
|
||||
|
||||
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()
|
||||
let color: string = 'default'
|
||||
let label = action || '—'
|
||||
if (a === 'accept' || a === 'allowed' || a === 'accepted') {
|
||||
color = 'green'; label = 'ACCEPT'
|
||||
} else if (a === 'drop' || a === 'blocked' || a === 'dropped') {
|
||||
color = 'red'; label = 'DROP'
|
||||
} else if (a === 'reject' || a === 'rejected') {
|
||||
color = 'orange'; label = 'REJECT'
|
||||
} else if (prefix) {
|
||||
color = 'blue'; label = 'LOG'
|
||||
}
|
||||
return <Tag color={color} style={{ fontWeight: 600 }}>{label}</Tag>
|
||||
}
|
||||
|
||||
function protoTag(p?: string) {
|
||||
if (!p) return <Tag>—</Tag>
|
||||
const lower = p.toLowerCase()
|
||||
const color =
|
||||
lower === 'tcp' ? 'geekblue' :
|
||||
lower === 'udp' ? 'cyan' :
|
||||
lower.startsWith('icmp') ? 'purple' : 'default'
|
||||
return <Tag color={color}>{lower}</Tag>
|
||||
}
|
||||
|
||||
function csvEscape(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
const s = String(v)
|
||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||
return `"${s.replace(/"/g, '""')}"`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function toCSV(rows: Entry[]): string {
|
||||
const cols = ['timestamp', 'rule_id', 'action', 'proto', 'src_ip', 'src_port', 'dst_ip', 'dst_port', 'if_in', 'if_out', 'pkt_len']
|
||||
const lines = [cols.join(',')]
|
||||
for (const r of rows) {
|
||||
lines.push(cols.map((c) => csvEscape((r as unknown as Record<string, unknown>)[c])).join(','))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export default function FirewallLivePage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
action: '', proto: '', src: '', dst: '', rule_id: '',
|
||||
})
|
||||
|
||||
// useRef'd damit der WS-Reconnect-Effekt nicht bei jeder Pause-
|
||||
// Änderung re-runt.
|
||||
const pausedRef = useRef(paused)
|
||||
pausedRef.current = paused
|
||||
const pendingDuringPauseRef = useRef<Entry[]>([])
|
||||
|
||||
const query = useMemo(() => buildQuery(filters), [filters])
|
||||
|
||||
// WS-Lifecycle — reconnect bei query-Änderung oder nach Drop.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled) return
|
||||
setError(null)
|
||||
try {
|
||||
ws = new WebSocket(wsURL(query))
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
ws.onopen = () => setConnected(true)
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const e: Entry = JSON.parse(ev.data as string)
|
||||
if (pausedRef.current) {
|
||||
// während Pause: nicht ins UI rendern, aber buffern damit
|
||||
// beim Resume die zwischenzeitlichen Events da sind.
|
||||
pendingDuringPauseRef.current.push(e)
|
||||
if (pendingDuringPauseRef.current.length > UI_RING) {
|
||||
pendingDuringPauseRef.current = pendingDuringPauseRef.current.slice(-UI_RING)
|
||||
}
|
||||
return
|
||||
}
|
||||
setEntries((prev) => {
|
||||
const next = prev.length >= UI_RING
|
||||
? [...prev.slice(prev.length - UI_RING + 1), e]
|
||||
: [...prev, e]
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// Parse-Fehler einer Zeile — wir verlieren den Event aber
|
||||
// brechen die Pipeline nicht ab.
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
setConnected(false)
|
||||
if (!cancelled) scheduleReconnect()
|
||||
}
|
||||
ws.onerror = () => {
|
||||
setError(t('fwlog.connError'))
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(connect, 2000)
|
||||
}
|
||||
|
||||
setEntries([]) // bei filter-change Tabelle leeren (server schickt fresh snapshot)
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
if (ws) ws.close()
|
||||
}
|
||||
}, [query, t])
|
||||
|
||||
// Bei Resume die während-pause gebufferten Events in die Tabelle
|
||||
// schieben.
|
||||
useEffect(() => {
|
||||
if (!paused && pendingDuringPauseRef.current.length > 0) {
|
||||
const flushed = pendingDuringPauseRef.current
|
||||
pendingDuringPauseRef.current = []
|
||||
setEntries((prev) => {
|
||||
const merged = [...prev, ...flushed]
|
||||
return merged.length > UI_RING ? merged.slice(merged.length - UI_RING) : merged
|
||||
})
|
||||
}
|
||||
}, [paused])
|
||||
|
||||
const exportCSV = useCallback(() => {
|
||||
if (entries.length === 0) {
|
||||
message.info(t('fwlog.exportEmpty'))
|
||||
return
|
||||
}
|
||||
const blob = new Blob([toCSV(entries)], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `firewall-log-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [entries, t])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setEntries([])
|
||||
pendingDuringPauseRef.current = []
|
||||
}, [])
|
||||
|
||||
const columns: ColumnsType<Entry> = [
|
||||
{
|
||||
title: t('fwlog.col.time'), dataIndex: 'timestamp', width: 170,
|
||||
render: (v: string) => <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{v ? new Date(v).toLocaleTimeString([], { hour12: false }) + '.' + new Date(v).getMilliseconds().toString().padStart(3, '0') : '—'}
|
||||
</Text>,
|
||||
},
|
||||
{
|
||||
title: t('fwlog.col.action'), dataIndex: 'action', width: 90,
|
||||
render: (a: string | undefined, row) => actionTag(a, row.prefix),
|
||||
},
|
||||
{
|
||||
title: t('fwlog.col.rule'), dataIndex: 'rule_id', width: 80,
|
||||
render: (v?: string) => v ? <Tag>{v}</Tag> : <Text type="secondary">—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('fwlog.col.proto'), dataIndex: 'proto', width: 80,
|
||||
render: protoTag,
|
||||
},
|
||||
{
|
||||
title: t('fwlog.col.src'), key: 'src',
|
||||
render: (_, r) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>
|
||||
{r.src_ip || '—'}{r.src_port ? <Text type="secondary">:{r.src_port}</Text> : null}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('fwlog.col.dst'), key: 'dst',
|
||||
render: (_, r) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>
|
||||
{r.dst_ip || '—'}{r.dst_port ? <Text type="secondary">:{r.dst_port}</Text> : null}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('fwlog.col.iface'), key: 'iface', width: 110,
|
||||
render: (_, r) => (
|
||||
<Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{r.if_in || '—'}{r.if_out ? <> → <Text>{r.if_out}</Text></> : null}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('fwlog.col.size'), dataIndex: 'pkt_len', width: 70,
|
||||
render: (n?: number) => n ? <Text type="secondary">{n} B</Text> : '—',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<FireOutlined />}
|
||||
title={t('fwlog.title')}
|
||||
subtitle={t('fwlog.intro')}
|
||||
extra={
|
||||
<Space>
|
||||
<Tag color={connected ? 'green' : 'red'} icon={<AlertOutlined />}>
|
||||
{connected ? t('fwlog.live') : t('fwlog.disconnected')}
|
||||
</Tag>
|
||||
<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>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <Alert type="warning" showIcon message={error} className="mb-16" closable />}
|
||||
|
||||
<Card size="small" className="mb-16">
|
||||
<Space wrap>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user