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:
@@ -26,6 +26,7 @@ const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
|
||||
const LogsPage = lazy(() => import('./pages/Logs'))
|
||||
const LicensePage = lazy(() => import('./pages/License'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
|
||||
@@ -111,6 +112,7 @@ export default function App() {
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/firewall-live" element={<FirewallLivePage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -18,6 +18,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/firewall-live': 'nav.firewallLive',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/logs': 'nav.logs',
|
||||
'/license': 'nav.license',
|
||||
'/settings': 'nav.settings',
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
EyeOutlined,
|
||||
FileSearchOutlined,
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
@@ -71,13 +72,14 @@ const NAV: NavSection[] = [
|
||||
labelKey: 'nav.section.system',
|
||||
items: [
|
||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
|
||||
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
||||
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.61'
|
||||
const VERSION = '1.0.62'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"firewall": "Firewall",
|
||||
"firewallLive": "Firewall-Log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"license": "Lizenz",
|
||||
"settings": "Einstellungen",
|
||||
"section": {
|
||||
@@ -619,6 +620,31 @@
|
||||
"cta": "Jetzt aktivieren →",
|
||||
"openPage": "Lizenz-Seite öffnen →"
|
||||
},
|
||||
"logs": {
|
||||
"title": "System-Logs",
|
||||
"intro": "Aggregierter Blick auf alle Service-Journals + audit_log. Multi-Source-Auswahl, Level-Filter, Freitext-Suche, Zeit-Range, Auto-Refresh (5s).",
|
||||
"autoOn": "Auto",
|
||||
"autoOff": "Manuell",
|
||||
"refresh": "Aktualisieren",
|
||||
"refreshTooltip": "Einmalig neu laden",
|
||||
"export": "CSV",
|
||||
"exportTooltip": "Aktuelle Tabelle als CSV exportieren",
|
||||
"exportEmpty": "Keine Einträge zum Exportieren",
|
||||
"found": "{{n}} Einträge",
|
||||
"limit": "Limit",
|
||||
"empty": "Keine Einträge gefunden. Quellen-Auswahl ändern oder Zeit-Range erweitern.",
|
||||
"col": {
|
||||
"time": "Zeit",
|
||||
"source": "Quelle",
|
||||
"level": "Level",
|
||||
"message": "Nachricht"
|
||||
},
|
||||
"filter": {
|
||||
"sources": "Quellen wählen (alle wenn leer)",
|
||||
"levels": "Level filtern",
|
||||
"grep": "Volltext-Suche"
|
||||
}
|
||||
},
|
||||
"fwlog": {
|
||||
"title": "Firewall-Log (Live)",
|
||||
"intro": "Pakete, die in nft-Regeln mit aktivem Log-Flag matchen, fließen via NFLOG → ulogd2 → JSONL hierher. WebSocket-Stream zeigt Live-Events; Ring-Buffer (1000) hält die letzten Treffer auch nach Reconnect.",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"firewall": "Firewall",
|
||||
"firewallLive": "Firewall log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"license": "License",
|
||||
"settings": "Settings",
|
||||
"section": {
|
||||
@@ -619,6 +620,31 @@
|
||||
"cta": "Activate now →",
|
||||
"openPage": "Open license page →"
|
||||
},
|
||||
"logs": {
|
||||
"title": "System logs",
|
||||
"intro": "Aggregated view across all service journals + audit_log. Multi-source selection, level filter, free-text search, time range, auto-refresh (5s).",
|
||||
"autoOn": "Auto",
|
||||
"autoOff": "Manual",
|
||||
"refresh": "Refresh",
|
||||
"refreshTooltip": "Reload once",
|
||||
"export": "CSV",
|
||||
"exportTooltip": "Export current table as CSV",
|
||||
"exportEmpty": "No entries to export",
|
||||
"found": "{{n}} entries",
|
||||
"limit": "limit",
|
||||
"empty": "No entries found. Change source selection or widen the time range.",
|
||||
"col": {
|
||||
"time": "Time",
|
||||
"source": "Source",
|
||||
"level": "Level",
|
||||
"message": "Message"
|
||||
},
|
||||
"filter": {
|
||||
"sources": "Select sources (all if empty)",
|
||||
"levels": "Filter levels",
|
||||
"grep": "Full-text search"
|
||||
}
|
||||
},
|
||||
"fwlog": {
|
||||
"title": "Firewall log (live)",
|
||||
"intro": "Packets matching nft rules with the log flag enabled flow via NFLOG → ulogd2 → JSONL into this view. WebSocket stream shows live events; ring buffer (1000) keeps recent hits across reconnects.",
|
||||
|
||||
259
management-ui/src/pages/Logs/index.tsx
Normal file
259
management-ui/src/pages/Logs/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user