refactor(fwlog): Live-Log als Child-Route /firewall/live statt Firewall-Tab

User-Feedback: Tab fühlt sich falsch an, will eine eigene Page mit
URL-Pfad unter /firewall.

UI:
- pages/Firewall/LiveLog.tsx → pages/FirewallLive/index.tsx
- FirewallPage entfernt den live-Tab aus tabs[]
- App.tsx routet /firewall/live → FirewallLivePage
- Sidebar: neuer Eintrag „Firewall-Log" eingerückt direkt unter
  „Firewall" in der Security-Section (child: true Flag → CSS-Klasse
  sidebar-menu-item--child mit padding-left 28px + dünnem vertikalem
  Trenn-Stab links). Sibling-Active-Logik exklusiv: /firewall matched
  NICHT mehr wenn /firewall/live aktiv ist.
- AppLayout PAGE_TITLES bekommt /firewall/live VOR /firewall damit
  der Title-Lookup den spezifischeren Pfad zuerst trifft.

Keine Backend-Änderungen.

Bekanntes Verhalten zu erklären: Im Live-Log sehen User aktuell nur
Smoke-Test-Events (oob.prefix=edgeguard:smoke / edgeguard:42, src/dst
127.0.0.1) — das sind die manuell-injizierten nft-Rules vom End-to-
End-Test der Pipeline. Reale Pakete fließen erst durch, wenn der
Operator auf einer firewall_rule den Log-Switch aktiviert (Firewall
→ Regeln → bearbeiten → Logging an). Aktuell hat keine einzige Rule
log=true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 07:04:19 +02:00
parent b031725dfe
commit 24c40bc776
10 changed files with 55 additions and 14 deletions

View File

@@ -0,0 +1,415 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Alert, Button, Card, Empty, Input, Select, Space, Table, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
AlertOutlined,
ClearOutlined,
DownloadOutlined,
EyeOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
PoweroffOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
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
}
const UI_RING = 1000
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) {
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')
}
// 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 FirewallLivePage() {
const { t } = useTranslation()
const [active, setActive] = useState(false) // Start/Stop master switch
const [paused, setPaused] = useState(false)
const [connected, setConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const [entries, setEntries] = useState<Entry[]>([])
const [filters, setFilters] = useState<Filters>({
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)
const pausedRef = useRef(paused)
pausedRef.current = paused
const pendingDuringPauseRef = useRef<Entry[]>([])
const wsRef = useRef<WebSocket | null>(null)
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 reconnectTimer: ReturnType<typeof setTimeout> | null = null
const connect = () => {
if (cancelled || !active) return
setError(null)
let ws: WebSocket
try {
ws = new WebSocket(wsURL(query))
} catch (e) {
setError(String(e))
scheduleReconnect()
return
}
wsRef.current = ws
ws.onopen = () => setConnected(true)
ws.onmessage = (ev) => {
try {
const e: Entry = JSON.parse(ev.data as string)
if (pausedRef.current) {
pendingDuringPauseRef.current.push(e)
if (pendingDuringPauseRef.current.length > UI_RING) {
pendingDuringPauseRef.current =
pendingDuringPauseRef.current.slice(-UI_RING)
}
return
}
setEntries((prev) => {
return prev.length >= UI_RING
? [...prev.slice(prev.length - UI_RING + 1), e]
: [...prev, e]
})
} catch {
// line parse fail — schluck, broken nicht die Pipeline
}
}
ws.onclose = () => {
setConnected(false)
if (!cancelled && active) scheduleReconnect()
}
ws.onerror = () => {
setError(t('fwlog.connError'))
}
}
const scheduleReconnect = () => {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, 2000)
}
setEntries([]) // bei active/query-change frischer Slate
connect()
return () => {
cancelled = true
if (reconnectTimer) clearTimeout(reconnectTimer)
if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
}
}, [active, query, t])
// Resume: gebufferte Events in die Tabelle mergen.
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={<EyeOutlined />}
title={t('fwlog.title')}
subtitle={t('fwlog.intro')}
/>
{!active ? (
<Card style={{ textAlign: 'center', padding: '32px 16px' }}>
<Empty
image={<PoweroffOutlined style={{ fontSize: 48, color: '#94A3B8' }} />}
description={
<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 />}>
{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>
<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>
)
}