Files
logstream/query.go
T
cedricandClaude Opus 5.5 0beaa24f82 Kernel messages (dmesg) source read from /dev/kmsg
New source in Settings > Sources, off by default: reads the kernel ring
buffer of the host through /dev/kmsg, dates records from the boot time and
resumes after the last sequence number of the same boot (no duplicates on
restart). Missing device or denied access is shown in the UI, retried every
10 s. docker-compose.yml and .env.example document the device and the
kernel.dmesg_restrict / CAP_SYSLOG options.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:52:17 +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", "kernel":
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","kernel"))`)
}
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
}