- 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>
150 lines
3.9 KiB
Go
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
|
|
}
|