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>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-09-28 16:52:05 +02:00
1 parent aad0fb7c16
commit 085095c46f
12 files changed
+974 -10

No files matched your search

+4
View File
@@ -15,3 +15,7 @@ DNS_SERVER=
ALLOW_PURGE=true
# Maximum number of rows in a CSV export
EXPORT_MAX=100000
# Collect the logs of the Docker containers of this machine (on/off)
DOCKER_LOGS=on
# History read from a container seen for the first time (e.g. 30m, 1h, 24h; 0 = only new lines)
DOCKER_BACKFILL=1h
+32
View File
@@ -59,6 +59,34 @@ Severity badges `err`/`crit` and `warning` use the colors of the `error` and `wa
Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields.
## Docker container logs
Logstream also collects the logs of the Docker containers running on the machine where it is
installed (`DOCKER_LOGS=on`, the default in `docker-compose.yml`). They are searched, filtered,
colored and exported like syslog messages:
- **host** is the Docker host name, **app** the compose service (or the container name), and
each log also carries `container`, `container_id`, `image`, `compose_project`,
`compose_service` and `stream` (stdout/stderr), visible in the row details.
- The **Source** filter shows only syslog or only Docker logs; Docker rows have a small cube
before the app name.
- The severity comes from the line itself when the application writes it: JSON
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, or an upper-case level word at the
start of the line (`ERROR`, `WARN`…). Otherwise it is `info`. Terminal color codes are removed.
- **Settings > Sources** lists the containers grouped by compose project, with a switch for
each one, "Enable all" / "Disable all", and whether new containers are followed
automatically (on by default). Choices are saved per compose service (or container name)
in `/data/docker.json`, so they survive re-creations.
- Logstream remembers the position read in each container (`/data/docker-state.json`): after a
restart it resumes without losing or duplicating lines. A container seen for the first time
is read from `DOCKER_BACKFILL` ago (1 hour by default).
- Logstream itself and the proxy below are never collected; add the label
`logstream.exclude=true` to any other container to exclude it for good.
**Security**: access to the Docker socket is equivalent to root on the machine. Logstream
therefore goes through [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy),
which only lets through listing containers, reading logs, events and engine info (`GET` only).
## CSV export
The **Export** button (next to the log count) downloads every stored log matching the
@@ -136,6 +164,9 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
| `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export |
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
| `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) |
| `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time |
| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) |
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning |
@@ -163,6 +194,7 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `hub.go` | pushes new messages to browsers (SSE) |
| `rdns.go` | cached reverse DNS lookups |
| `export.go` | streamed CSV export |
| `docker.go` | Docker container logs (API, followers, positions, level detection) |
| `tags.go` | color tag storage |
| `api.go` | `/api/*` HTTP routes |
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) |
+33
View File
@@ -19,6 +19,7 @@ type API struct {
rdns *ReverseDNS
allowPurge bool
exportMax int
docker *DockerManager // nil when DOCKER_LOGS is off
}
func (a *API) Routes(mux *http.ServeMux) {
@@ -36,6 +37,38 @@ func (a *API) Routes(mux *http.ServeMux) {
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/docker", a.dockerStatus)
mux.HandleFunc("PUT /api/docker", a.dockerConfigure)
}
// 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) {
+24 -1
View File
@@ -5,6 +5,7 @@ services:
restart: unless-stopped
depends_on:
- victorialogs
- docker-proxy
ports:
- "${SYSLOG_PORT:-514}:5514/udp"
- "${SYSLOG_PORT:-514}:5514/tcp"
@@ -18,8 +19,13 @@ services:
DNS_SERVER: ${DNS_SERVER:-} # e.g. 192.168.1.1 to query your LAN DNS; empty = system resolver
ALLOW_PURGE: ${ALLOW_PURGE:-true}
EXPORT_MAX: ${EXPORT_MAX:-100000}
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collect the logs of this machine's containers
DOCKER_HOST: tcp://docker-proxy:2375 # read-only Docker API gateway (below)
DOCKER_BACKFILL: ${DOCKER_BACKFILL:-1h} # history read from a container seen for the first time
volumes:
- logstream-data:/data # tags.json
- logstream-data:/data # tags.json, docker.json (container choices)
labels:
logstream.exclude: "true" # never collect Logstream's own logs
victorialogs:
# Pin a specific version in production (see hub.docker.com/r/victoriametrics/victoria-logs/tags)
@@ -37,6 +43,23 @@ services:
# VictoriaLogs debug UI (http://localhost:9428/select/vmui), localhost only
- "127.0.0.1:9428:9428"
docker-proxy:
# Read-only gateway to the Docker API: Logstream can only list containers,
# read their logs, receive events and engine info. Anything else (start,
# stop, exec, images, volumes…) is refused.
image: tecnativa/docker-socket-proxy:latest
container_name: logstream-docker-proxy
restart: unless-stopped
environment:
CONTAINERS: 1
EVENTS: 1
INFO: 1
POST: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
logstream.exclude: "true" # its access log would only echo Logstream's own requests
volumes:
logstream-data:
vlogs-data:
+633
View File
@@ -0,0 +1,633 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
// DockerContainer is a container as listed in Settings > Sources.
type DockerContainer struct {
ID string `json:"id"`
Key string `json:"key"` // stable across re-creations: "project/service", or the name
Name string `json:"name"`
Image string `json:"image"`
Project string `json:"project,omitempty"`
Service string `json:"service,omitempty"`
State string `json:"state"` // running, exited, …
Enabled bool `json:"enabled"`
Locked bool `json:"locked"` // Logstream itself, or label logstream.exclude=true
Following bool `json:"following"`
}
// dockerConfig is saved in /data/docker.json and edited from the UI.
type dockerConfig struct {
DefaultEnabled bool `json:"defaultEnabled"`
Containers map[string]bool `json:"containers"` // key -> enabled, overrides the default
}
type follower struct{ cancel context.CancelFunc }
// DockerManager follows the logs of the containers of the local Docker engine
// (through the Docker API) and sends each line to the sink as an Entry.
type DockerManager struct {
client *http.Client
base string
sink func(*Entry)
backfill time.Duration
selfID string
cfgPath string
statePath string
syncCh chan struct{}
mu sync.Mutex
cfg dockerConfig
hostName string
containers []DockerContainer
followers map[string]*follower // container ID -> running follower
checkpoint map[string]time.Time // container ID -> timestamp of the last line read
dirty bool
connected bool
lastErr string
}
func NewDockerManager(host, dataDir string, backfill time.Duration, sink func(*Entry)) (*DockerManager, error) {
client, base, err := dockerHTTP(host)
if err != nil {
return nil, err
}
self, _ := os.Hostname() // in a container: the short container ID
m := &DockerManager{
client: client,
base: base,
sink: sink,
backfill: backfill,
selfID: self,
cfgPath: filepath.Join(dataDir, "docker.json"),
statePath: filepath.Join(dataDir, "docker-state.json"),
syncCh: make(chan struct{}, 1),
cfg: dockerConfig{DefaultEnabled: true, Containers: map[string]bool{}},
followers: map[string]*follower{},
checkpoint: map[string]time.Time{},
}
m.load()
return m, nil
}
// dockerHTTP returns an HTTP client for DOCKER_HOST: unix:///path, tcp://host:port or http(s)://…
func dockerHTTP(host string) (*http.Client, string, error) {
switch {
case strings.HasPrefix(host, "unix://"):
path := strings.TrimPrefix(host, "unix://")
tr := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", path)
}}
return &http.Client{Transport: tr}, "http://docker", nil
case strings.HasPrefix(host, "tcp://"):
return &http.Client{}, "http://" + strings.TrimPrefix(host, "tcp://"), nil
case strings.HasPrefix(host, "http://"), strings.HasPrefix(host, "https://"):
return &http.Client{}, strings.TrimRight(host, "/"), nil
}
return nil, "", fmt.Errorf("unsupported DOCKER_HOST %q", host)
}
func (m *DockerManager) load() {
if b, err := os.ReadFile(m.cfgPath); err == nil {
var cfg dockerConfig
if err := json.Unmarshal(b, &cfg); err == nil {
if cfg.Containers == nil {
cfg.Containers = map[string]bool{}
}
m.cfg = cfg
}
}
if b, err := os.ReadFile(m.statePath); err == nil {
var st map[string]string
if err := json.Unmarshal(b, &st); err == nil {
for id, s := range st {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
m.checkpoint[id] = t
}
}
}
}
}
func writeJSONFile(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// Run keeps the followed containers in line with the Docker engine and the
// configuration until ctx is cancelled.
func (m *DockerManager) Run(ctx context.Context) {
go m.watchEvents(ctx)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
save := time.NewTicker(5 * time.Second)
defer save.Stop()
m.sync(ctx)
for {
select {
case <-ctx.Done():
m.saveState()
return
case <-ticker.C:
m.sync(ctx)
case <-m.syncCh:
m.sync(ctx)
case <-save.C:
m.saveState()
}
}
}
func (m *DockerManager) requestSync() {
select {
case m.syncCh <- struct{}{}:
default:
}
}
func (m *DockerManager) get(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.base+path, nil)
if err != nil {
return nil, err
}
resp, err := m.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
return nil, fmt.Errorf("docker %s: HTTP %d %s", strings.SplitN(path, "?", 2)[0], resp.StatusCode, strings.TrimSpace(string(msg)))
}
return resp, nil
}
func (m *DockerManager) getJSON(ctx context.Context, path string, v any) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
resp, err := m.get(ctx, path)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(v)
}
func (m *DockerManager) setError(err error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.lastErr != err.Error() {
log.Printf("docker: %v", err)
}
m.connected = false
m.lastErr = err.Error()
}
// enabled tells whether a container's logs must be collected; the caller holds the lock.
func (m *DockerManager) enabled(c DockerContainer) bool {
if c.Locked {
return false
}
if v, ok := m.cfg.Containers[c.Key]; ok {
return v
}
return m.cfg.DefaultEnabled
}
// sync lists the containers and starts or stops their followers.
func (m *DockerManager) sync(ctx context.Context) {
m.mu.Lock()
needInfo := m.hostName == ""
m.mu.Unlock()
if needInfo {
var info struct{ Name string }
if err := m.getJSON(ctx, "/info", &info); err == nil && info.Name != "" {
m.mu.Lock()
m.hostName = info.Name
m.mu.Unlock()
}
}
var list []struct {
ID string `json:"Id"`
Names []string
Image string
State string
Labels map[string]string
}
if err := m.getJSON(ctx, "/containers/json?all=1", &list); err != nil {
if ctx.Err() == nil {
m.setError(err)
}
return
}
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
log.Printf("docker: connected, %d containers", len(list))
}
m.connected, m.lastErr = true, ""
seen := map[string]bool{}
cs := make([]DockerContainer, 0, len(list))
for _, c := range list {
dc := DockerContainer{ID: c.ID, Image: c.Image, State: c.State,
Project: c.Labels["com.docker.compose.project"], Service: c.Labels["com.docker.compose.service"]}
if len(c.Names) > 0 {
dc.Name = strings.TrimPrefix(c.Names[0], "/")
}
dc.Key = dc.Name
if dc.Project != "" && dc.Service != "" {
dc.Key = dc.Project + "/" + dc.Service
}
dc.Locked = c.Labels["logstream.exclude"] == "true" || (m.selfID != "" && strings.HasPrefix(c.ID, m.selfID))
dc.Enabled = m.enabled(dc)
seen[c.ID] = true
want := dc.Enabled && c.State == "running"
f, following := m.followers[c.ID]
switch {
case want && !following:
m.startFollower(ctx, dc)
case !want && following:
f.cancel()
delete(m.followers, c.ID)
}
cs = append(cs, dc)
}
for id, f := range m.followers {
if !seen[id] {
f.cancel()
delete(m.followers, id)
}
}
for id := range m.checkpoint {
if !seen[id] {
delete(m.checkpoint, id)
m.dirty = true
}
}
sort.Slice(cs, func(i, j int) bool {
if cs[i].Project != cs[j].Project {
return cs[i].Project < cs[j].Project
}
return cs[i].Name < cs[j].Name
})
m.containers = cs
}
// startFollower launches the log reader of a container; the caller holds the lock.
func (m *DockerManager) startFollower(parent context.Context, c DockerContainer) {
ctx, cancel := context.WithCancel(parent)
f := &follower{cancel: cancel}
m.followers[c.ID] = f
go func() {
defer func() {
cancel()
m.mu.Lock()
if m.followers[c.ID] == f {
delete(m.followers, c.ID)
}
m.mu.Unlock()
}()
m.follow(ctx, c)
}()
}
// follow reads the logs of a container while it runs, reconnecting when the
// stream is cut (proxy timeout, Docker restart) without losing or repeating lines.
func (m *DockerManager) follow(ctx context.Context, c DockerContainer) {
for {
var inspect struct {
Config struct{ Tty bool }
State struct{ Running bool }
}
if err := m.getJSON(ctx, "/containers/"+c.ID+"/json", &inspect); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("docker %s: %v", c.Name, err)
sleepCtx(ctx, 5*time.Second)
continue
}
if !inspect.State.Running {
return
}
err := m.stream(ctx, c, inspect.Config.Tty, m.since(c.ID))
if ctx.Err() != nil {
return
}
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
log.Printf("docker %s: %v", c.Name, err)
}
sleepCtx(ctx, time.Second)
}
}
func sleepCtx(ctx context.Context, d time.Duration) {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
}
// since returns where to resume reading: just after the last line read, or
// DOCKER_BACKFILL ago for a container seen for the first time.
func (m *DockerManager) since(id string) time.Time {
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.checkpoint[id]; ok {
return t.Add(time.Nanosecond)
}
return time.Now().Add(-m.backfill)
}
func (m *DockerManager) setCheckpoint(id string, t time.Time) {
m.mu.Lock()
if t.After(m.checkpoint[id]) {
m.checkpoint[id] = t
m.dirty = true
}
m.mu.Unlock()
}
func (m *DockerManager) saveState() {
m.mu.Lock()
if !m.dirty {
m.mu.Unlock()
return
}
st := make(map[string]string, len(m.checkpoint))
for id, t := range m.checkpoint {
st[id] = t.UTC().Format(time.RFC3339Nano)
}
m.dirty = false
m.mu.Unlock()
if err := writeJSONFile(m.statePath, st); err != nil {
log.Printf("docker: saving positions: %v", err)
}
}
func (m *DockerManager) stream(ctx context.Context, c DockerContainer, tty bool, since time.Time) error {
q := url.Values{
"follow": {"1"},
"stdout": {"1"},
"stderr": {"1"},
"timestamps": {"1"},
"since": {fmt.Sprintf("%d.%09d", since.Unix(), since.Nanosecond())},
}
resp, err := m.get(ctx, "/containers/"+c.ID+"/logs?"+q.Encode())
if err != nil {
return err
}
defer resp.Body.Close()
emit := func(stream string, line []byte) { m.emit(c, stream, line) }
if tty {
return readLines(resp.Body, "stdout", emit)
}
return readMultiplexed(resp.Body, emit)
}
// readLines reads a TTY container stream: plain lines.
func readLines(r io.Reader, stream string, emit func(string, []byte)) error {
br := bufio.NewReaderSize(r, 64*1024)
for {
line, err := br.ReadSlice('\n')
if len(line) > 0 {
emit(stream, line)
}
if err == bufio.ErrBufferFull {
continue
}
if err != nil {
return err
}
}
}
// readMultiplexed reads a non-TTY container stream: frames with an 8-byte
// header (stream type, 3 zero bytes, big-endian payload size).
func readMultiplexed(r io.Reader, emit func(string, []byte)) error {
br := bufio.NewReaderSize(r, 64*1024)
hdr := make([]byte, 8)
names := [2]string{"stdout", "stderr"}
var partial [2][]byte
for {
if _, err := io.ReadFull(br, hdr); err != nil {
return err
}
size := int(binary.BigEndian.Uint32(hdr[4:8]))
idx := 0
if hdr[0] == 2 {
idx = 1
}
if size > maxFrame {
if _, err := io.CopyN(io.Discard, br, int64(size)); err != nil {
return err
}
continue
}
buf := make([]byte, size)
if _, err := io.ReadFull(br, buf); err != nil {
return err
}
data := append(partial[idx], buf...)
for {
i := bytes.IndexByte(data, '\n')
if i < 0 {
break
}
emit(names[idx], data[:i])
data = data[i+1:]
}
if len(data) > maxFrame {
emit(names[idx], data)
data = nil
}
partial[idx] = append([]byte(nil), data...)
}
}
// Terminal color and cursor codes, frequent in container output.
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
func (m *DockerManager) emit(c DockerContainer, stream string, line []byte) {
now := time.Now()
ts := now
s := strings.TrimRight(string(line), "\r\n")
// With timestamps=1, Docker prefixes each line with its RFC 3339 timestamp.
if sp := strings.IndexByte(s, ' '); sp > 0 {
if t, err := time.Parse(time.RFC3339Nano, s[:sp]); err == nil {
ts, s = t, s[sp+1:]
}
} else if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
ts, s = t, ""
}
defer m.setCheckpoint(c.ID, ts)
s = ansiRe.ReplaceAllString(s, "")
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, string(utf8.RuneError))
}
if strings.TrimSpace(s) == "" {
return
}
sev := 6 // info
if n, ok := detectLevel(s); ok {
sev = n
}
app := c.Service
if app == "" {
app = c.Name
}
m.mu.Lock()
host := m.hostName
m.mu.Unlock()
if host == "" {
host = "docker"
}
short := c.ID
if len(short) > 12 {
short = short[:12]
}
m.sink(&Entry{
Time: ts,
Received: now,
Host: host,
App: app,
SevNum: sev,
Severity: severityNames[sev],
Message: s,
Proto: "docker",
SourceType: "docker",
Extra: map[string]string{
"container": c.Name,
"container_id": short,
"image": c.Image,
"compose_project": c.Project,
"compose_service": c.Service,
"stream": stream,
},
})
}
// Log level written by the application: JSON ("level":"error"), logfmt
// (level=warn), [ERROR], or an upper-case level word near the start.
var (
levelJSON = regexp.MustCompile(`(?i)"(?:level|lvl|severity|log\.level)"\s*:\s*"([a-z]+)"`)
levelLogfmt = regexp.MustCompile(`(?i)\b(?:level|lvl|severity)="?([a-z]+)`)
levelBracket = regexp.MustCompile(`(?i)\[\s*(emerg|alert|crit|critical|fatal|panic|err|error|warn|warning|notice|info|debug|trace)\s*\]`)
levelWord = regexp.MustCompile(`\b(EMERG|ALERT|CRIT|CRITICAL|FATAL|PANIC|ERR|ERROR|WARN|WARNING|NOTICE|INFO|DEBUG|TRACE)\b`)
)
func levelNum(word string) (int, bool) {
switch strings.ToLower(word) {
case "emerg", "emergency":
return 0, true
case "alert":
return 1, true
case "crit", "critical", "fatal", "panic":
return 2, true
case "err", "error":
return 3, true
case "warn", "warning":
return 4, true
case "notice":
return 5, true
case "info", "information":
return 6, true
case "debug", "trace":
return 7, true
}
return 0, false
}
func detectLevel(s string) (int, bool) {
for _, re := range []*regexp.Regexp{levelJSON, levelLogfmt, levelBracket} {
if m := re.FindStringSubmatch(s); m != nil {
if n, ok := levelNum(m[1]); ok {
return n, true
}
}
}
head := s
if len(head) > 60 {
head = head[:60]
}
if m := levelWord.FindStringSubmatch(head); m != nil {
return levelNum(m[1])
}
return 0, false
}
// Status is the state shown in Settings > Sources.
func (m *DockerManager) Status() map[string]any {
m.mu.Lock()
defer m.mu.Unlock()
cs := make([]DockerContainer, len(m.containers))
copy(cs, m.containers)
for i := range cs {
cs[i].Enabled = m.enabled(cs[i])
_, cs[i].Following = m.followers[cs[i].ID]
}
return map[string]any{
"enabled": true,
"connected": m.connected,
"error": m.lastErr,
"host": m.hostName,
"defaultEnabled": m.cfg.DefaultEnabled,
"containers": cs,
}
}
// Configure changes the default and per-container choices, then applies them.
func (m *DockerManager) Configure(defaultEnabled *bool, containers map[string]bool) error {
m.mu.Lock()
if defaultEnabled != nil {
m.cfg.DefaultEnabled = *defaultEnabled
}
for k, v := range containers {
m.cfg.Containers[k] = v
}
cfg := dockerConfig{DefaultEnabled: m.cfg.DefaultEnabled, Containers: map[string]bool{}}
for k, v := range m.cfg.Containers {
cfg.Containers[k] = v
}
m.mu.Unlock()
m.requestSync()
return writeJSONFile(m.cfgPath, cfg)
}
+3
View File
@@ -22,6 +22,9 @@ var exportColumns = []struct{ field, name string }{
{"procid", "pid"},
{"source", "source_ip"},
{"proto", "proto"},
{"source_type", "source_type"},
{"container", "container"},
{"image", "image"},
{"_msg", "message"},
}
+23
View File
@@ -35,6 +35,9 @@ type config struct {
dnsServer string
allowPurge bool
exportMax int
dockerLogs bool
dockerHost string
backfill time.Duration
batchSize int
queueSize int
flushEvery time.Duration
@@ -64,6 +67,13 @@ func getenvBool(key string, def bool) bool {
return def
}
func getenvDuration(key string, def time.Duration) time.Duration {
if d, err := time.ParseDuration(os.Getenv(key)); err == nil && d >= 0 {
return d
}
return def
}
func main() {
cfg := config{
syslogAddr: getenv("SYSLOG_ADDR", ":5514"),
@@ -76,6 +86,9 @@ func main() {
dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true),
exportMax: getenvInt("EXPORT_MAX", 100000),
dockerLogs: getenvBool("DOCKER_LOGS", false),
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
backfill: getenvDuration("DOCKER_BACKFILL", time.Hour),
batchSize: getenvInt("BATCH_SIZE", 1000),
queueSize: getenvInt("QUEUE_SIZE", 100000),
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
@@ -117,6 +130,16 @@ func main() {
}
mux := http.NewServeMux()
api := &API{store: store, hub: hub, tags: tags, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax}
if cfg.dockerLogs {
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
if err != nil {
log.Printf("docker logs disabled: %v", err)
} else {
go dm.Run(ctx)
api.docker = dm
log.Printf("docker logs enabled through %s", cfg.dockerHost)
}
}
api.Routes(mux)
mux.Handle("GET /", http.FileServer(http.FS(static)))
+12 -1
View File
@@ -14,7 +14,8 @@ type Filter struct {
Range string // 5m, 15m, 1h, 6h, 24h, 7d, 30d; anything else = no time limit
Host string
App string
Severity int // highest severity number included (0 = emerg … 7 = debug), -1 = all
Severity int // highest severity number included (0 = emerg … 7 = debug), -1 = all
Source string // "syslog", "docker" or "" for all
}
var validRanges = map[string]bool{"5m": true, "15m": true, "1h": true, "6h": true, "24h": true, "7d": true, "30d": true}
@@ -27,6 +28,7 @@ func FilterFromRequest(r *http.Request) Filter {
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 {
@@ -90,6 +92,12 @@ func (f Filter) filterExpr() string {
if f.App != "" {
parts = append(parts, "app:="+strconv.Quote(f.App))
}
switch f.Source {
case "docker":
parts = append(parts, `source_type:="docker"`)
case "syslog": // also matches logs stored before source_type existed
parts = append(parts, `!(source_type:="docker")`)
}
if f.Severity >= 0 && f.Severity < 7 {
names := make([]string, 0, 8)
for i := 0; i <= f.Severity; i++ {
@@ -173,6 +181,9 @@ func (m *Matcher) Match(e *Entry) bool {
if m.f.App != "" && e.App != m.f.App {
return false
}
if (m.f.Source == "docker") != (e.SourceType == "docker") && m.f.Source != "" {
return false
}
if m.f.Severity >= 0 && e.SevNum > m.f.Severity {
return false
}
+12 -1
View File
@@ -35,6 +35,9 @@ type Entry struct {
Source string // sender IP address
Proto string // udp or tcp
HostIP string // original host value when it was an IP resolved through DNS
SourceType string // "syslog" or "docker"
Extra map[string]string // additional fields (docker: container, image, …)
}
// Record returns the entry in the shape stored in VictoriaLogs and returned by the API.
@@ -65,6 +68,14 @@ func (e *Entry) Record() map[string]string {
if e.HostIP != "" {
r["host_ip"] = e.HostIP
}
if e.SourceType != "" {
r["source_type"] = e.SourceType
}
for k, v := range e.Extra {
if v != "" {
r[k] = v
}
}
return r
}
@@ -81,7 +92,7 @@ func ParseSyslog(raw []byte, source, proto string, now time.Time) *Entry {
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, "\uFFFD")
}
e := &Entry{Time: now, Received: now, Host: source, Source: source, Proto: proto}
e := &Entry{Time: now, Received: now, Host: source, Source: source, Proto: proto, SourceType: "syslog"}
e.setPri(13) // user.notice, the RFC 3164 default
if len(s) > 2 && s[0] == '<' {
+123 -6
View File
@@ -66,6 +66,19 @@ const I18N = {
msgTimeTitle: 'Timestamp from the message',
fHostName: 'host name (DNS)', fHostIp: 'host IP',
clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app',
sourceAria: 'Source', srcAll: 'All sources', tabSources: 'Sources',
dockerTitle: 'Docker containers',
dockerDefault: 'Follow new containers automatically',
dockerAll: 'Enable all', dockerNone: 'Disable all',
dockerOff: 'The Docker source is disabled on this server. Set DOCKER_LOGS=on (see docker-compose.yml).',
dockerErr: 'Cannot reach Docker: ',
dockerOk: ({ h, n, f }) => `Connected to Docker on ${h}: ${n} containers, ${f} followed.`,
dockerStandalone: 'Standalone containers',
stFollowing: 'followed', stIgnored: 'running, ignored', stStopped: 'stopped',
stLocked: 'excluded', stLockedTitle: 'Logstream itself, or label logstream.exclude=true',
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: Logstream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
export: 'Export', exportCsvHint: 'Comma separated, UTF-8',
exportExcel: 'CSV for Excel', exportExcelHint: 'Semicolon separated, for Excel in French',
exportNote: (n) => `Up to ${n} logs matching the current filters, newest first. Dates use the time zone chosen in Settings.`,
@@ -154,6 +167,19 @@ const I18N = {
msgTimeTitle: 'Horodatage contenu dans le message',
fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte',
clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli',
sourceAria: 'Source', srcAll: 'Toutes les sources', tabSources: 'Sources',
dockerTitle: 'Conteneurs Docker',
dockerDefault: 'Suivre automatiquement les nouveaux conteneurs',
dockerAll: 'Tout activer', dockerNone: 'Tout désactiver',
dockerOff: 'La source Docker est désactivée sur ce serveur. Réglez DOCKER_LOGS=on (voir docker-compose.yml).',
dockerErr: 'Docker injoignable : ',
dockerOk: ({ h, n, f }) => `Connecté à Docker sur ${h} : ${n} conteneurs, ${f} suivis.`,
dockerStandalone: 'Conteneurs isolés',
stFollowing: 'suivi', stIgnored: 'actif, ignoré', stStopped: 'arrêté',
stLocked: 'exclu', stLockedTitle: 'Logstream lui-même, ou étiquette logstream.exclude=true',
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : Logstream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
export: 'Exporter', exportCsvHint: 'Séparateur virgule, UTF-8',
exportExcel: 'CSV pour Excel', exportExcelHint: 'Séparateur point-virgule, pour Excel en français',
exportNote: (n) => `Jusqu'à ${n} logs correspondant aux filtres, du plus récent au plus ancien. Dates dans le fuseau choisi dans Paramètres.`,
@@ -362,6 +388,7 @@ function setLang(next) {
renderTimeSettings();
renderPurge();
renderInterface();
renderDocker();
}
$('#langSwitch').addEventListener('click', (ev) => {
@@ -498,7 +525,7 @@ function params() {
if (q) p.set('q', q);
p.set('mode', state.mode);
p.set('range', $('#range').value);
for (const id of ['severity', 'host', 'app']) {
for (const id of ['severity', 'host', 'app', 'source']) {
const v = $('#' + id).value;
if (v !== '') p.set(id, v);
}
@@ -518,7 +545,7 @@ function syncControls() {
btn.disabled = !liveAvailable();
btn.classList.toggle('on', state.live && liveAvailable());
btn.title = liveAvailable() ? t('liveTitle') : t('liveUnavailable');
for (const id of ['severity', 'host', 'app']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
for (const id of ['severity', 'host', 'app', 'source']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
}
/* ================= Tags and highlighting ================= */
@@ -618,12 +645,14 @@ function rowHTML(r, isNew) {
? `<span class="host" data-act="host" title="${esc(hostTitle(r) + ' · ' + t('clickHost'))}">${esc(r.host_name || r.host)}</span>`
: '<span class="host"></span>')
+ (r.app
? `<span class="app" data-act="app" title="${esc(r.app + ' · ' + t('clickApp'))}">${esc(r.app)}</span>`
? `<span class="app" data-act="app" title="${esc((r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
: '<span class="app"></span>')
+ `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>';
}
const DOCKER_ICON = '<svg class="src-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5M12 13v8"/></svg>';
// Tooltip of the host cell: name and IP when both are known.
function hostTitle(r) {
const ip = r.host_ip || (r.host_name ? r.host : '');
@@ -700,12 +729,15 @@ function updateCount(tookMs) {
}
function detailsHTML(r) {
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'source_type', 'container', 'image', 'compose_project', 'compose_service', 'stream', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const hidden = new Set(['_stream_id', '_stream', 'sevnum']);
if (r.msg_time) hidden.add('_time'); // same as the reception time
const keys = order.filter((k) => r[k] != null && r[k] !== '' && !hidden.has(k))
.concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort());
const label = { msg_time: t('fTime'), host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const label = {
container: t('fContainer'), container_id: t('fContainerId'), image: t('fImage'), compose_project: t('fProject'),
compose_service: t('fService'), stream: t('fStream'), source_type: t('fSourceType'),
msg_time: t('fTime'), host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const val = (k) => (k === '_time' || k === 'received' || k === 'msg_time'
? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>`
: esc(r[k]));
@@ -847,7 +879,7 @@ $('#q').addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') refresh();
if (ev.key === 'Escape') { ev.target.value = ''; refresh(); }
});
for (const id of ['range', 'severity', 'host', 'app']) $('#' + id).addEventListener('change', onFilterChange);
for (const id of ['range', 'severity', 'host', 'app', 'source']) $('#' + id).addEventListener('change', onFilterChange);
$('#mode').addEventListener('click', () => {
state.mode = state.mode === 'simple' ? 'logsql' : 'simple';
@@ -1048,6 +1080,90 @@ $('#exportMenu').addEventListener('click', (ev) => {
document.addEventListener('click', (ev) => { if (!ev.target.closest('.export')) setExportMenu(false); });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExportMenu(false); });
/* ================= Docker source ================= */
let dockerState = null;
async function loadDocker() {
try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; }
renderDocker();
}
function renderDocker() {
const status = $('#dockerStatus');
const body = $('#dockerBody');
const d = dockerState;
if (!d) { status.textContent = ''; body.hidden = true; return; }
if (!d.enabled) {
status.textContent = t('dockerOff');
status.className = 'docker-status muted';
body.hidden = true;
return;
}
const cs = d.containers || [];
if (!d.connected) {
status.textContent = t('dockerErr') + (d.error || '');
status.className = 'docker-status bad';
} else {
status.textContent = t('dockerOk', { h: d.host || 'docker', n: cs.length, f: cs.filter((c) => c.following).length });
status.className = 'docker-status good';
}
body.hidden = !cs.length && !d.connected;
$('#dockerDefault').checked = !!d.defaultEnabled;
// Group by compose project; standalone containers last.
const groups = new Map();
for (const c of cs) {
const g = c.project || '';
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(c);
}
const names = [...groups.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b));
let html = '';
for (const g of names) {
html += `<div class="dk-group"><div class="dk-group-title">${esc(g || t('dockerStandalone'))}</div>`;
for (const c of groups.get(g)) {
let st = 'stopped';
let label = t('stStopped');
if (c.locked) { st = 'locked'; label = t('stLocked'); }
else if (c.following) { st = 'following'; label = t('stFollowing'); }
else if (c.state === 'running') { st = 'ignored'; label = t('stIgnored'); }
html += `<label class="dk-row${c.locked ? ' locked' : ''}">
<input type="checkbox" class="switch" data-key="${esc(c.key)}"${c.enabled ? ' checked' : ''}${c.locked ? ' disabled' : ''}>
<span class="dk-main"><strong>${esc(c.service || c.name)}</strong><small>${esc(c.name)} · ${esc(c.image)}</small></span>
<span class="dk-state ${st}"${c.locked ? ` title="${esc(t('stLockedTitle'))}"` : ''}>${esc(label)}</span>
</label>`;
}
html += '</div>';
}
$('#dockerList').innerHTML = html;
}
async function configureDocker(body) {
try {
dockerState = await api('/api/docker', { method: 'PUT', body });
renderDocker();
setTimeout(loadDocker, 1500); // followers start or stop in the background
} catch (e) { toast(e.message); }
}
$('#dockerDefault').addEventListener('change', (ev) => configureDocker({ defaultEnabled: ev.target.checked }));
$('#dockerList').addEventListener('change', (ev) => {
const key = ev.target.dataset.key;
if (key) configureDocker({ containers: { [key]: ev.target.checked } });
});
for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) {
$(id).addEventListener('click', () => {
const containers = {};
for (const c of (dockerState && dockerState.containers) || []) if (!c.locked) containers[c.key] = on;
configureDocker({ containers });
});
}
// Keep the list fresh while the Sources tab is open.
setInterval(() => {
if ($('#settingsDlg').open && !document.querySelector('[data-panel="sources"]').hidden) loadDocker();
}, 4000);
/* ================= Purge ================= */
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
@@ -1207,6 +1323,7 @@ $('#settingsBtn').addEventListener('click', () => {
renderTagList();
renderInterface();
loadPurgeStatus();
loadDocker();
showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal();
});
+29
View File
@@ -66,6 +66,11 @@
</select>
<select id="host" data-i18n-aria="hostAria"><option value="" data-i18n="allHosts">All hosts</option></select>
<select id="app" data-i18n-aria="appAria"><option value="" data-i18n="allApps">All apps</option></select>
<select id="source" data-i18n-aria="sourceAria">
<option value="" data-i18n="srcAll">All sources</option>
<option value="syslog">Syslog</option>
<option value="docker">Docker</option>
</select>
<span class="spacer"></span>
<span id="count" class="muted"></span>
<div class="export">
@@ -121,6 +126,10 @@
<svg viewBox="0 0 24 24"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></svg>
<span data-i18n="tabFilters">Filters</span>
</button>
<button type="button" role="tab" data-tab="sources">
<svg viewBox="0 0 24 24"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5M12 13v8"/></svg>
<span data-i18n="tabSources">Sources</span>
</button>
<button type="button" role="tab" data-tab="interface">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
<span data-i18n="tabInterface">Interface</span>
@@ -167,6 +176,26 @@
</section>
</div>
<!-- Sources -->
<div class="set-panel" role="tabpanel" data-panel="sources" hidden>
<section class="set-section">
<h3 data-i18n="dockerTitle">Docker containers</h3>
<p id="dockerStatus" class="docker-status"></p>
<div id="dockerBody" hidden>
<label class="switch-row">
<input id="dockerDefault" type="checkbox" class="switch">
<span data-i18n="dockerDefault">Follow new containers automatically</span>
</label>
<div class="docker-actions">
<button id="dockerAll" class="btn" type="button" data-i18n="dockerAll">Enable all</button>
<button id="dockerNone" class="btn ghost" type="button" data-i18n="dockerNone">Disable all</button>
</div>
<div id="dockerList" class="docker-list"></div>
</div>
<p class="muted small" data-i18n="dockerHelp"></p>
</section>
</div>
<!-- Interface -->
<div class="set-panel" role="tabpanel" data-panel="interface" hidden>
<section class="set-section">
+46 -1
View File
@@ -218,6 +218,9 @@ body.busy .progress::after {
.menu small { font-size: 12px; color: var(--muted); }
.menu-note { margin: 4px 0 0; padding: 8px 10px 4px; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--muted); }
/* Docker logs */
.src-ico { width: 1em; height: 1em; vertical-align: -.15em; margin-right: 4px; color: #2496ed; }
/* ---------- Histogram ---------- */
.histo { padding: 0 20px 6px; }
.histo .bars {
@@ -411,6 +414,48 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
.purge-status { font-size: 12.5px; }
.purge-status.bad { color: var(--sev-err); }
.purge-status.good { color: var(--sev-info); }
.docker-status { font-size: 13px; margin: 0 0 12px !important; }
.docker-status.good::before, .docker-status.bad::before { content: "●"; margin-right: 6px; }
.docker-status.good::before { color: var(--sev-info); }
.docker-status.bad { color: var(--sev-err); }
.switch-row { display: flex; align-items: center; gap: 10px; font-size: 13.5px; font-weight: 550; cursor: pointer; }
.docker-actions { display: flex; gap: 8px; margin: 12px 0; }
.docker-list { display: flex; flex-direction: column; gap: 14px; margin-bottom: 12px; }
.dk-group { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.dk-group-title {
padding: 7px 12px; background: var(--panel-2);
font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
}
.dk-row {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px;
padding: 8px 12px; border-top: 1px solid var(--border); cursor: pointer;
}
.dk-row:hover { background: var(--row-hover); }
.dk-row.locked { cursor: default; opacity: .65; }
.dk-main { display: flex; flex-direction: column; min-width: 0; }
.dk-main strong { font-size: 13.5px; font-weight: 600; }
.dk-main small { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dk-state { font-size: 12px; white-space: nowrap; color: var(--muted); }
.dk-state::before { content: "●"; margin-right: 5px; }
.dk-state.following { color: var(--sev-info); }
.dk-state.ignored::before { color: var(--sev-warning); }
.dk-state.stopped::before, .dk-state.locked::before { opacity: .4; }
/* Toggle switch */
input.switch {
appearance: none; -webkit-appearance: none; flex: none;
width: 34px; height: 20px; margin: 0; border-radius: 999px;
background: var(--border); position: relative; cursor: pointer; transition: background .15s;
}
input.switch::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .15s;
}
input.switch:checked { background: var(--accent); }
input.switch:checked::after { transform: translateX(14px); }
input.switch:disabled { cursor: not-allowed; }
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
@@ -483,7 +528,7 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.set-nav {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 6px;
display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px;
border-right: 0; border-bottom: 1px solid var(--border);
}
.set-nav button {