import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, Card, DatePicker, Input, Select, Space, Switch, Table, Tag, Tooltip, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { DownloadOutlined, FileSearchOutlined, ReloadOutlined, } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import dayjs, { type Dayjs } from 'dayjs' import apiClient, { isEnvelope } from '../../api/client' import PageHeader from '../../components/PageHeader' const { Text, Paragraph } = Typography const { RangePicker } = DatePicker interface Entry { timestamp: string source: string level: 'debug' | 'info' | 'warn' | 'error' message: string host?: string actor?: string action?: string subject?: string } const LEVEL_COLOR: Record = { debug: 'default', info: 'blue', warn: 'orange', error: 'red', } const SOURCE_COLOR: Record = { 'edgeguard-api': 'geekblue', 'edgeguard-scheduler': 'cyan', 'haproxy': 'magenta', 'squid': 'orange', 'unbound': 'purple', 'chrony': 'gold', 'wg-quick': 'green', 'ulogd2': 'volcano', 'audit': 'lime', } interface Filters { sources: string[] levels: ('debug' | 'info' | 'warn' | 'error')[] range: [Dayjs | null, Dayjs | null] | null grep: string limit: number } 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', 'source', 'level', 'message', 'actor', 'action', 'subject'] const lines = [cols.join(',')] for (const r of rows) { lines.push(cols.map((c) => csvEscape((r as unknown as Record)[c])).join(',')) } return lines.join('\n') } // localStorage-Key für die Filter-Persistenz. Filter werden so über // Reloads + Tab-Wechsel hinweg gehalten — sonst muss der Operator nach // jedem F5 source/level/grep neu eintippen. const LOGS_FILTERS_KEY = 'edgeguard.logs.filters.v1' // Filter aus localStorage lesen + sicher dekodieren. range wird als // String[2] (ISO) serialisiert; beim Lesen rekonstruieren wir es als // String — die DatePicker-Range erwartet Dayjs aber range-Restoration // vereinfachen wir hier zu null, weil Datumsbereiche meist nicht über // Sessions hinweg interessant sind (logs sind zeitnah). function loadStoredFilters(): Filters { const empty: Filters = { sources: [], levels: [], range: null, grep: '', limit: 200 } try { const raw = localStorage.getItem(LOGS_FILTERS_KEY) if (!raw) return empty const v = JSON.parse(raw) as Partial return { sources: Array.isArray(v.sources) ? v.sources : [], levels: Array.isArray(v.levels) ? v.levels : [], range: null, // Range nicht persistieren (Logs sind „jetzt"-relevant) grep: typeof v.grep === 'string' ? v.grep : '', limit: typeof v.limit === 'number' && v.limit > 0 ? v.limit : 200, } } catch { return empty } } export default function LogsPage() { const { t } = useTranslation() const [filters, setFilters] = useState(() => loadStoredFilters()) const [autoRefresh, setAutoRefresh] = useState(true) // Filter-State in localStorage persistieren. Range bewusst weglassen // (Datumsbereiche sind „jetzt"-relevant, nicht zwischen Sessions). useEffect(() => { try { const { range: _, ...persistable } = filters void _ localStorage.setItem(LOGS_FILTERS_KEY, JSON.stringify(persistable)) } catch { // localStorage voll / disabled — sw allowed, einfach ignorieren. } }, [filters]) // Sources-Liste vom Backend (statisch im internal/services/syslogs). const sourcesQuery = useQuery({ queryKey: ['logs', 'sources'], queryFn: async () => { const r = await apiClient.get('/logs/sources') return isEnvelope(r.data) ? (r.data.data as { sources: string[] }).sources : [] }, staleTime: 60 * 60 * 1000, }) const queryParams = useMemo(() => { const p = new URLSearchParams() if (filters.sources.length > 0) p.set('sources', filters.sources.join(',')) if (filters.levels.length > 0) p.set('levels', filters.levels.join(',')) if (filters.range?.[0]) p.set('since', filters.range[0].toISOString()) if (filters.range?.[1]) p.set('until', filters.range[1].toISOString()) if (filters.grep) p.set('grep', filters.grep) p.set('limit', String(filters.limit)) return p.toString() }, [filters]) const logsQuery = useQuery({ queryKey: ['logs', 'list', queryParams], queryFn: async () => { const r = await apiClient.get(`/logs?${queryParams}`) return isEnvelope(r.data) ? (r.data.data as { entries: Entry[] }).entries : [] }, refetchInterval: autoRefresh ? 5_000 : false, }) const entries = logsQuery.data ?? [] const exportCSV = useCallback(() => { if (entries.length === 0) { message.info(t('logs.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 = `edgeguard-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) }, [entries, t]) const columns: ColumnsType = [ { title: t('logs.col.time'), dataIndex: 'timestamp', width: 170, render: (v: string) => ( {v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'} ), }, { title: t('logs.col.source'), dataIndex: 'source', width: 150, render: (s: string) => {s}, }, { title: t('logs.col.level'), dataIndex: 'level', width: 80, render: (l: Entry['level']) => {l.toUpperCase()}, }, { title: t('logs.col.message'), key: 'msg', render: (_, r) => (
{r.message}
{r.actor && ( actor: {r.actor}{r.action ? ` · ${r.action}` : ''}{r.subject ? ` · ${r.subject}` : ''} )}
), }, ] return (
} title={t('logs.title')} subtitle={t('logs.intro')} extra={ } /> setFilters((f) => ({ ...f, levels: v as Filters['levels'] }))} options={[ { value: 'debug', label: 'debug' }, { value: 'info', label: 'info' }, { value: 'warn', label: 'warn' }, { value: 'error', label: 'error' }, ]} /> setFilters((f) => ({ ...f, range: v as Filters['range'] }))} /> setFilters((f) => ({ ...f, grep: e.target.value }))} onSearch={(v) => setFilters((f) => ({ ...f, grep: v }))} />