feat(audit): Live-Stream im Dashboard via WebSocket
Recent-Activity-Karte zeigt neue audit_log-Events jetzt sofort statt
in 15s-Polls.
internal/services/audit/audit.go:
- Repo bekommt Subscribe()-Methode mit fan-out-channel (Buffer 32,
non-blocking-send — langsame Clients droppen Events statt die
Pipeline zu blockieren).
- Log() macht jetzt INSERT … RETURNING id, created_at und broadcastet
den fertigen Entry an alle Subscribers. Broadcast nur nach
erfolgreichem INSERT — failed inserts erscheinen nicht.
internal/handlers/audit.go:
- Neuer GET /api/v1/audit/live (WebSocket): sendet beim Connect die
letzten 50 Einträge (oldest→newest), danach Live-Stream aus
Subscribe-Channel. 30s-Ping gegen HAProxy-Tunnel-Timeout.
- Recent (Poll-Endpoint) bleibt für Fallbacks erhalten.
UI Dashboard:
- useAuditLive(keep=15)-Hook ersetzt das 15s-useQuery-Poll.
- WebSocket auf wss://<host>/api/v1/audit/live; Auto-Reconnect alle
2s nach Drop.
- dedupe per id (Snapshot + erste live-Events können sich kurz
überschneiden während des Subscribe-Race).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.72'
|
||||
const VERSION = '1.0.73'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SafetyCertificateOutlined, ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
@@ -13,6 +14,51 @@ import StatusDot from '../../components/StatusDot'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
// useAuditLive verbindet sich mit dem WebSocket-Stream /audit/live.
|
||||
// Server sendet beim Connect die letzten 50 Einträge (oldest→newest),
|
||||
// danach jeden neuen INSERT direkt. UI behält das letzte `keep` und
|
||||
// zeigt newest-first. Reconnect alle 2s bei Drop.
|
||||
function useAuditLive(keep = 15) {
|
||||
const [data, setData] = useState<AuditEntry[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const connect = () => {
|
||||
if (cancelled) return
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${proto}//${window.location.host}/api/v1/audit/live`
|
||||
let ws: WebSocket
|
||||
try { ws = new WebSocket(url) } catch { scheduleReconnect(); return }
|
||||
wsRef.current = ws
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const e: AuditEntry = JSON.parse(ev.data as string)
|
||||
setData((prev) => {
|
||||
// dedupe by id (Snapshot + live können sich kurz überschneiden)
|
||||
if (prev.some((p) => p.id === e.id)) return prev
|
||||
const next = [e, ...prev]
|
||||
return next.length > keep ? next.slice(0, keep) : next
|
||||
})
|
||||
} catch { /* ignore parse fail */ }
|
||||
}
|
||||
ws.onclose = () => { if (!cancelled) scheduleReconnect() }
|
||||
ws.onerror = () => { /* onclose feuert danach */ }
|
||||
}
|
||||
const scheduleReconnect = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(connect, 2000)
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
}
|
||||
}, [keep])
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Wire shapes ───────────────────────────────────────────────
|
||||
|
||||
interface Domain { id: number; active: boolean; primary_backend_id?: number | null }
|
||||
@@ -130,11 +176,7 @@ export default function DashboardPage() {
|
||||
queryFn: () => fetchList<HAProxyBackend>('/haproxy/stats', 'backends'),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
const auditEntries = useQuery({
|
||||
queryKey: ['audit', 'recent'],
|
||||
queryFn: () => fetchList<AuditEntry>('/audit/recent?limit=10', 'entries'),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const auditEntries = useAuditLive(15)
|
||||
|
||||
const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList<Domain>('/domains', 'domains') })
|
||||
const backends = useQuery({ queryKey: ['backends'], queryFn: () => fetchList<Backend>('/backends', 'backends') })
|
||||
@@ -220,11 +262,11 @@ export default function DashboardPage() {
|
||||
{/* ── Recent activity (audit log) ─────────────────── */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={<><ApiOutlined /> {t('dashboard.activityCard.title')}</>} className="h-100">
|
||||
{(auditEntries.data ?? []).length === 0 ? (
|
||||
{auditEntries.length === 0 ? (
|
||||
<Text type="secondary">{t('dashboard.activityCard.empty')}</Text>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={2}>
|
||||
{(auditEntries.data ?? []).map(e => (
|
||||
{auditEntries.map(e => (
|
||||
<div key={e.id} style={{ fontSize: 12, color: '#334155', borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
|
||||
<Space size={6} wrap>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 10 }}>{e.action}</Tag>
|
||||
|
||||
Reference in New Issue
Block a user