Zwei unabhängige Bugs hinter "Live-Log zeigt Einträge, aber nichts Neues":
1) ulogd2 stirbt beim nächtlichen Logrotate. Der postinst nahm an, ulogd
laufe als root — die Debian-Unit startet aber `ulogd --daemon --uid ulog`.
Beim Start öffnet ulogd die JSONL noch als root und schreibt danach über
den offenen fd weiter, egal wem sie gehört. Nachts schickt das Distro-
Profil /etc/logrotate.d/ulogd2 ein SIGHUP; das Reopen läuft dann als
`ulog` und scheitert an root:edgeguard 0640 ("can't open JSON log file:
Permission denied"). ulogd wertet das als fatal und beendet sich mit
Exit-Code 0 — Restart=on-failure hätte also nicht gegriffen, und ohne
Restart= blieb der Dienst tot (auf utm-1 5 Tage unbemerkt). Die API
servierte derweil weiter ihren In-Memory-Ring von vor der Rotation,
deshalb sah die UI Einträge, aber nie neue.
Fix: Owner ulog (Schreiber) : edgeguard (Leser), logrotate `create`
passend, plus Drop-in Restart=always als Selbstheilung.
2) Seitengröße liess sich nicht umstellen. Die Tabellen übergaben ein
literales `pagination={{ pageSize: N }}`. antd merged via
extendsObject(innerPagination, paginationObj) — der Prop überschreibt
bei jedem Render den State, den der Size-Changer gerade gesetzt hat.
Bei einem Live-Log rendert das im Sekundentakt, der Klick auf 20/100
war also sofort wieder weg. Fix: defaultPageSize (unkontrolliert).
Betraf ausser dem Live-Log auch Logs, Backups-History, Routes,
Alerts und CrowdSec.
Ausserdem: `t` aus den WS-Effect-Deps genommen. i18n wechselt dessen
Identität bei Store-Updates, was den Effect neu laufen liess — inklusive
setEntries([]), d.h. der Live-Puffer leerte sich ohne erkennbaren Grund.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
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<Entry['level'], string> = {
|
|
debug: 'default',
|
|
info: 'blue',
|
|
warn: 'orange',
|
|
error: 'red',
|
|
}
|
|
|
|
const SOURCE_COLOR: Record<string, string> = {
|
|
'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<string, unknown>)[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<Filters>
|
|
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<Filters>(() => 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<Entry> = [
|
|
{
|
|
title: t('logs.col.time'), dataIndex: 'timestamp', width: 170,
|
|
render: (v: string) => (
|
|
<Text style={{ fontFamily: 'monospace', fontSize: 11 }}>
|
|
{v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
title: t('logs.col.source'), dataIndex: 'source', width: 150,
|
|
render: (s: string) => <Tag color={SOURCE_COLOR[s] || 'default'}>{s}</Tag>,
|
|
},
|
|
{
|
|
title: t('logs.col.level'), dataIndex: 'level', width: 80,
|
|
render: (l: Entry['level']) => <Tag color={LEVEL_COLOR[l]}>{l.toUpperCase()}</Tag>,
|
|
},
|
|
{
|
|
title: t('logs.col.message'), key: 'msg',
|
|
render: (_, r) => (
|
|
<div>
|
|
<div style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-word' }}>
|
|
{r.message}
|
|
</div>
|
|
{r.actor && (
|
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
actor: {r.actor}{r.action ? ` · ${r.action}` : ''}{r.subject ? ` · ${r.subject}` : ''}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
icon={<FileSearchOutlined />}
|
|
title={t('logs.title')}
|
|
subtitle={t('logs.intro')}
|
|
extra={
|
|
<Space>
|
|
<Switch
|
|
checkedChildren={t('logs.autoOn')}
|
|
unCheckedChildren={t('logs.autoOff')}
|
|
checked={autoRefresh}
|
|
onChange={setAutoRefresh}
|
|
/>
|
|
<Tooltip title={t('logs.refreshTooltip')}>
|
|
<Button icon={<ReloadOutlined />} onClick={() => logsQuery.refetch()}>
|
|
{t('logs.refresh')}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip title={t('logs.exportTooltip')}>
|
|
<Button icon={<DownloadOutlined />} onClick={exportCSV}>{t('logs.export')}</Button>
|
|
</Tooltip>
|
|
</Space>
|
|
}
|
|
/>
|
|
|
|
<Card size="small" className="mb-16">
|
|
<Space wrap>
|
|
<Select
|
|
mode="multiple"
|
|
allowClear
|
|
placeholder={t('logs.filter.sources')}
|
|
style={{ minWidth: 280 }}
|
|
value={filters.sources}
|
|
onChange={(v) => setFilters((f) => ({ ...f, sources: v }))}
|
|
options={(sourcesQuery.data ?? []).map((s) => ({ value: s, label: s }))}
|
|
/>
|
|
<Select
|
|
mode="multiple"
|
|
allowClear
|
|
placeholder={t('logs.filter.levels')}
|
|
style={{ minWidth: 200 }}
|
|
value={filters.levels}
|
|
onChange={(v) => 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' },
|
|
]}
|
|
/>
|
|
<RangePicker
|
|
showTime
|
|
value={filters.range as never}
|
|
onChange={(v) => setFilters((f) => ({ ...f, range: v as Filters['range'] }))}
|
|
/>
|
|
<Input.Search
|
|
placeholder={t('logs.filter.grep')}
|
|
allowClear
|
|
style={{ width: 220 }}
|
|
value={filters.grep}
|
|
onChange={(e) => setFilters((f) => ({ ...f, grep: e.target.value }))}
|
|
onSearch={(v) => setFilters((f) => ({ ...f, grep: v }))}
|
|
/>
|
|
<Select
|
|
value={filters.limit}
|
|
onChange={(v) => setFilters((f) => ({ ...f, limit: v }))}
|
|
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} ${t('logs.limit')}` }))}
|
|
style={{ width: 130 }}
|
|
/>
|
|
<Button
|
|
size="small"
|
|
onClick={() => setFilters({ sources: [], levels: [], range: null, grep: '', limit: 200 })}
|
|
disabled={
|
|
filters.sources.length === 0 &&
|
|
filters.levels.length === 0 &&
|
|
!filters.range &&
|
|
!filters.grep &&
|
|
filters.limit === 200
|
|
}
|
|
>
|
|
{t('logs.filter.reset')}
|
|
</Button>
|
|
<Text type="secondary">{t('logs.found', { n: entries.length })}</Text>
|
|
</Space>
|
|
</Card>
|
|
|
|
<Table
|
|
rowKey={(r) => `${r.timestamp}-${r.source}-${r.message.slice(0, 32)}`}
|
|
size="small"
|
|
loading={logsQuery.isFetching}
|
|
dataSource={entries}
|
|
columns={columns}
|
|
pagination={{ defaultPageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
|
|
/>
|
|
|
|
{entries.length === 0 && !logsQuery.isFetching && (
|
|
<Paragraph type="secondary" style={{ textAlign: 'center', marginTop: 16 }}>
|
|
{t('logs.empty')}
|
|
</Paragraph>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|