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:
@@ -37,6 +37,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
|
||||||
@@ -49,7 +50,7 @@ import (
|
|||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.61"
|
var version = "1.0.62"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -190,6 +191,9 @@ func main() {
|
|||||||
fwLogTailer := firewalllog.NewTailer(firewalllog.DefaultLogPath, 1000)
|
fwLogTailer := firewalllog.NewTailer(firewalllog.DefaultLogPath, 1000)
|
||||||
handlers.StartFirewallLogTailer(context.Background(), fwLogTailer)
|
handlers.StartFirewallLogTailer(context.Background(), fwLogTailer)
|
||||||
handlers.NewFirewallLogHandler(fwLogTailer, firewalllog.DefaultLogPath).Register(authed)
|
handlers.NewFirewallLogHandler(fwLogTailer, firewalllog.DefaultLogPath).Register(authed)
|
||||||
|
|
||||||
|
// /logs (Phase 4): aggregierter Reader für journalctl + audit_log
|
||||||
|
handlers.NewLogsHandler(syslogs.New(auditRepo)).Register(authed)
|
||||||
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
|
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
|
||||||
// Firewall reload: nach jeder Mutation den Renderer neu fahren
|
// Firewall reload: nach jeder Mutation den Renderer neu fahren
|
||||||
// (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen.
|
// (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.61"
|
var version = "1.0.62"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.61"
|
var version = "1.0.62"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
93
internal/handlers/logs.go
Normal file
93
internal/handlers/logs.go
Normal 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)})
|
||||||
|
}
|
||||||
347
internal/services/syslogs/syslogs.go
Normal file
347
internal/services/syslogs/syslogs.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
// Package syslogs aggregiert Log-Entries aus systemd-journal (per
|
||||||
|
// `journalctl --output=json`) und der audit_log-Tabelle in ein
|
||||||
|
// einheitliches Entry-Format. Phase 4 des Log-Systems.
|
||||||
|
//
|
||||||
|
// Quellen sind statisch konfiguriert — alles was zur EdgeGuard-Box
|
||||||
|
// gehört. ulogd2 ist NICHT enthalten, weil dessen Daten als
|
||||||
|
// strukturiertes File von /firewall-live gehandhabt werden.
|
||||||
|
//
|
||||||
|
// journalctl-Aufruf läuft als edgeguard-User; das funktioniert nur
|
||||||
|
// wenn der in der Gruppe `systemd-journal` (oder `adm`) ist — der
|
||||||
|
// postinst legt das an.
|
||||||
|
package syslogs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source bezeichnet eine logische Log-Quelle, die das UI als Filter
|
||||||
|
// auswählt. "audit" ist die DB-Tabelle, alle anderen sind systemd-
|
||||||
|
// Units.
|
||||||
|
type Source string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceAPI Source = "edgeguard-api"
|
||||||
|
SourceScheduler Source = "edgeguard-scheduler"
|
||||||
|
SourceHAProxy Source = "haproxy"
|
||||||
|
SourceSquid Source = "squid"
|
||||||
|
SourceUnbound Source = "unbound"
|
||||||
|
SourceChrony Source = "chrony"
|
||||||
|
SourceWireguard Source = "wg-quick" // bezieht alle wg-quick@*.service
|
||||||
|
SourceUlogd Source = "ulogd2"
|
||||||
|
SourceAudit Source = "audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllSources ist die Reihenfolge fürs UI-Dropdown.
|
||||||
|
var AllSources = []Source{
|
||||||
|
SourceAPI, SourceScheduler,
|
||||||
|
SourceHAProxy, SourceSquid, SourceUnbound, SourceChrony, SourceWireguard, SourceUlogd,
|
||||||
|
SourceAudit,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level mappt syslog-PRIORITY-Werte auf eine UI-freundliche
|
||||||
|
// Kategorie. journalctl liefert Strings; wir normalisieren auf
|
||||||
|
// debug/info/warn/error.
|
||||||
|
type Level string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelDebug Level = "debug"
|
||||||
|
LevelInfo Level = "info"
|
||||||
|
LevelWarn Level = "warn"
|
||||||
|
LevelError Level = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry ist die unified Sicht — sortiert by Timestamp DESC im
|
||||||
|
// Endergebnis.
|
||||||
|
type Entry struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Source Source `json:"source"`
|
||||||
|
Level Level `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
// Audit-spezifisch — optional gesetzt für SourceAudit. UI rendert
|
||||||
|
// das in einer eigenen Tooltip.
|
||||||
|
Actor string `json:"actor,omitempty"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter ist der Query-Container für GET /logs.
|
||||||
|
type Filter struct {
|
||||||
|
Sources []Source
|
||||||
|
Levels []Level
|
||||||
|
Since time.Time
|
||||||
|
Until time.Time
|
||||||
|
Grep string // case-insensitive substring auf Message + Actor/Action/Subject
|
||||||
|
Limit int // pro-Source-Limit, default 200, max 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader bündelt alle Quellen-Zugriffe.
|
||||||
|
type Reader struct {
|
||||||
|
Audit *audit.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(auditRepo *audit.Repo) *Reader {
|
||||||
|
return &Reader{Audit: auditRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query führt den Filter über alle gewählten Quellen aus und liefert
|
||||||
|
// eine zusammengeführte, by-Timestamp-DESC sortierte Liste. Pro
|
||||||
|
// Source-Slot maximal Filter.Limit Entries; das End-Result kann also
|
||||||
|
// in der Theorie len(Sources)*Limit groß sein — UI cappt selbst auf
|
||||||
|
// f.Limit beim Render.
|
||||||
|
func (r *Reader) Query(ctx context.Context, f Filter) ([]Entry, error) {
|
||||||
|
if f.Limit <= 0 {
|
||||||
|
f.Limit = 200
|
||||||
|
}
|
||||||
|
if f.Limit > 1000 {
|
||||||
|
f.Limit = 1000
|
||||||
|
}
|
||||||
|
sources := f.Sources
|
||||||
|
if len(sources) == 0 {
|
||||||
|
sources = AllSources
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
out []Entry
|
||||||
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
errs := make(chan error, len(sources))
|
||||||
|
|
||||||
|
for _, s := range sources {
|
||||||
|
s := s
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
var entries []Entry
|
||||||
|
var err error
|
||||||
|
if s == SourceAudit {
|
||||||
|
entries, err = r.queryAudit(ctx, f)
|
||||||
|
} else {
|
||||||
|
entries, err = readJournal(ctx, s, f)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errs <- fmt.Errorf("%s: %w", s, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Level + Grep nochmal filtern (journalctl-Output kommt
|
||||||
|
// roh durch).
|
||||||
|
entries = filterClient(entries, f)
|
||||||
|
mu.Lock()
|
||||||
|
out = append(out, entries...)
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
// Nicht-fatal — failed Source = einfach leere Liste für die,
|
||||||
|
// Operator sieht die Box "kein Eintrag von <source>". Aber wir
|
||||||
|
// flag'en den letzten Fehler oben für slog.Warn.
|
||||||
|
var lastErr error
|
||||||
|
for e := range errs {
|
||||||
|
lastErr = e
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return out[i].Timestamp.After(out[j].Timestamp)
|
||||||
|
})
|
||||||
|
if len(out) > f.Limit {
|
||||||
|
out = out[:f.Limit]
|
||||||
|
}
|
||||||
|
return out, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAudit füllt Entries aus der audit_log-Tabelle.
|
||||||
|
func (r *Reader) queryAudit(ctx context.Context, f Filter) ([]Entry, error) {
|
||||||
|
if r.Audit == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// audit.ListRecent hat einen 100-Max-Limit-Guard, was für unsere
|
||||||
|
// Aggregations-Use-Case zu wenig sein kann. Da wir nur die
|
||||||
|
// letzten Einträge brauchen reicht das aber als Source.
|
||||||
|
limit := f.Limit
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := r.Audit.ListRecent(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Entry, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
// Since/Until-Filter direkt anwenden (DB-Repo kennt das
|
||||||
|
// nicht — wir holen die letzten N und filtern hier).
|
||||||
|
if !f.Since.IsZero() && row.CreatedAt.Before(f.Since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.Until.IsZero() && row.CreatedAt.After(f.Until) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subject := ""
|
||||||
|
if row.Subject != nil {
|
||||||
|
subject = *row.Subject
|
||||||
|
}
|
||||||
|
msg := row.Action
|
||||||
|
if subject != "" {
|
||||||
|
msg = row.Action + ": " + subject
|
||||||
|
}
|
||||||
|
out = append(out, Entry{
|
||||||
|
Timestamp: row.CreatedAt,
|
||||||
|
Source: SourceAudit,
|
||||||
|
Level: LevelInfo,
|
||||||
|
Message: msg,
|
||||||
|
Actor: row.Actor,
|
||||||
|
Action: row.Action,
|
||||||
|
Subject: subject,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readJournal ruft `journalctl --output=json -u <unit> --since=...`
|
||||||
|
// auf. Mehrere Units (wg-quick@*) sind erlaubt via mehreren -u-Flags;
|
||||||
|
// für SourceWireguard wickeln wir das mit `-u wg-quick@*` ab.
|
||||||
|
func readJournal(ctx context.Context, s Source, f Filter) ([]Entry, error) {
|
||||||
|
args := []string{"--output=json", "--no-pager"}
|
||||||
|
unit := string(s) + ".service"
|
||||||
|
if s == SourceWireguard {
|
||||||
|
// alle wg-quick-Instanzen — glob-Pattern wird vom systemd
|
||||||
|
// matcher unterstützt.
|
||||||
|
args = append(args, "-u", "wg-quick@*.service")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-u", unit)
|
||||||
|
}
|
||||||
|
if !f.Since.IsZero() {
|
||||||
|
args = append(args, "--since", f.Since.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if !f.Until.IsZero() {
|
||||||
|
args = append(args, "--until", f.Until.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
// journalctl -n N gibt die letzten N. Wir nehmen N=Limit als
|
||||||
|
// Obergrenze pro Source.
|
||||||
|
args = append(args, "-n", strconv.Itoa(f.Limit))
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "journalctl", args...)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = cmd.Wait() }()
|
||||||
|
|
||||||
|
out := make([]Entry, 0, f.Limit)
|
||||||
|
sc := bufio.NewScanner(stdout)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Bytes()
|
||||||
|
if len(line) == 0 || line[0] != '{' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(line, &raw); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := parseJournalEntry(s, raw)
|
||||||
|
if e.Timestamp.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil && !errors.Is(err, bufio.ErrTooLong) {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJournalEntry(s Source, raw map[string]json.RawMessage) Entry {
|
||||||
|
e := Entry{Source: s}
|
||||||
|
// __REALTIME_TIMESTAMP ist string von Mikrosekunden seit epoch.
|
||||||
|
if v, ok := raw["__REALTIME_TIMESTAMP"]; ok {
|
||||||
|
var ts string
|
||||||
|
if err := json.Unmarshal(v, &ts); err == nil {
|
||||||
|
if usec, err := strconv.ParseInt(ts, 10, 64); err == nil {
|
||||||
|
e.Timestamp = time.UnixMicro(usec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := raw["MESSAGE"]; ok {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(v, &s) == nil {
|
||||||
|
e.Message = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := raw["_HOSTNAME"]; ok {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(v, &s) == nil {
|
||||||
|
e.Host = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := raw["PRIORITY"]; ok {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(v, &s) == nil {
|
||||||
|
e.Level = priorityToLevel(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Level == "" {
|
||||||
|
e.Level = LevelInfo
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorityToLevel(p string) Level {
|
||||||
|
switch p {
|
||||||
|
case "0", "1", "2", "3":
|
||||||
|
return LevelError
|
||||||
|
case "4":
|
||||||
|
return LevelWarn
|
||||||
|
case "7":
|
||||||
|
return LevelDebug
|
||||||
|
default:
|
||||||
|
return LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterClient wendet Level + Grep clientseitig an (journalctl filtert
|
||||||
|
// die nicht direkt; das einfacher als --priority + --grep zu jonglieren).
|
||||||
|
func filterClient(in []Entry, f Filter) []Entry {
|
||||||
|
hasLevel := len(f.Levels) > 0
|
||||||
|
levelSet := map[Level]bool{}
|
||||||
|
for _, l := range f.Levels {
|
||||||
|
levelSet[l] = true
|
||||||
|
}
|
||||||
|
grep := strings.ToLower(strings.TrimSpace(f.Grep))
|
||||||
|
|
||||||
|
out := in[:0]
|
||||||
|
for _, e := range in {
|
||||||
|
if hasLevel && !levelSet[e.Level] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if grep != "" {
|
||||||
|
hay := strings.ToLower(e.Message)
|
||||||
|
if !strings.Contains(hay, grep) &&
|
||||||
|
!strings.Contains(strings.ToLower(e.Actor), grep) &&
|
||||||
|
!strings.Contains(strings.ToLower(e.Action), grep) &&
|
||||||
|
!strings.Contains(strings.ToLower(e.Subject), grep) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const DNSPage = lazy(() => import('./pages/DNS'))
|
|||||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||||
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
|
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
|
||||||
|
const LogsPage = lazy(() => import('./pages/Logs'))
|
||||||
const LicensePage = lazy(() => import('./pages/License'))
|
const LicensePage = lazy(() => import('./pages/License'))
|
||||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ export default function App() {
|
|||||||
<Route path="/ntp" element={<NTPPage />} />
|
<Route path="/ntp" element={<NTPPage />} />
|
||||||
<Route path="/cluster" element={<ClusterPage />} />
|
<Route path="/cluster" element={<ClusterPage />} />
|
||||||
<Route path="/firewall-live" element={<FirewallLivePage />} />
|
<Route path="/firewall-live" element={<FirewallLivePage />} />
|
||||||
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
<Route path="/license" element={<LicensePage />} />
|
<Route path="/license" element={<LicensePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const PAGE_TITLES: Record<string, string> = {
|
|||||||
'/ip-addresses': 'nav.ipAddresses',
|
'/ip-addresses': 'nav.ipAddresses',
|
||||||
'/firewall-live': 'nav.firewallLive',
|
'/firewall-live': 'nav.firewallLive',
|
||||||
'/cluster': 'nav.cluster',
|
'/cluster': 'nav.cluster',
|
||||||
|
'/logs': 'nav.logs',
|
||||||
'/license': 'nav.license',
|
'/license': 'nav.license',
|
||||||
'/settings': 'nav.settings',
|
'/settings': 'nav.settings',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CrownOutlined,
|
CrownOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
@@ -71,13 +72,14 @@ const NAV: NavSection[] = [
|
|||||||
labelKey: 'nav.section.system',
|
labelKey: 'nav.section.system',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||||
|
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
|
||||||
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
||||||
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
{ 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:
|
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"firewall": "Firewall",
|
"firewall": "Firewall",
|
||||||
"firewallLive": "Firewall-Log",
|
"firewallLive": "Firewall-Log",
|
||||||
"cluster": "Cluster",
|
"cluster": "Cluster",
|
||||||
|
"logs": "Logs",
|
||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"section": {
|
"section": {
|
||||||
@@ -619,6 +620,31 @@
|
|||||||
"cta": "Jetzt aktivieren →",
|
"cta": "Jetzt aktivieren →",
|
||||||
"openPage": "Lizenz-Seite öffnen →"
|
"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": {
|
"fwlog": {
|
||||||
"title": "Firewall-Log (Live)",
|
"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.",
|
"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",
|
"firewall": "Firewall",
|
||||||
"firewallLive": "Firewall log",
|
"firewallLive": "Firewall log",
|
||||||
"cluster": "Cluster",
|
"cluster": "Cluster",
|
||||||
|
"logs": "Logs",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"section": {
|
"section": {
|
||||||
@@ -619,6 +620,31 @@
|
|||||||
"cta": "Activate now →",
|
"cta": "Activate now →",
|
||||||
"openPage": "Open license page →"
|
"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": {
|
"fwlog": {
|
||||||
"title": "Firewall log (live)",
|
"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.",
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,15 @@ case "$1" in
|
|||||||
if getent group haproxy >/dev/null; then
|
if getent group haproxy >/dev/null; then
|
||||||
usermod -a -G haproxy "$EG_USER" || true
|
usermod -a -G haproxy "$EG_USER" || true
|
||||||
fi
|
fi
|
||||||
|
# systemd-journal + adm: damit edgeguard-api `journalctl -u …`
|
||||||
|
# ohne sudo lesen kann — wird für /api/v1/logs gebraucht
|
||||||
|
# (zentrale Log-Übersicht über alle Services).
|
||||||
|
if getent group systemd-journal >/dev/null; then
|
||||||
|
usermod -a -G systemd-journal "$EG_USER" || true
|
||||||
|
fi
|
||||||
|
if getent group adm >/dev/null; then
|
||||||
|
usermod -a -G adm "$EG_USER" || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Directories ──────────────────────────────────────────────
|
# ── Directories ──────────────────────────────────────────────
|
||||||
# /etc/edgeguard und Service-Subdirs müssen für die Service-User
|
# /etc/edgeguard und Service-Subdirs müssen für die Service-User
|
||||||
|
|||||||
Reference in New Issue
Block a user