feat(logs): Phase 4 — zentrales Logsystem /api/v1/logs + /system/logs

Aggregierter Reader für alle EdgeGuard-Service-Journale + audit_log.

internal/services/syslogs/
  - 9 Quellen: edgeguard-api, edgeguard-scheduler, haproxy, squid,
    unbound, chrony, wg-quick@*, ulogd2, audit
  - journalctl --output=json + parser für __REALTIME_TIMESTAMP,
    PRIORITY (0-7 → debug/info/warn/error), MESSAGE, _HOSTNAME
  - audit-Reader nutzt bestehende audit.Repo.ListRecent
  - Concurrent fan-out über alle gewählten Quellen, dann merge-sort
    by Timestamp DESC + cap auf Limit (max 1000)
  - Client-Filter: Level, Grep (case-insensitive über message +
    actor + action + subject)

internal/handlers/logs.go:
  GET /api/v1/logs            — Filter via Query-Params
  GET /api/v1/logs/sources    — statische Quellen-Liste fürs UI

postinst: edgeguard → systemd-journal + adm Gruppen, damit
journalctl ohne sudo lesen kann. Verifiziert auf der Box: id zeigt
`groups=adm,systemd-journal,haproxy,edgeguard`.

UI: management-ui/src/pages/Logs — Multi-Source-Select, Level-Color-
Tags, Time-Range-Picker, Volltext-Suche, Auto-Refresh 5s (Toggle),
CSV-Export. Sidebar-Eintrag "Logs" unter System (FileSearchOutlined).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-12 21:29:38 +02:00
parent 66187e5b77
commit 827c364335
13 changed files with 774 additions and 5 deletions

View File

@@ -0,0 +1,259 @@
import { useCallback, 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')
}
export default function LogsPage() {
const { t } = useTranslation()
const [filters, setFilters] = useState<Filters>({
sources: [], // [] = alle
levels: [], // [] = alle
range: null,
grep: '',
limit: 200,
})
const [autoRefresh, setAutoRefresh] = useState(true)
// 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 }}
/>
<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={{ pageSize: 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>
)
}