Files
cedricandClaude Opus 5.5 30018e09e2 Host system logs source (systemd journal or /var/log)
New source, off by default and switched in Settings > Sources, that
collects the system logs of the machine hosting the stack:
- reads the systemd journal files directly (pure Go reader, no
  journalctl in the image), from /var/log/journal and /run/log/journal
  mounted read-only under /host;
- falls back to following the text files of /var/log (syslog,
  messages, *.log) on hosts without journald;
- positions saved in /data/hostlogs-state.json, HOST_LOGS_BACKFILL
  read when the source is turned on;
- source_type "host", selectable in the Source filter;
- compose mounts and group_add (HOST_LOGS_GID, adm by default), docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 10:05:38 +02:00

226 lines
5.6 KiB
Go

package main
import (
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
// Filter holds the search criteria sent by the UI.
type Filter struct {
Mode string // "simple" or "logsql"
Text string
Range string // 5m, 15m, 1h, 6h, 24h, 7d, 30d; anything else = no time limit
From, To time.Time // absolute range (timeline zoom), replaces Range when From is set
Host string
App string
Severity int // highest severity number included (0 = emerg … 7 = debug), -1 = all
Source string // "syslog", "docker", "host" or "" for all
}
var rangeDurations = map[string]time.Duration{
"5m": 5 * time.Minute, "15m": 15 * time.Minute, "1h": time.Hour, "6h": 6 * time.Hour,
"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour,
}
// msParam reads a Unix time in milliseconds (zero time when absent or invalid).
func msParam(v string) time.Time {
ms, err := strconv.ParseInt(v, 10, 64)
if err != nil || ms <= 0 {
return time.Time{}
}
return time.UnixMilli(ms)
}
func FilterFromRequest(r *http.Request) Filter {
q := r.URL.Query()
f := Filter{
Mode: q.Get("mode"),
Text: strings.TrimSpace(q.Get("q")),
Range: q.Get("range"),
Host: q.Get("host"),
App: q.Get("app"),
Source: q.Get("source"),
Severity: -1,
}
if v, err := strconv.Atoi(q.Get("severity")); err == nil && v >= 0 && v <= 7 {
f.Severity = v
}
// from/to: Unix milliseconds, sent when the timeline is zoomed.
if from := msParam(q.Get("from")); !from.IsZero() {
f.From, f.To = from, msParam(q.Get("to"))
if !f.To.IsZero() && !f.To.After(f.From) {
f.To = time.Time{}
}
}
return f
}
type term struct {
text string
neg bool
}
// parseTerms splits a simple search into words, "quoted phrases" and -exclusions.
func parseTerms(s string) []term {
var out []term
for i := 0; i < len(s); {
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
if i >= len(s) {
break
}
neg := false
if s[i] == '-' && i+1 < len(s) && s[i+1] != ' ' {
neg = true
i++
}
var t string
if s[i] == '"' {
end := strings.IndexByte(s[i+1:], '"')
if end < 0 {
t, i = s[i+1:], len(s)
} else {
t, i = s[i+1:i+1+end], i+end+2
}
} else {
end := strings.IndexAny(s[i:], " \t")
if end < 0 {
t, i = s[i:], len(s)
} else {
t, i = s[i:i+end], i+end
}
}
if t != "" {
out = append(out, term{t, neg})
}
}
return out
}
// filterExpr turns the criteria (except raw LogsQL text) into a LogsQL filter.
func (f Filter) filterExpr() string {
var parts []string
switch {
case !f.From.IsZero() && !f.To.IsZero():
parts = append(parts, "_time:["+logsqlTime(f.From)+", "+logsqlTime(f.To)+")")
case !f.From.IsZero():
parts = append(parts, "_time:>="+logsqlTime(f.From))
case rangeDurations[f.Range] > 0:
parts = append(parts, "_time:"+f.Range)
}
if f.Host != "" {
parts = append(parts, "host:="+strconv.Quote(f.Host))
}
if f.App != "" {
parts = append(parts, "app:="+strconv.Quote(f.App))
}
switch f.Source {
case "docker", "host":
parts = append(parts, `source_type:=`+strconv.Quote(f.Source))
case "syslog": // also matches logs stored before source_type existed
parts = append(parts, `!(source_type:in("docker","host"))`)
}
if f.Severity >= 0 && f.Severity < 7 {
names := make([]string, 0, 8)
for i := 0; i <= f.Severity; i++ {
names = append(names, strconv.Quote(severityNames[i]))
}
parts = append(parts, "severity:in("+strings.Join(names, ",")+")")
}
if f.Mode != "logsql" {
// Case-insensitive substring search in the message, host and app.
for _, t := range parseTerms(f.Text) {
re := strconv.Quote("(?i)" + regexp.QuoteMeta(t.text))
expr := "(_msg:~" + re + " or host:~" + re + " or app:~" + re + ")"
if t.neg {
expr = "!" + expr
}
parts = append(parts, expr)
}
}
return strings.Join(parts, " ")
}
func logsqlTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
// LogsQL returns the filter part and any pipes ("| …") typed in LogsQL mode.
func (f Filter) LogsQL() (filter, pipes string) {
filter = f.filterExpr()
if f.Mode == "logsql" && f.Text != "" {
userFilter, userPipes := splitPipes(f.Text)
if userFilter != "" && userFilter != "*" {
filter = strings.TrimSpace(filter + " (" + userFilter + ")")
}
pipes = userPipes
}
if filter == "" {
filter = "*"
}
return filter, pipes
}
// splitPipes separates the filter from the pipes, ignoring "|" inside quotes.
func splitPipes(q string) (filter, pipes string) {
var quote byte
for i := 0; i < len(q); i++ {
c := q[i]
switch {
case quote != 0:
if c == '\\' && quote != '`' {
i++
} else if c == quote {
quote = 0
}
case c == '"' || c == '\'' || c == '`':
quote = c
case c == '|':
return strings.TrimSpace(q[:i]), " " + q[i:]
}
}
return strings.TrimSpace(q), ""
}
// Matcher applies the same filter as filterExpr to live messages.
type Matcher struct {
f Filter
res []*regexp.Regexp
negs []bool
}
func (f Filter) Matcher() *Matcher {
m := &Matcher{f: f}
if f.Mode != "logsql" {
for _, t := range parseTerms(f.Text) {
m.res = append(m.res, regexp.MustCompile("(?i)"+regexp.QuoteMeta(t.text)))
m.negs = append(m.negs, t.neg)
}
}
return m
}
func (m *Matcher) Match(e *Entry) bool {
if m.f.Host != "" && e.Host != m.f.Host {
return false
}
if m.f.App != "" && e.App != m.f.App {
return false
}
if m.f.Source != "" && m.f.Source != e.SourceType {
return false
}
if m.f.Severity >= 0 && e.SevNum > m.f.Severity {
return false
}
for i, re := range m.res {
hit := re.MatchString(e.Message) || re.MatchString(e.Host) || re.MatchString(e.App)
if hit == m.negs[i] {
return false
}
}
return true
}