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

93
internal/handlers/logs.go Normal file
View File

@@ -0,0 +1,93 @@
package handlers
import (
"context"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
)
// LogsHandler exposes:
//
// GET /api/v1/logs?sources=edgeguard-api,haproxy,...
// &levels=error,warn
// &since=2026-05-12T20:00:00Z
// &until=2026-05-12T21:00:00Z
// &grep=503
// &limit=500
//
// Aggregiert journalctl-Output für die EdgeGuard-Services + die
// audit_log-Tabelle in ein einheitliches Entry-Format.
type LogsHandler struct {
Reader *syslogs.Reader
}
func NewLogsHandler(r *syslogs.Reader) *LogsHandler {
return &LogsHandler{Reader: r}
}
func (h *LogsHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/logs")
g.GET("", h.List)
g.GET("/sources", h.Sources)
}
// Sources gibt die statische Quellen-Liste fürs UI-Dropdown zurück —
// das UI muss die nicht hartcodieren.
func (h *LogsHandler) Sources(c *gin.Context) {
out := make([]string, 0, len(syslogs.AllSources))
for _, s := range syslogs.AllSources {
out = append(out, string(s))
}
response.OK(c, gin.H{"sources": out})
}
// List baut den Filter aus den Query-Params und führt Reader.Query aus.
// Errors aus einzelnen Sources sind nicht-fatal (Reader.Query logged
// sie selbst); wir liefern was vorhanden ist.
func (h *LogsHandler) List(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
f := syslogs.Filter{
Grep: c.Query("grep"),
}
if s := c.Query("sources"); s != "" {
for _, raw := range strings.Split(s, ",") {
s := strings.TrimSpace(raw)
if s != "" {
f.Sources = append(f.Sources, syslogs.Source(s))
}
}
}
if s := c.Query("levels"); s != "" {
for _, raw := range strings.Split(s, ",") {
s := strings.TrimSpace(raw)
if s != "" {
f.Levels = append(f.Levels, syslogs.Level(s))
}
}
}
if s := c.Query("since"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
f.Since = t
}
}
if s := c.Query("until"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
f.Until = t
}
}
if s := c.Query("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
f.Limit = n
}
}
entries, _ := h.Reader.Query(ctx, f)
response.OK(c, gin.H{"entries": entries, "count": len(entries)})
}