Files
logstream/api.go
cedricandClaude Opus 5.5 194edee6de Settings: Save and Cancel buttons instead of saving each change
Changes made in the Settings window show at once but are only kept with
the Save button, placed under the tab labels; it takes the accent color
while changes are waiting. Cancel goes back to the saved settings, and
closing the window (or the page) with unsaved changes asks first.

Tags are now sent as a whole list (PUT /api/tags, nothing saved when one
is invalid, the error gives its position) and "Restore default tags"
shows GET /api/tags/defaults until Save.

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

579 lines
17 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"sort"
"strconv"
"time"
)
type API struct {
store *Store
hub *Hub
tags *TagStore
presets string // presets file, built-in presets when missing
rdns *ReverseDNS
allowPurge bool
exportMax int
docker *DockerManager // nil when DOCKER_LOGS is off
syslog *SyslogServer
host *HostLogs
dmesg *Dmesg
audit *Audit
files *Files
selfLog *SelfLog
dbstats dbStatsCache
}
func (a *API) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", a.health)
mux.HandleFunc("GET /api/logs", a.logs)
mux.HandleFunc("GET /api/histogram", a.histogram)
mux.HandleFunc("GET /api/facets", a.facets)
mux.HandleFunc("GET /api/stream", a.stream)
mux.HandleFunc("GET /api/stats", a.stats)
mux.HandleFunc("GET /api/dbstats", a.dbStats)
mux.HandleFunc("GET /api/tags", a.listTags)
mux.HandleFunc("POST /api/tags", a.createTag)
mux.HandleFunc("PUT /api/tags", a.replaceTags)
mux.HandleFunc("GET /api/tags/defaults", a.defaultTags)
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
mux.HandleFunc("GET /api/presets", a.listPresets)
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag)
mux.HandleFunc("GET /api/purge", a.purgeStatus)
mux.HandleFunc("POST /api/purge", a.purge)
mux.HandleFunc("GET /api/export.csv", a.exportCSV)
mux.HandleFunc("GET /api/syslog", a.syslogStatus)
mux.HandleFunc("PUT /api/syslog", a.syslogConfigure)
mux.HandleFunc("GET /api/docker", a.dockerStatus)
mux.HandleFunc("PUT /api/docker", a.dockerConfigure)
mux.HandleFunc("GET /api/hostlogs", a.hostLogsStatus)
mux.HandleFunc("PUT /api/hostlogs", a.hostLogsConfigure)
mux.HandleFunc("GET /api/dmesg", a.dmesgStatus)
mux.HandleFunc("PUT /api/dmesg", a.dmesgConfigure)
mux.HandleFunc("GET /api/audit", a.auditStatus)
mux.HandleFunc("PUT /api/audit", a.auditConfigure)
mux.HandleFunc("GET /api/files", a.filesStatus)
mux.HandleFunc("PUT /api/files", a.filesConfigure)
mux.HandleFunc("POST /api/files/detect", a.filesDetect)
mux.HandleFunc("GET /api/selflog", a.selfLogStatus)
mux.HandleFunc("PUT /api/selflog", a.selfLogConfigure)
}
// GET /api/audit: state of the Linux audit source (Settings > Sources).
func (a *API) auditStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.audit.Status())
}
// PUT /api/audit {"enabled": bool, "path": "/var/log/audit/audit.log"}
func (a *API) auditConfigure(w http.ResponseWriter, r *http.Request) {
var cfg auditConfig
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.audit.Configure(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
log.Printf("audit source changed from %s: %+v", r.RemoteAddr, cfg)
writeJSON(w, http.StatusOK, a.audit.Status())
}
// GET /api/files: the log files of the "Files" source (Settings > Sources).
func (a *API) filesStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.files.Status())
}
// PUT /api/files {"sources": [{"id", "name", "path", "format" (auto or a format), "regex", "host", "enabled"}]}
// (no id: a new file).
func (a *API) filesConfigure(w http.ResponseWriter, r *http.Request) {
var cfg filesConfig
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.files.Configure(cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
log.Printf("file sources changed from %s: %d files", r.RemoteAddr, len(cfg.Sources))
writeJSON(w, http.StatusOK, a.files.Status())
}
// POST /api/files/detect {"path": "/logs/traefik/access.log"}: whether the file
// can be read, its detected format and its last line (Settings > Sources). A
// POST, so that read-only accounts cannot read files through it.
func (a *API) filesDetect(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, DetectFile(req.Path))
}
// GET /api/selflog: state of the LogStream internal logs (Settings > Sources).
func (a *API) selfLogStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.selfLog.Status())
}
// PUT /api/selflog {"enabled": bool, "min_level": "warning"}
func (a *API) selfLogConfigure(w http.ResponseWriter, r *http.Request) {
var cfg selfLogConfig
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.selfLog.Configure(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
log.Printf("internal logs changed from %s: %+v", r.RemoteAddr, cfg)
writeJSON(w, http.StatusOK, a.selfLog.Status())
}
// GET /api/dmesg: state of the kernel messages source (Settings > Sources).
func (a *API) dmesgStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.dmesg.Status())
}
// PUT /api/dmesg {"enabled": bool}
func (a *API) dmesgConfigure(w http.ResponseWriter, r *http.Request) {
var cfg dmesgConfig
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.dmesg.Configure(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
log.Printf("kernel messages source changed from %s: %+v", r.RemoteAddr, cfg)
writeJSON(w, http.StatusOK, a.dmesg.Status())
}
// GET /api/hostlogs: state of the host system logs source (Settings > Sources).
func (a *API) hostLogsStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.host.Status())
}
// PUT /api/hostlogs {"enabled": bool}
func (a *API) hostLogsConfigure(w http.ResponseWriter, r *http.Request) {
var cfg hostLogsConfig
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.host.Configure(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
log.Printf("host system logs changed from %s: %+v", r.RemoteAddr, cfg)
writeJSON(w, http.StatusOK, a.host.Status())
}
// GET /api/syslog: syslog reception state (Settings > Sources).
func (a *API) syslogStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.syslog.Status())
}
// PUT /api/syslog {"enabled": bool, "udp": bool, "tcp": bool}
func (a *API) syslogConfigure(w http.ResponseWriter, r *http.Request) {
var cfg syslogConfig
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.syslog.Configure(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
log.Printf("syslog reception changed from %s: %+v", r.RemoteAddr, cfg)
writeJSON(w, http.StatusOK, a.syslog.Status())
}
// GET /api/docker: Docker source state and containers (Settings > Sources).
func (a *API) dockerStatus(w http.ResponseWriter, r *http.Request) {
if a.docker == nil {
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
writeJSON(w, http.StatusOK, a.docker.Status())
}
// PUT /api/docker {"defaultEnabled": bool, "containers": {"project/service": bool}}
func (a *API) dockerConfigure(w http.ResponseWriter, r *http.Request) {
if a.docker == nil {
writeErr(w, http.StatusConflict, &codedError{code: "docker_off", msg: "the Docker source is disabled (DOCKER_LOGS=off)"})
return
}
var body struct {
DefaultEnabled *bool `json:"defaultEnabled"`
Containers map[string]bool `json:"containers"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024)).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.docker.Configure(body.DefaultEnabled, body.Containers); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, a.docker.Status())
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeErr returns {"error": "...", "code": "...", "detail": "..."}; the UI
// translates known codes and falls back to the English message.
func writeErr(w http.ResponseWriter, status int, err error) {
body := map[string]string{"error": err.Error()}
var ce *codedError
if errors.As(err, &ce) {
body["code"] = ce.code
if ce.detail != "" {
body["detail"] = ce.detail
}
}
writeJSON(w, status, body)
}
func (a *API) health(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// GET /api/logs?q=&mode=&range=&host=&app=&severity=&limit=
func (a *API) logs(w http.ResponseWriter, r *http.Request) {
f := FilterFromRequest(r)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 5000 {
limit = 500
}
filter, pipes := f.LogsQL()
q := fmt.Sprintf("%s%s | sort by (_time desc) | limit %d", filter, pipes, limit)
start := time.Now()
rows, err := a.store.Query(r.Context(), q)
if err != nil {
writeErr(w, queryStatus(err), err)
return
}
a.annotateHosts(rows)
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"rows": rows,
"took_ms": time.Since(start).Milliseconds(),
})
}
func toInt(v any) int64 {
switch x := v.(type) {
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
case float64:
return int64(x)
}
return 0
}
// annotateHosts adds "host_name" to rows whose host is still an IP address
// (logs stored before DNS resolution, or resolved too slowly at reception).
func (a *API) annotateHosts(rows []map[string]any) {
seen := map[string]bool{}
var ips []string
for _, row := range rows {
if h, _ := row["host"].(string); h != "" && !seen[h] && net.ParseIP(h) != nil {
seen[h] = true
ips = append(ips, h)
}
}
if len(ips) == 0 {
return
}
if len(ips) > 100 {
ips = ips[:100]
}
names := a.rdns.LookupMany(ips, time.Second)
for _, row := range rows {
if h, _ := row["host"].(string); names[h] != "" {
row["host_name"] = names[h]
}
}
}
type facet struct {
Value string `json:"value"` // stored value, used for filtering
Label string `json:"label"` // displayed text ("name (ip)" for resolved IPs)
}
// GET /api/facets: hosts and apps seen over 7 days, for the filter dropdowns.
func (a *API) facets(w http.ResponseWriter, r *http.Request) {
res := map[string][]facet{}
for _, field := range []string{"host", "app"} {
q := "_time:7d | stats by (" + field + ") count() hits | sort by (hits desc) | limit 300"
rows, err := a.store.Query(r.Context(), q)
if err != nil {
writeErr(w, queryStatus(err), err)
return
}
vals := make([]string, 0, len(rows))
var ips []string
for _, row := range rows {
if v, _ := row[field].(string); v != "" {
vals = append(vals, v)
if field == "host" && net.ParseIP(v) != nil {
ips = append(ips, v)
}
}
}
names := a.rdns.LookupMany(ips, time.Second)
list := make([]facet, 0, len(vals))
for _, v := range vals {
label := v
if n := names[v]; n != "" {
label = n + " (" + v + ")"
}
list = append(list, facet{Value: v, Label: label})
}
sort.Slice(list, func(i, j int) bool { return list[i].Label < list[j].Label })
res[field] = list
}
writeJSON(w, http.StatusOK, res)
}
// GET /api/stream: SSE stream of new messages matching the filter,
// sent in batches every 250 ms.
func (a *API) stream(w http.ResponseWriter, r *http.Request) {
f := FilterFromRequest(r)
if f.Mode == "logsql" && f.Text != "" {
writeErr(w, http.StatusBadRequest, &codedError{code: "live_logsql", msg: "live view is not available in LogsQL mode"})
return
}
m := f.Matcher()
rc := http.NewResponseController(w)
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, ": connected\n\n")
_ = rc.Flush()
ch := a.hub.Subscribe()
defer a.hub.Unsubscribe(ch)
tick := time.NewTicker(250 * time.Millisecond)
defer tick.Stop()
const maxBatch = 500
var pending []map[string]string
skipped, idle := 0, 0
for {
select {
case <-r.Context().Done():
return
case e := <-ch:
if !m.Match(e) {
continue
}
if len(pending) < maxBatch {
pending = append(pending, e.Record())
} else {
skipped++
}
case <-tick.C:
var err error
if len(pending) == 0 && skipped == 0 {
// SSE comment every 15 s to keep the connection open.
if idle++; idle < 60 {
continue
}
idle = 0
_, err = fmt.Fprint(w, ": ping\n\n")
} else {
b, _ := json.Marshal(map[string]any{"rows": pending, "skipped": skipped})
pending, skipped, idle = pending[:0], 0, 0
_, err = fmt.Fprintf(w, "data: %s\n\n", b)
}
if err == nil {
err = rc.Flush()
}
if err != nil {
return
}
}
}
}
func (a *API) stats(w http.ResponseWriter, r *http.Request) {
s := a.store.Stats()
s["clients"] = a.hub.Count()
s["exportMax"] = a.exportMax
writeJSON(w, http.StatusOK, s)
}
// GET /api/dbstats[?refresh=1]: size and content of the VictoriaLogs base
// (Settings > Data), cached for 30 s.
func (a *API) dbStats(w http.ResponseWriter, r *http.Request) {
st, err := a.dbstats.get(r.Context(), a.store, r.URL.Query().Get("refresh") == "1")
if err != nil {
writeErr(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, st)
}
func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.tags.List())
}
func (a *API) listPresets(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, loadPresets(a.presets))
}
func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) {
var t Tag
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t)
return t, err
}
func (a *API) createTag(w http.ResponseWriter, r *http.Request) {
t, err := decodeTag(w, r)
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if t, err = a.tags.Create(t); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
func (a *API) updateTag(w http.ResponseWriter, r *http.Request) {
t, err := decodeTag(w, r)
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
t, err = a.tags.Update(r.PathValue("id"), t)
switch {
case errors.Is(err, errTagNotFound):
writeErr(w, http.StatusNotFound, err)
case err != nil:
writeErr(w, http.StatusBadRequest, err)
default:
writeJSON(w, http.StatusOK, t)
}
}
func (a *API) deleteTag(w http.ResponseWriter, r *http.Request) {
err := a.tags.Delete(r.PathValue("id"))
switch {
case errors.Is(err, errTagNotFound):
writeErr(w, http.StatusNotFound, err)
case err != nil:
writeErr(w, http.StatusInternalServerError, err)
default:
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/purge: whether purging is allowed and how many deletions are running.
func (a *API) purgeStatus(w http.ResponseWriter, r *http.Request) {
res := map[string]any{"allowed": a.allowPurge, "running": 0}
if a.allowPurge {
n, err := a.store.PurgeRunning(r.Context())
if err != nil {
res["error"] = err.Error()
var ce *codedError
if errors.As(err, &ce) {
res["code"] = ce.code
}
}
res["running"] = n
}
writeJSON(w, http.StatusOK, res)
}
// POST /api/purge {"confirm":"PURGE"}: deletes every stored log.
func (a *API) purge(w http.ResponseWriter, r *http.Request) {
if !a.allowPurge {
writeErr(w, http.StatusForbidden, &codedError{code: "purge_forbidden", msg: "purging is disabled (ALLOW_PURGE=false)"})
return
}
var body struct {
Confirm string `json:"confirm"`
}
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body)
if body.Confirm != "PURGE" {
writeErr(w, http.StatusBadRequest, &codedError{code: "purge_confirm", msg: `type "PURGE" to confirm`})
return
}
id, err := a.store.Purge(r.Context())
if err != nil {
writeErr(w, http.StatusBadGateway, err)
return
}
log.Printf("purge of all logs requested from %s (task %s)", r.RemoteAddr, id)
writeJSON(w, http.StatusOK, map[string]string{"task_id": id})
}
// PUT /api/tags [tags]: replaces the whole list. On an invalid tag, "index"
// gives its position and nothing is saved.
func (a *API) replaceTags(w http.ResponseWriter, r *http.Request) {
var in []Tag
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
tags, err := a.tags.Replace(in)
var le *tagListError
switch {
case errors.As(err, &le):
body := map[string]any{"error": err.Error(), "index": le.index}
var ce *codedError
if errors.As(err, &ce) {
body["code"] = ce.code
if ce.detail != "" {
body["detail"] = ce.detail
}
}
writeJSON(w, http.StatusBadRequest, body)
case errors.Is(err, errTooManyTags):
writeErr(w, http.StatusBadRequest, err)
case err != nil:
writeErr(w, http.StatusInternalServerError, err)
default:
writeJSON(w, http.StatusOK, tags)
}
}
// GET /api/tags/defaults: the tags "Restore default tags" shows before saving.
func (a *API) defaultTags(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, defaultTags())
}
func (a *API) resetTags(w http.ResponseWriter, r *http.Request) {
tags, err := a.tags.Reset()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, tags)
}