Files
cedricandClaude Opus 5.5 085095c46f Collect the logs of the local Docker containers
- docker.go follows every running container through the Docker API
  (events + logs with follow), resumes after a restart from the last
  position saved in /data/docker-state.json, reads DOCKER_BACKFILL (1h)
  of history for new containers, strips terminal color codes and guesses
  the severity from the line (JSON, logfmt, [ERROR], ERROR ...).
- Logs carry source_type=docker, container, container_id, image,
  compose_project, compose_service and stream; host is the Docker host.
- Settings > Sources: one switch per container (grouped by compose
  project), enable/disable all, follow new containers automatically.
  Choices are saved per compose service in /data/docker.json.
- Source filter (syslog / docker) in the filter bar and the live view.
- docker-compose: read-only docker-socket-proxy; Logstream and the proxy
  are labelled logstream.exclude=true and never collected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:52:05 +02:00

150 lines
3.9 KiB
Go

package main
import (
"encoding/csv"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
)
// Columns of the CSV export: stored field -> column name.
var exportColumns = []struct{ field, name string }{
{"received", "received"},
{"msg_time", "message_time"},
{"severity", "severity"},
{"facility", "facility"},
{"host", "host"},
{"host_ip", "host_ip"},
{"app", "app"},
{"procid", "pid"},
{"source", "source_ip"},
{"proto", "proto"},
{"source_type", "source_type"},
{"container", "container"},
{"image", "image"},
{"_msg", "message"},
}
// UTF-8 byte order mark: lets Excel detect the encoding (accents).
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
// GET /api/export.csv?<filters>&tz=Europe/Paris&excel=1&limit=N
// Streams every stored log matching the filters (newest first, at most
// EXPORT_MAX rows) as CSV. excel=1 uses ";" as separator (French Excel),
// adds a BOM and neutralizes cells Excel would run as formulas.
func (a *API) exportCSV(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := FilterFromRequest(r)
filter, pipes := f.LogsQL()
if strings.TrimSpace(pipes) != "" {
writeErr(w, http.StatusBadRequest, &codedError{code: "export_pipes", msg: "export does not support LogsQL pipes (| ...): keep only the filter part"})
return
}
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 || limit > a.exportMax {
limit = a.exportMax
}
loc := time.UTC
if tz := q.Get("tz"); tz != "" {
if l, err := time.LoadLocation(tz); err == nil {
loc = l
}
}
excel := q.Get("excel") == "1"
query := fmt.Sprintf("%s | sort by (_time desc) | limit %d", filter, limit)
// The response starts with the first row, so that a query error can
// still be returned as a proper HTTP error.
var cw *csv.Writer
start := func() {
name := "logstream-" + time.Now().In(loc).Format("20060102-150405") + ".csv"
h := w.Header()
h.Set("Content-Type", "text/csv; charset=utf-8")
h.Set("Content-Disposition", `attachment; filename="`+name+`"`)
h.Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if excel {
_, _ = w.Write(utf8BOM)
}
cw = csv.NewWriter(w)
if excel {
cw.Comma = ';'
}
zone := loc.String()
header := make([]string, len(exportColumns))
for i, c := range exportColumns {
header[i] = c.name
if c.field == "received" || c.field == "msg_time" {
header[i] += " (" + zone + ")"
}
}
_ = cw.Write(header)
}
rows := 0
record := make([]string, len(exportColumns))
err := a.store.QueryStream(r.Context(), query, func(row map[string]any) error {
if cw == nil {
start()
}
for i, c := range exportColumns {
v, _ := row[c.field].(string)
switch c.field {
case "received":
v = exportTime(v, loc)
case "msg_time":
if v == "" { // logs stored before msg_time existed
v, _ = row["_time"].(string)
}
v = exportTime(v, loc)
}
if excel {
v = excelSafe(v)
}
record[i] = v
}
if err := cw.Write(record); err != nil {
return err
}
rows++
if rows%1000 == 0 {
cw.Flush()
}
return cw.Error()
})
if err != nil {
if cw == nil {
writeErr(w, queryStatus(err), err)
return
}
// Headers are already sent: the file is truncated, log why.
log.Printf("export interrupted after %d rows: %v", rows, err)
}
if cw == nil {
start() // no matching log: header only
}
cw.Flush()
}
// exportTime converts an RFC 3339 timestamp to "2006-01-02 15:04:05.000" in loc,
// a format spreadsheets recognize as a date.
func exportTime(v string, loc *time.Location) string {
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
return v
}
return t.In(loc).Format("2006-01-02 15:04:05.000")
}
// excelSafe prevents spreadsheet formula injection: log messages come from the
// network, and a cell starting with = + - @ would be run as a formula.
func excelSafe(v string) string {
if v != "" && strings.ContainsRune("=+-@\t\r", rune(v[0])) {
return "'" + v
}
return v
}