From 8200c2bc87f40f8980cf456a2c898a73aa62b3c3 Mon Sep 17 00:00:00 2001 From: Cedric Date: Mon, 28 Sep 2026 12:19:29 +0200 Subject: [PATCH] First commit --- .dockerignore | 4 + .env.example | 9 + Dockerfile | 19 ++ README.md | 102 +++++++ api.go | 266 ++++++++++++++++++ docker-compose.yml | 37 +++ go.mod | 3 + hub.go | 45 +++ main.go | 140 ++++++++++ query.go | 186 +++++++++++++ store.go | 182 +++++++++++++ syslog.go | 330 ++++++++++++++++++++++ tags.go | 151 +++++++++++ tools/send-test-logs.sh | 47 ++++ web/app.js | 586 ++++++++++++++++++++++++++++++++++++++++ web/index.html | 105 +++++++ web/style.css | 380 ++++++++++++++++++++++++++ 17 files changed, 2592 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 api.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 hub.go create mode 100644 main.go create mode 100644 query.go create mode 100644 store.go create mode 100644 syslog.go create mode 100644 tags.go create mode 100755 tools/send-test-logs.sh create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..492ca9b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.env +.git +tools +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0be8ed --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copiez ce fichier en .env et adaptez-le. +SYSLOG_PORT=514 +HTTP_PORT=8080 +TZ=Europe/Paris +# Durée de conservation des logs (ex. 7d, 30d, 12w, 1y) +RETENTION=30d +# Authentification de l'interface web (vide = désactivée) +AUTH_USER= +AUTH_PASS= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cd9d66 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# ---- Compilation ---- +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +COPY web ./web +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logwell . + +# ---- Image finale (~15 Mo) ---- +FROM alpine:3.20 +RUN adduser -D -H -u 10001 logwell \ + && mkdir /data && chown logwell /data +COPY --from=build /out/logwell /usr/local/bin/logwell +USER logwell +VOLUME /data +# 5514 en interne (port non privilégié), publié sur 514 par docker-compose +EXPOSE 5514/udp 5514/tcp 8080/tcp +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/usr/local/bin/logwell"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..85156f8 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Logwell + +Un puits de logs syslog simple : réception UDP/TCP sur le port 514, stockage dans +[VictoriaLogs](https://docs.victoriametrics.com/victorialogs/), et une interface web +claire (thème clair/sombre, recherche dynamique, direct, tags de couleur). + +``` +équipements ──514 udp/tcp──▶ logwell (Go) ──lots HTTP──▶ VictoriaLogs + ▲ └── SSE (direct) ──▶ navigateur + └──── API / interface ◀──┘ +``` + +## Démarrage + +```bash +cp .env.example .env # optionnel +docker compose up -d --build +./tools/send-test-logs.sh # envoie 100 messages de test +``` + +Puis ouvrez . + +## Envoyer ses logs + +- **rsyslog** (Linux) : ajoutez `*.* @IP_DU_SERVEUR:514` (UDP) ou `*.* @@IP_DU_SERVEUR:514` (TCP) + dans `/etc/rsyslog.d/90-logwell.conf`, puis exécutez `systemctl restart rsyslog`. +- **Équipements réseau, NAS, pare-feu** : renseignez l'IP du serveur et le port 514 dans la + configuration « syslog distant ». +- **Test manuel** : `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) ou + `echo "<14>test ok" | nc -u -w1 127.0.0.1 514`. + +Formats acceptés : RFC 3164 (BSD) et RFC 5424. En TCP, les découpages « une ligne par message » +et « octet counting » (RFC 6587) sont tous deux acceptés. + +## Recherche + +**Mode simple** (par défaut) : chaque mot est recherché comme une sous-chaîne, sans tenir +compte de la casse, dans le message, l'hôte et l'appli. Les mots se combinent avec un ET. + +| Saisie | Effet | +|---|---| +| `error disk` | contient « error » **et** « disk » | +| `"disk full"` | contient l'expression exacte | +| `error -timeout` | contient « error » mais pas « timeout » | + +**Mode LogsQL** (bouton `Simple` / `LogsQL`) : syntaxe complète de VictoriaLogs, par exemple +`error AND host:web-01`, `app:~"ssh|nginx"`, ou `* | stats by (host) count()`. +Le direct est désactivé dans ce mode. + +Raccourcis : `/` place le curseur dans la recherche, `Échap` la vide. Un clic sur une ligne +affiche tous ses champs. + +## Tags de couleur + +L'icône d'étiquette ouvre l'éditeur. Chaque tag a un mot-clé, une couleur de fond (le texte +passe automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, +respect de la casse, expression régulière, actif. Les tags sont enregistrés dans +`/data/tags.json` (volume `logwell-data`). Tags par défaut : `warning` (orange), +`error` (rouge), `ok` (vert). + +## Configuration + +| Variable | Défaut | Rôle | +|---|---|---| +| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte | +| `HTTP_PORT` | `8080` | port de l'interface web | +| `RETENTION` | `30d` | durée de conservation dans VictoriaLogs | +| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic de l'interface | +| `TZ` | `Europe/Paris` | fuseau utilisé pour les horodatages RFC 3164 (sans fuseau) | +| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | réglages de l'ingestion | + +## Débogage + +- `docker compose logs -f logwell` : erreurs de réception et d'envoi vers VictoriaLogs. +- La barre du bas affiche les compteurs reçus / stockés / perdus et la dernière erreur de stockage. +- : interface native de VictoriaLogs pour tester des requêtes LogsQL. +- API : + ```bash + curl 'localhost:8080/api/logs?q=error&range=1h&limit=5' # la réponse inclut la requête LogsQL générée + curl localhost:8080/api/stats + curl localhost:8080/api/tags + ``` +- Exécution hors Docker : `VLOGS_URL=http://localhost:9428 DATA_DIR=./data SYSLOG_ADDR=:5514 go run .` + +## Structure du code + +| Fichier | Contenu | +|---|---| +| `main.go` | configuration, démarrage, authentification | +| `syslog.go` | écoute UDP/TCP et décodage RFC 3164 / 5424 | +| `store.go` | envoi par lots à VictoriaLogs et requêtes LogsQL | +| `query.go` | traduction des filtres de l'interface en LogsQL, filtre du direct | +| `hub.go` | diffusion des nouveaux messages aux navigateurs (SSE) | +| `tags.go` | stockage des tags de couleur | +| `api.go` | routes HTTP `/api/*` | +| `web/` | interface (HTML, CSS, JavaScript sans framework ni build), embarquée dans le binaire | + +## Remarque + +Sous Docker Desktop (macOS/Windows), l'IP source vue par le conteneur pour les paquets UDP +est celle de la passerelle Docker. Le champ `host` provient toutefois de l'en-tête syslog, +qui contient normalement le vrai nom de l'émetteur. diff --git a/api.go b/api.go new file mode 100644 index 0000000..2d21705 --- /dev/null +++ b/api.go @@ -0,0 +1,266 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "time" +) + +type API struct { + store *Store + hub *Hub + tags *TagStore +} + +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/tags", a.listTags) + mux.HandleFunc("POST /api/tags", a.createTag) + mux.HandleFunc("POST /api/tags/reset", a.resetTags) + mux.HandleFunc("PUT /api/tags/{id}", a.updateTag) + mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag) +} + +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) +} + +func writeErr(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +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, http.StatusBadGateway, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "query": q, + "rows": rows, + "took_ms": time.Since(start).Milliseconds(), + }) +} + +var histSteps = map[string]struct { + expr string + sec int +}{ + "5m": {"10s", 10}, "15m": {"30s", 30}, "1h": {"1m", 60}, "6h": {"5m", 300}, + "24h": {"15m", 900}, "7d": {"2h", 7200}, "30d": {"12h", 43200}, +} + +// GET /api/histogram : nombre de messages par intervalle de temps. +func (a *API) histogram(w http.ResponseWriter, r *http.Request) { + f := FilterFromRequest(r) + step, ok := histSteps[f.Range] + if !ok { + step.expr, step.sec = "1d", 86400 + } + filter, _ := f.LogsQL() + q := filter + " | stats by (_time:" + step.expr + ") count() hits | sort by (_time)" + rows, err := a.store.Query(r.Context(), q) + if err != nil { + writeErr(w, http.StatusBadGateway, err) + return + } + type bucket struct { + T string `json:"t"` + N int64 `json:"n"` + } + out := make([]bucket, 0, len(rows)) + for _, row := range rows { + t, _ := row["_time"].(string) + out = append(out, bucket{T: t, N: toInt(row["hits"])}) + } + writeJSON(w, http.StatusOK, map[string]any{"step": step.sec, "buckets": out}) +} + +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 +} + +// GET /api/facets : hôtes et applis vus sur 7 jours, pour les listes de filtres. +func (a *API) facets(w http.ResponseWriter, r *http.Request) { + res := map[string][]string{} + 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, http.StatusBadGateway, err) + return + } + vals := make([]string, 0, len(rows)) + for _, row := range rows { + if v, _ := row[field].(string); v != "" { + vals = append(vals, v) + } + } + sort.Strings(vals) + res[field] = vals + } + writeJSON(w, http.StatusOK, res) +} + +// GET /api/stream : flux SSE des nouveaux messages correspondant au filtre, +// envoyés par paquets toutes les 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, errors.New("le direct n'est pas disponible en mode LogsQL")) + 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, ": connecté\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 { + // Commentaire SSE toutes les 15 s pour garder la connexion ouverte. + 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() + writeJSON(w, http.StatusOK, s) +} + +func (a *API) listTags(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, a.tags.List()) +} + +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) + } +} + +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) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5cbae23 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + logwell: + build: . + container_name: logwell + restart: unless-stopped + depends_on: + - victorialogs + ports: + - "${SYSLOG_PORT:-514}:5514/udp" + - "${SYSLOG_PORT:-514}:5514/tcp" + - "${HTTP_PORT:-8080}:8080" + environment: + VLOGS_URL: http://victorialogs:9428 + TZ: ${TZ:-Europe/Paris} + AUTH_USER: ${AUTH_USER:-} # laisser vide pour désactiver l'authentification + AUTH_PASS: ${AUTH_PASS:-} + volumes: + - logwell-data:/data # tags.json + + victorialogs: + # Épinglez une version précise en production (voir hub.docker.com/r/victoriametrics/victoria-logs/tags) + image: victoriametrics/victoria-logs:latest + container_name: logwell-victorialogs + restart: unless-stopped + command: + - -storageDataPath=/vlogs + - -retentionPeriod=${RETENTION:-30d} + - -httpListenAddr=:9428 + volumes: + - vlogs-data:/vlogs + ports: + # Interface de debug VictoriaLogs (http://localhost:9428/select/vmui), locale uniquement + - "127.0.0.1:9428:9428" + +volumes: + logwell-data: + vlogs-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7403f0c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module logwell + +go 1.23 diff --git a/hub.go b/hub.go new file mode 100644 index 0000000..f3ea4d0 --- /dev/null +++ b/hub.go @@ -0,0 +1,45 @@ +package main + +import "sync" + +// Hub diffuse les messages reçus aux navigateurs connectés en direct (SSE). +type Hub struct { + mu sync.RWMutex + subs map[chan *Entry]struct{} +} + +func NewHub() *Hub { + return &Hub{subs: make(map[chan *Entry]struct{})} +} + +func (h *Hub) Subscribe() chan *Entry { + ch := make(chan *Entry, 1024) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *Hub) Unsubscribe(ch chan *Entry) { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() +} + +// Publish ne bloque jamais : un client trop lent perd simplement des messages. +func (h *Hub) Publish(e *Entry) { + h.mu.RLock() + defer h.mu.RUnlock() + for ch := range h.subs { + select { + case ch <- e: + default: + } + } +} + +func (h *Hub) Count() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.subs) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ba861ff --- /dev/null +++ b/main.go @@ -0,0 +1,140 @@ +// Logwell : puits de logs syslog (UDP/TCP) stocké dans VictoriaLogs, +// avec une interface web de consultation. +package main + +import ( + "context" + "crypto/subtle" + "embed" + "errors" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "syscall" + "time" + _ "time/tzdata" // fuseaux horaires embarqués : la variable TZ fonctionne sans paquet système +) + +//go:embed web +var webFS embed.FS + +type config struct { + syslogAddr string + httpAddr string + vlogsURL string + dataDir string + authUser string + authPass string + batchSize int + queueSize int + flushEvery time.Duration +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getenvInt(key string, def int) int { + if v, err := strconv.Atoi(os.Getenv(key)); err == nil && v > 0 { + return v + } + return def +} + +func main() { + cfg := config{ + syslogAddr: getenv("SYSLOG_ADDR", ":5514"), + httpAddr: getenv("HTTP_ADDR", ":8080"), + vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"), + dataDir: getenv("DATA_DIR", "/data"), + authUser: os.Getenv("AUTH_USER"), + authPass: os.Getenv("AUTH_PASS"), + batchSize: getenvInt("BATCH_SIZE", 1000), + queueSize: getenvInt("QUEUE_SIZE", 100000), + flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + tags, err := LoadTagStore(filepath.Join(cfg.dataDir, "tags.json")) + if err != nil { + log.Fatalf("tags : %v", err) + } + + store := NewStore(cfg.vlogsURL, cfg.batchSize, cfg.queueSize, cfg.flushEvery) + storeDone := make(chan struct{}) + go func() { + store.Run(ctx) + close(storeDone) + }() + + hub := NewHub() + sink := func(e *Entry) { + store.Enqueue(e) + hub.Publish(e) + } + if err := StartSyslog(ctx, cfg.syslogAddr, sink); err != nil { + log.Fatalf("syslog : %v", err) + } + + static, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatal(err) + } + mux := http.NewServeMux() + api := &API{store: store, hub: hub, tags: tags} + api.Routes(mux) + mux.Handle("GET /", http.FileServer(http.FS(static))) + + srv := &http.Server{ + Addr: cfg.httpAddr, + Handler: basicAuth(cfg.authUser, cfg.authPass, mux), + ReadHeaderTimeout: 10 * time.Second, + // Les requêtes héritent du contexte global : les flux SSE se ferment à l'arrêt. + BaseContext: func(net.Listener) context.Context { return ctx }, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + log.Printf("interface web sur %s, syslog sur %s (udp+tcp), stockage %s", cfg.httpAddr, cfg.syslogAddr, cfg.vlogsURL) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("http : %v", err) + } + <-storeDone + log.Println("arrêt terminé") +} + +// basicAuth protège l'interface si AUTH_USER est défini (sauf /healthz). +func basicAuth(user, pass string, next http.Handler) http.Handler { + if user == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + u, p, ok := r.BasicAuth() + if !ok || + subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 || + subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 { + w.Header().Set("WWW-Authenticate", `Basic realm="logwell"`) + http.Error(w, "authentification requise", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..ea4b9e3 --- /dev/null +++ b/query.go @@ -0,0 +1,186 @@ +package main + +import ( + "net/http" + "regexp" + "strconv" + "strings" +) + +// Filter décrit les critères de recherche envoyés par l'interface. +type Filter struct { + Mode string // "simple" ou "logsql" + Text string + Range string // 5m, 15m, 1h, 6h, 24h, 7d, 30d ; autre valeur = pas de limite + Host string + App string + Severity int // sévérité maximale incluse (0 = emerg … 7 = debug), -1 = toutes +} + +var validRanges = map[string]bool{"5m": true, "15m": true, "1h": true, "6h": true, "24h": true, "7d": true, "30d": true} + +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"), + Severity: -1, + } + if v, err := strconv.Atoi(q.Get("severity")); err == nil && v >= 0 && v <= 7 { + f.Severity = v + } + return f +} + +type term struct { + text string + neg bool +} + +// parseTerms découpe la recherche simple : mots, "phrases entre guillemets", -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 traduit les critères (hors texte LogsQL brut) en filtre LogsQL. +func (f Filter) filterExpr() string { + var parts []string + if validRanges[f.Range] { + 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)) + } + 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" { + // Recherche de sous-chaîne insensible à la casse dans le message, l'hôte et l'appli. + 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, " ") +} + +// LogsQL renvoie la partie filtre et les éventuels pipes (« | … ») saisis en mode LogsQL. +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 sépare le filtre des pipes, en ignorant les « | » entre guillemets. +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 applique le même filtre que filterExpr aux messages du flux direct. +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.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 +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..d6d8f90 --- /dev/null +++ b/store.go @@ -0,0 +1,182 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" +) + +// Store envoie les messages à VictoriaLogs par lots et l'interroge en LogsQL. +type Store struct { + base string + client *http.Client + in chan *Entry + batchSize int + flushEvery time.Duration + + received atomic.Int64 + ingested atomic.Int64 + dropped atomic.Int64 + lastErr atomic.Value // string +} + +func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration) *Store { + s := &Store{ + base: strings.TrimRight(base, "/"), + client: &http.Client{Timeout: 60 * time.Second}, + in: make(chan *Entry, queueSize), + batchSize: batchSize, + flushEvery: flushEvery, + } + s.lastErr.Store("") + return s +} + +// Enqueue ne bloque jamais : si la file est pleine, le message est compté comme perdu. +func (s *Store) Enqueue(e *Entry) { + s.received.Add(1) + select { + case s.in <- e: + default: + s.dropped.Add(1) + } +} + +// Run vide la file vers VictoriaLogs jusqu'à l'annulation du contexte, +// puis envoie ce qui reste. +func (s *Store) Run(ctx context.Context) { + ticker := time.NewTicker(s.flushEvery) + defer ticker.Stop() + batch := make([]*Entry, 0, s.batchSize) + + flush := func() { + if len(batch) == 0 { + return + } + if err := s.insert(batch); err != nil { + s.dropped.Add(int64(len(batch))) + s.lastErr.Store(err.Error()) + log.Printf("victorialogs : %d messages perdus : %v", len(batch), err) + } else { + s.ingested.Add(int64(len(batch))) + s.lastErr.Store("") + } + batch = batch[:0] + } + + for { + select { + case e := <-s.in: + batch = append(batch, e) + if len(batch) >= s.batchSize { + flush() + } + case <-ticker.C: + flush() + case <-ctx.Done(): + for { + select { + case e := <-s.in: + batch = append(batch, e) + if len(batch) >= s.batchSize { + flush() + } + default: + flush() + return + } + } + } + } +} + +func (s *Store) insert(batch []*Entry) error { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + for _, e := range batch { + if err := enc.Encode(e.Record()); err != nil { + return err + } + } + u := s.base + "/insert/jsonline?_stream_fields=host,app&_msg_field=_msg&_time_field=_time" + + var err error + for attempt := 0; attempt < 5; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(1< 2 && s[0] == '<' { + if end := strings.IndexByte(s, '>'); end > 1 && end <= 4 { + if pri, err := strconv.Atoi(s[1:end]); err == nil && pri >= 0 && pri <= 191 { + e.setPri(pri) + s = s[end+1:] + } + } + } + if strings.HasPrefix(s, "1 ") { + parse5424(e, s[2:]) + } else { + parse3164(e, s) + } + if e.Message == "" { + e.Message = "(vide)" + } + return e +} + +// parse5424 : TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG +func parse5424(e *Entry, s string) { + f := strings.SplitN(s, " ", 6) + if len(f) < 6 { + e.Message = s + return + } + if t, err := time.Parse(time.RFC3339Nano, f[0]); err == nil { + e.Time = t + } + if f[1] != "-" { + e.Host = f[1] + } + if f[2] != "-" { + e.App = f[2] + } + if f[3] != "-" { + e.ProcID = f[3] + } + rest := f[5] + switch { + case rest == "-" || strings.HasPrefix(rest, "- "): + rest = strings.TrimPrefix(rest[1:], " ") + case strings.HasPrefix(rest, "["): + n := sdLen(rest) + e.SD = rest[:n] + rest = strings.TrimPrefix(rest[n:], " ") + } + e.Message = strings.TrimPrefix(rest, "") +} + +// sdLen renvoie la longueur du bloc de données structurées « [..][..] ». +func sdLen(s string) int { + i := 0 + for i < len(s) && s[i] == '[' { + inQuote := false + for i++; i < len(s); i++ { + c := s[i] + if inQuote && c == '\\' { + i++ + continue + } + if c == '"' { + inQuote = !inQuote + continue + } + if c == ']' && !inQuote { + i++ + break + } + } + } + if i > len(s) { + i = len(s) + } + return i +} + +// parse3164 : « Mmm dd hh:mm:ss HOST TAG[PID]: MSG », ou horodatage ISO (rsyslog). +func parse3164(e *Entry, s string) { + if len(s) >= 16 && s[15] == ' ' { + if t, err := time.ParseInLocation(time.Stamp, s[:15], time.Local); err == nil { + // L'année est absente : on prend l'année courante, sauf si la date tombe dans le futur. + now := e.Time + t = time.Date(now.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.Local) + if t.After(now.Add(24 * time.Hour)) { + t = t.AddDate(-1, 0, 0) + } + e.Time = t + parseHostTag(e, s[16:], true) + return + } + } + if sp := strings.IndexByte(s, ' '); sp >= 19 { + if t, err := time.Parse(time.RFC3339Nano, s[:sp]); err == nil { + e.Time = t + parseHostTag(e, s[sp+1:], true) + return + } + } + parseHostTag(e, s, false) +} + +func parseHostTag(e *Entry, s string, withHost bool) { + if withHost { + if sp := strings.IndexByte(s, ' '); sp > 0 { + tok := s[:sp] + if !strings.HasSuffix(tok, ":") && !strings.Contains(tok, "[") { + e.Host = tok + s = s[sp+1:] + } + } + } + if i := strings.IndexAny(s, ":[ "); i > 0 && i <= 48 { + switch s[i] { + case ':': + if i+1 == len(s) || s[i+1] == ' ' { + e.App = s[:i] + s = strings.TrimPrefix(s[i+1:], " ") + } + case '[': + if j := strings.IndexByte(s[i:], ']'); j > 1 { + e.App = s[:i] + e.ProcID = s[i+1 : i+j] + s = strings.TrimPrefix(strings.TrimPrefix(s[i+j+1:], ":"), " ") + } + } + } + e.Message = s +} + +// StartSyslog démarre les écoutes UDP et TCP sur la même adresse. +func StartSyslog(ctx context.Context, addr string, sink func(*Entry)) error { + pc, err := net.ListenPacket("udp", addr) + if err != nil { + return err + } + ln, err := net.Listen("tcp", addr) + if err != nil { + pc.Close() + return err + } + if uc, ok := pc.(*net.UDPConn); ok { + _ = uc.SetReadBuffer(8 << 20) + } + go func() { + <-ctx.Done() + pc.Close() + ln.Close() + }() + go serveUDP(ctx, pc, sink) + go serveTCP(ctx, ln, sink) + return nil +} + +func serveUDP(ctx context.Context, pc net.PacketConn, sink func(*Entry)) { + buf := make([]byte, 65536) + for { + n, addr, err := pc.ReadFrom(buf) + if err != nil { + if ctx.Err() != nil { + return + } + log.Printf("syslog udp : %v", err) + time.Sleep(100 * time.Millisecond) + continue + } + sink(ParseSyslog(buf[:n], hostOf(addr), "udp", time.Now())) + } +} + +func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) { + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + log.Printf("syslog tcp : %v", err) + time.Sleep(100 * time.Millisecond) + continue + } + go handleTCP(ctx, conn, sink) + } +} + +const maxFrame = 1 << 20 + +// handleTCP gère les deux découpages de la RFC 6587 : « octet counting » +// (« 123 <34>1 ... ») et un message par ligne. +func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) { + defer conn.Close() + stop := context.AfterFunc(ctx, func() { conn.Close() }) + defer stop() + + src := hostOf(conn.RemoteAddr()) + r := bufio.NewReaderSize(conn, 64*1024) + for { + c, err := r.ReadByte() + if err != nil { + return + } + switch { + case c == '\n' || c == '\r' || c == ' ' || c == 0: + continue + case c >= '1' && c <= '9': + n := int(c - '0') + for { + c, err = r.ReadByte() + if err != nil { + return + } + if c == ' ' { + break + } + if c < '0' || c > '9' || n > maxFrame { + log.Printf("syslog tcp %s : trame invalide, connexion fermée", src) + return + } + n = n*10 + int(c-'0') + } + if n > maxFrame { + return + } + msg := make([]byte, n) + if _, err := io.ReadFull(r, msg); err != nil { + return + } + sink(ParseSyslog(msg, src, "tcp", time.Now())) + default: + _ = r.UnreadByte() + line, err := r.ReadSlice('\n') + if len(line) > 0 { + sink(ParseSyslog(line, src, "tcp", time.Now())) + } + // Ligne plus longue que le tampon : on ignore la suite. + for err == bufio.ErrBufferFull { + _, err = r.ReadSlice('\n') + } + if err != nil { + return + } + } + } +} + +func hostOf(a net.Addr) string { + if a == nil { + return "" + } + h, _, err := net.SplitHostPort(a.String()) + if err != nil { + return a.String() + } + return h +} diff --git a/tags.go b/tags.go new file mode 100644 index 0000000..bc2eb76 --- /dev/null +++ b/tags.go @@ -0,0 +1,151 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" +) + +// Tag colore un mot-clé dans les messages affichés. +type Tag struct { + ID string `json:"id"` + Pattern string `json:"pattern"` + Color string `json:"color"` + WholeWord bool `json:"wholeWord"` + CaseSensitive bool `json:"caseSensitive"` + Regex bool `json:"regex"` + Enabled bool `json:"enabled"` +} + +func defaultTags() []Tag { + return []Tag{ + {ID: "warning", Pattern: "warning", Color: "#f59e0b", WholeWord: true, Enabled: true}, + {ID: "error", Pattern: "error", Color: "#ef4444", WholeWord: true, Enabled: true}, + {ID: "ok", Pattern: "ok", Color: "#22c55e", WholeWord: true, Enabled: true}, + } +} + +var ( + errTagNotFound = errors.New("tag introuvable") + colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) +) + +func (t *Tag) validate() error { + t.Pattern = strings.TrimSpace(t.Pattern) + if t.Pattern == "" { + return errors.New("le mot-clé est obligatoire") + } + if !colorRe.MatchString(t.Color) { + return errors.New("couleur invalide (format #rrggbb attendu)") + } + if t.Regex { + if _, err := regexp.Compile(t.Pattern); err != nil { + return fmt.Errorf("expression régulière invalide : %v", err) + } + } + return nil +} + +// TagStore conserve les tags dans un fichier JSON (volume /data). +type TagStore struct { + mu sync.Mutex + path string + tags []Tag +} + +func LoadTagStore(path string) (*TagStore, error) { + s := &TagStore{path: path} + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + s.tags = defaultTags() + return s, s.save() + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(b, &s.tags); err != nil { + return nil, fmt.Errorf("%s : %w", path, err) + } + return s, nil +} + +// save écrit le fichier de façon atomique ; l'appelant détient le verrou. +func (s *TagStore) save() error { + b, err := json.MarshalIndent(s.tags, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func (s *TagStore) List() []Tag { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Tag(nil), s.tags...) +} + +func (s *TagStore) Create(t Tag) (Tag, error) { + if err := t.validate(); err != nil { + return t, err + } + t.ID = newID() + s.mu.Lock() + defer s.mu.Unlock() + s.tags = append(s.tags, t) + return t, s.save() +} + +func (s *TagStore) Update(id string, t Tag) (Tag, error) { + if err := t.validate(); err != nil { + return t, err + } + t.ID = id + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.tags { + if s.tags[i].ID == id { + s.tags[i] = t + return t, s.save() + } + } + return t, errTagNotFound +} + +func (s *TagStore) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.tags { + if s.tags[i].ID == id { + s.tags = append(s.tags[:i], s.tags[i+1:]...) + return s.save() + } + } + return errTagNotFound +} + +func (s *TagStore) Reset() ([]Tag, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.tags = defaultTags() + return append([]Tag(nil), s.tags...), s.save() +} + +func newID() string { + b := make([]byte, 6) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/tools/send-test-logs.sh b/tools/send-test-logs.sh new file mode 100755 index 0000000..6e4e2fc --- /dev/null +++ b/tools/send-test-logs.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Envoie des messages syslog de test (RFC 3164 et RFC 5424) en UDP. +# Usage : ./tools/send-test-logs.sh [hôte] [port] [nombre] +set -eu + +HOST=${1:-127.0.0.1} +PORT=${2:-514} +COUNT=${3:-100} + +hosts=(web-01 web-02 db-01 fw-edge nas-home) +apps=(nginx sshd postgres kernel backup cron) + +# « sévérité|message » (3 = err, 4 = warning, 5 = notice, 6 = info, 7 = debug) +samples=( + "6|GET /api/health 200 in 3ms - ok" + "6|Accepted publickey for admin from 192.168.1.20 port 51422" + "6|Backup job finished: ok (12.4 GB, 00:14:32)" + "4|warning: disk usage at 91% on /var" + "4|WARNING slow query (2.3s): SELECT * FROM events WHERE ts > now() - interval '1 day'" + "3|error: connection refused to upstream 10.0.0.12:5432" + "3|Error while rotating logs: permission denied (/var/log/app.log)" + "5|Configuration reloaded, 42 rules loaded" + "7|debug: cache hit ratio 0.93, token refreshed" + "4|Temperature warning: CPU at 84°C" + "6|link eth0 up, 1000Mbps full duplex, status ok" + "3|Out of memory: Killed process 4121 (java)" +) + +for ((i = 0; i < COUNT; i++)); do + h=${hosts[RANDOM % ${#hosts[@]}]} + a=${apps[RANDOM % ${#apps[@]}]} + s=${samples[RANDOM % ${#samples[@]}]} + sev=${s%%|*} + msg=${s#*|} + pri=$((16 * 8 + sev)) # facility local0 + + if ((i % 2)); then + ts=$(LC_ALL=C date '+%b %e %H:%M:%S') + line="<$pri>$ts $h $a[$((RANDOM % 9000 + 1000))]: $msg" + else + ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + line="<$pri>1 $ts $h $a $((RANDOM)) - - $msg" + fi + printf '%s' "$line" > "/dev/udp/$HOST/$PORT" + sleep 0.05 +done +echo "$COUNT messages envoyés à $HOST:$PORT (udp)" diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..92a5f9a --- /dev/null +++ b/web/app.js @@ -0,0 +1,586 @@ +'use strict'; + +/* ================= Utilitaires ================= */ + +const $ = (sel) => document.querySelector(sel); +const PAGE_SIZE = 500; // lignes chargées par recherche +const MAX_ROWS = 2000; // lignes gardées à l'écran en mode direct +const RANGE_MS = { '5m': 3e5, '15m': 9e5, '1h': 36e5, '6h': 216e5, '24h': 864e5, '7d': 6048e5, '30d': 2592e6 }; + +const store = { + get(k, def) { try { const v = localStorage.getItem('logwell.' + k); return v === null ? def : v; } catch { return def; } }, + set(k, v) { try { localStorage.setItem('logwell.' + k, v); } catch { /* stockage indisponible */ } }, +}; + +const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]); +const pad = (n, w = 2) => String(n).padStart(w, '0'); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; +const fmtNum = (n) => Number(n || 0).toLocaleString('fr-FR'); + +// Date.parse n'accepte que 3 décimales partout : on tronque les nanosecondes. +const parseTime = (s) => new Date(String(s || '').replace(/(\.\d{3})\d+/, '$1')); + +async function api(url, opts = {}) { + if (opts.body && typeof opts.body !== 'string') { + opts.body = JSON.stringify(opts.body); + opts.headers = { 'Content-Type': 'application/json' }; + } + const res = await fetch(url, opts); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { /* réponse non JSON */ } + if (!res.ok) throw new Error((data && data.error) || text || res.statusText); + return data; +} + +function toast(msg) { + const el = $('#toast'); + // Une fenêtre modale passe au-dessus de tout : on y place le toast pour qu'il reste visible. + const dlg = $('#tagsDlg'); + (dlg.open ? dlg : document.body).append(el); + el.textContent = msg; + el.hidden = false; + clearTimeout(toast.t); + toast.t = setTimeout(() => { el.hidden = true; }, 1800); +} + +/* ================= État ================= */ + +const state = { + rows: [], // enregistrements affichés, du plus récent au plus ancien + pending: [], // messages reçus en direct pendant que l'on fait défiler + tags: [], + matchers: [], // tags compilés + termes recherchés + mode: store.get('mode', 'simple'), + live: store.get('live', '1') === '1', + es: null, + reqId: 0, +}; +const recOf = new WeakMap(); // élément .row -> enregistrement + +const list = $('#list'); + +/* ================= Thème ================= */ + +function effectiveTheme() { + const t = document.documentElement.dataset.theme; + if (t) return t; + return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} +$('#themeBtn').addEventListener('click', () => { + const next = effectiveTheme() === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + store.set('theme', next); +}); + +/* ================= Filtres ================= */ + +function params() { + const p = new URLSearchParams(); + const q = $('#q').value.trim(); + if (q) p.set('q', q); + p.set('mode', state.mode); + p.set('range', $('#range').value); + for (const id of ['severity', 'host', 'app']) { + const v = $('#' + id).value; + if (v !== '') p.set(id, v); + } + return p; +} + +function liveAvailable() { + return !(state.mode === 'logsql' && $('#q').value.trim()); +} + +function syncControls() { + const logsql = state.mode === 'logsql'; + $('#mode').textContent = logsql ? 'LogsQL' : 'Simple'; + $('#mode').classList.toggle('on', logsql); + $('#q').placeholder = logsql + ? 'LogsQL : error AND host:web-01 | sort by (_time)' + : 'Rechercher… error -timeout "disk full" (touche /)'; + const btn = $('#live'); + btn.disabled = !liveAvailable(); + btn.classList.toggle('on', state.live && liveAvailable()); + btn.title = liveAvailable() ? 'Afficher les nouveaux messages en direct' : 'Le direct n\'est pas disponible en mode LogsQL'; + for (const id of ['severity', 'host', 'app']) $('#' + id).classList.toggle('set', $('#' + id).value !== ''); +} + +/* ================= Tags et surlignage ================= */ + +// Couleur de texte lisible (noir ou blanc) selon la luminosité du fond. +function textOn(hex) { + const n = parseInt(hex.slice(1), 16); + const yiq = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000; + return yiq >= 150 ? '#111827' : '#ffffff'; +} +const tagStyle = (color) => `background:${color};color:${textOn(color)}`; +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +function compileMatchers() { + const out = []; + for (const t of state.tags) { + if (!t.enabled || !t.pattern) continue; + let src = t.regex ? t.pattern : escapeRe(t.pattern); + if (t.wholeWord) src = `(?` }); + } catch (e) { + console.warn('Tag ignoré (expression invalide) :', t.pattern, e.message); + } + } + // Les termes de la recherche simple sont soulignés discrètement, avec la priorité la plus basse. + if (state.mode === 'simple') { + for (const tok of $('#q').value.match(/-?"[^"]*"|\S+/g) || []) { + if (tok.startsWith('-')) continue; + const word = tok.replace(/^"|"$/g, ''); + if (word) out.push({ re: new RegExp(escapeRe(word), 'giu'), html: '' }); + } + } + state.matchers = out; +} + +function highlight(text) { + text = String(text ?? ''); + if (!state.matchers.length || !text) return esc(text); + const spans = []; + state.matchers.forEach((m, prio) => { + m.re.lastIndex = 0; + let x; + while ((x = m.re.exec(text)) !== null) { + if (x[0] === '') { m.re.lastIndex++; continue; } + spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html }); + } + }); + if (!spans.length) return esc(text); + spans.sort((a, b) => a.s - b.s || a.prio - b.prio); + let out = ''; + let pos = 0; + for (const sp of spans) { + if (sp.s < pos) continue; // chevauchement : le premier arrivé l'emporte + out += esc(text.slice(pos, sp.s)) + sp.html + esc(text.slice(sp.s, sp.e)) + ''; + pos = sp.e; + } + return out + esc(text.slice(pos)); +} + +/* ================= Rendu de la liste ================= */ + +const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' }; + +function fmtTime(s) { + const d = parseTime(s); + if (isNaN(d)) return esc(s); + return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +function rowHTML(r, isNew) { + const sev = r.severity || ''; + const cls = SEV_CLASS[sev] || 'info'; + return `
` + + `` + + `${esc(sev || '—')}` + + `${esc(r.host)}` + + `${esc(r.app)}` + + `
${highlight(r._msg)}
` + + '
'; +} + +// Associe les n premiers (ou derniers) éléments .row à leurs enregistrements. +function bindRows(first, rows) { + let el = first; + for (const r of rows) { + if (!el) break; + recOf.set(el, r); + el = el.nextElementSibling; + } +} + +function renderList() { + list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join(''); + bindRows(list.firstElementChild, state.rows); + $('#empty').hidden = state.rows.length > 0; +} + +function rehighlight() { + for (const row of list.children) { + const r = recOf.get(row); + if (r) row.querySelector('.msg').innerHTML = highlight(r._msg); + } +} + +function prependRows(rows) { + if (!rows.length) return; + list.insertAdjacentHTML('afterbegin', rows.map((r) => rowHTML(r, true)).join('')); + bindRows(list.firstElementChild, rows); + state.rows = rows.concat(state.rows); + while (state.rows.length > MAX_ROWS) { + state.rows.pop(); + list.lastElementChild?.remove(); + } + $('#empty').hidden = true; + updateCount(); +} + +function updateCount(tookMs) { + const n = state.rows.length; + const took = tookMs != null ? ` · ${tookMs} ms` : ''; + $('#count').textContent = `${fmtNum(n)} ${n > 1 ? 'logs affichés' : 'log affiché'}${took}`; +} + +function detailsHTML(r) { + const order = ['_time', 'host', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg']; + const hidden = new Set(['_stream_id', '_stream', 'sevnum']); + const keys = order.filter((k) => r[k] != null && r[k] !== '') + .concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort()); + const label = { _time: 'horodatage', _msg: 'message', procid: 'pid', sd: 'données structurées' }; + const val = (k) => (k === '_time' ? `${esc(parseTime(r[k]).toLocaleString('fr-FR'))} (${esc(r[k])})` : esc(r[k])); + return '
' + + keys.map((k) => `
${esc(label[k] || k)}
${val(k)}
`).join('') + + '
' + + (r.host ? '' : '') + + (r.app ? '' : '') + + '' + + '' + + '
'; +} + +async function copy(text) { + try { + await navigator.clipboard.writeText(text); + } catch { + // Repli pour les pages servies en HTTP simple (hors localhost). + const ta = Object.assign(document.createElement('textarea'), { value: text }); + document.body.append(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + toast('Copié'); +} + +function setSelect(id, value) { + const sel = $('#' + id); + if (![...sel.options].some((o) => o.value === value)) sel.add(new Option(value, value)); + sel.value = value; +} + +list.addEventListener('click', (ev) => { + const row = ev.target.closest('.row'); + if (!row) return; + const r = recOf.get(row); + const act = ev.target.closest('[data-act]'); + if (act) { + const a = act.dataset.act; + if (a === 'host' || a === 'app') { setSelect(a, r[a]); onFilterChange(); } + if (a === 'copy') copy(r._msg || ''); + if (a === 'json') copy(JSON.stringify(r, null, 2)); + return; + } + if (ev.target.closest('.details')) return; + if (String(window.getSelection())) return; // on sélectionne du texte : ne pas replier + const open = row.classList.toggle('open'); + const det = row.querySelector('.details'); + if (open && !det) row.insertAdjacentHTML('beforeend', detailsHTML(r)); + else if (det) det.hidden = !open; +}); +list.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && ev.target.classList.contains('row')) ev.target.click(); +}); + +/* ================= Histogramme ================= */ + +function renderHisto(h) { + const el = $('#histo'); + if (!h || !h.buckets) { el.innerHTML = ''; return; } + const stepMs = h.step * 1000; + const counts = new Map(); + for (const b of h.buckets) counts.set(Math.floor(parseTime(b.t).getTime() / stepMs), b.n); + + const span = RANGE_MS[$('#range').value]; + const end = Math.floor(Date.now() / stepMs); + let start = span ? Math.floor((Date.now() - span) / stepMs) + 1 : (counts.size ? Math.min(...counts.keys()) : end); + if (end - start > 400) start = end - 400; + + let max = 1; + for (let k = start; k <= end; k++) max = Math.max(max, counts.get(k) || 0); + const label = (k) => { + const d = new Date(k * stepMs); + return stepMs >= 864e5 ? d.toLocaleDateString('fr-FR') : d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + }; + let bars = ''; + for (let k = start; k <= end; k++) { + const n = counts.get(k) || 0; + const hgt = n ? Math.max(4, (n / max) * 100) : 0; + bars += ``; + } + el.innerHTML = `
${bars}
${label(start)}max ${fmtNum(max)} / intervalle${label(end)}
`; +} + +async function refreshHisto() { + try { renderHisto(await api('/api/histogram?' + params())); } catch { renderHisto(null); } +} + +/* ================= Recherche ================= */ + +function setError(msg) { + const el = $('#error'); + el.textContent = msg || ''; + el.hidden = !msg; +} + +async function refresh() { + const id = ++state.reqId; + syncControls(); + compileMatchers(); + document.body.classList.add('busy'); + connectLive(); + try { + const res = await api(`/api/logs?${params()}&limit=${PAGE_SIZE}`); + if (id !== state.reqId) return; + state.rows = res.rows || []; + state.pending = []; + $('#newPill').hidden = true; + renderList(); + updateCount(res.took_ms); + setError(null); + } catch (e) { + if (id !== state.reqId) return; + state.rows = []; + renderList(); + $('#empty').hidden = true; + $('#count').textContent = ''; + setError('Erreur de recherche : ' + e.message); + } finally { + if (id === state.reqId) document.body.classList.remove('busy'); + } + refreshHisto(); +} + +const onFilterChange = () => { + store.set('range', $('#range').value); + store.set('severity', $('#severity').value); + refresh(); +}; + +$('#q').addEventListener('input', debounce(refresh, 350)); +$('#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); + +$('#mode').addEventListener('click', () => { + state.mode = state.mode === 'simple' ? 'logsql' : 'simple'; + store.set('mode', state.mode); + refresh(); + $('#q').focus(); +}); + +document.addEventListener('keydown', (ev) => { + if (ev.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) && !$('#tagsDlg').open) { + ev.preventDefault(); + $('#q').focus(); + } +}); + +/* ================= Direct (SSE) ================= */ + +function setConn(cls, text) { + const el = $('#conn'); + el.className = 'conn ' + cls; + el.textContent = text; +} + +function connectLive() { + if (state.es) { state.es.close(); state.es = null; } + if (!state.live || !liveAvailable()) { + setConn('', state.live ? 'direct indisponible (LogsQL)' : 'direct en pause'); + return; + } + const es = new EventSource('/api/stream?' + params()); + state.es = es; + setConn('', 'connexion…'); + es.onopen = () => setConn('ok', 'direct'); + es.onerror = () => setConn('ko', 'reconnexion…'); + es.onmessage = (ev) => { + const data = JSON.parse(ev.data); + const rows = (data.rows || []).reverse(); // le plus récent en premier + if (data.skipped) toast(`${fmtNum(data.skipped)} messages non affichés (débit trop élevé)`); + if (window.scrollY > 80) { + state.pending = rows.concat(state.pending).slice(0, MAX_ROWS); + const pill = $('#newPill'); + pill.textContent = `↑ ${fmtNum(state.pending.length)} nouveau${state.pending.length > 1 ? 'x' : ''} log${state.pending.length > 1 ? 's' : ''}`; + pill.hidden = false; + } else { + prependRows(rows); + } + }; +} + +function flushPending() { + if (!state.pending.length) return; + prependRows(state.pending); + state.pending = []; + $('#newPill').hidden = true; +} + +$('#newPill').addEventListener('click', () => { + flushPending(); + window.scrollTo({ top: 0, behavior: 'smooth' }); +}); +window.addEventListener('scroll', debounce(() => { if (window.scrollY <= 80) flushPending(); }, 150), { passive: true }); + +$('#live').addEventListener('click', () => { + state.live = !state.live; + store.set('live', state.live ? '1' : '0'); + if (state.live) refresh(); // rattrape ce qui a été reçu pendant la pause + else { syncControls(); connectLive(); } +}); + +/* ================= Listes hôtes / applis, statistiques ================= */ + +async function loadFacets() { + try { + const f = await api('/api/facets'); + for (const id of ['host', 'app']) { + const sel = $('#' + id); + const cur = sel.value; + const first = sel.options[0]; + sel.replaceChildren(first, ...(f[id] || []).map((v) => new Option(v, v))); + if (cur) setSelect(id, cur); + } + } catch { /* VictoriaLogs pas encore prêt : on réessaiera */ } +} + +async function loadStats() { + try { + const s = await api('/api/stats'); + const err = s.lastError ? ` · stockage : ${esc(s.lastError)}` : ''; + const dropped = s.dropped ? `perdus ${fmtNum(s.dropped)}` : 'perdus 0'; + $('#stats').innerHTML = `reçus ${fmtNum(s.received)} · stockés ${fmtNum(s.ingested)} · ${dropped} · file ${fmtNum(s.queue)}${err}`; + } catch { + $('#stats').textContent = 'serveur injoignable'; + } +} + +/* ================= Gestion des tags ================= */ + +const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316']; + +async function loadTags() { + try { state.tags = await api('/api/tags'); } catch (e) { toast('Tags : ' + e.message); } + compileMatchers(); +} + +function tagRowHTML(t) { + const opt = (field, label, title) => + ``; + return `
+ + + ${esc(t.pattern || 'aperçu')} +
+ ${opt('wholeWord', 'mot entier', 'Ne colorer que les mots entiers (évite « ok » dans « token »)')} + ${opt('caseSensitive', 'Aa', 'Respecter la casse')} + ${opt('regex', '.*', 'Expression régulière')} + ${opt('enabled', 'actif', 'Activer ce tag')} +
+ +
+
`; +} + +function renderTagList() { + $('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || '

Aucun tag.

'; +} + +const saveTimers = {}; +function scheduleSave(t, rowEl) { + clearTimeout(saveTimers[t.id]); + saveTimers[t.id] = setTimeout(async () => { + const errEl = rowEl.querySelector('.err'); + try { + await api('/api/tags/' + encodeURIComponent(t.id), { method: 'PUT', body: t }); + errEl.textContent = ''; + } catch (e) { + errEl.textContent = e.message; + } + }, 400); +} + +const applyTags = debounce(() => { compileMatchers(); rehighlight(); }, 150); + +$('#tagList').addEventListener('input', (ev) => { + const rowEl = ev.target.closest('.tag-row'); + const field = ev.target.dataset.f; + if (!rowEl || !field) return; + const t = state.tags.find((x) => x.id === rowEl.dataset.id); + if (!t) return; + t[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value; + rowEl.classList.toggle('off', !t.enabled); + const mark = rowEl.querySelector('.preview mark'); + mark.textContent = t.pattern || 'aperçu'; + mark.setAttribute('style', tagStyle(t.color)); + scheduleSave(t, rowEl); + applyTags(); +}); + +$('#tagList').addEventListener('click', async (ev) => { + const del = ev.target.closest('[data-del]'); + if (!del) return; + const rowEl = del.closest('.tag-row'); + const t = state.tags.find((x) => x.id === rowEl.dataset.id); + if (!t || !confirm(`Supprimer le tag « ${t.pattern} » ?`)) return; + try { + await api('/api/tags/' + encodeURIComponent(t.id), { method: 'DELETE' }); + state.tags = state.tags.filter((x) => x !== t); + renderTagList(); + applyTags(); + } catch (e) { toast(e.message); } +}); + +$('#addTag').addEventListener('click', async () => { + const color = PALETTE[state.tags.length % PALETTE.length]; + try { + const t = await api('/api/tags', { method: 'POST', body: { pattern: 'nouveau', color, wholeWord: true, enabled: true } }); + state.tags.push(t); + renderTagList(); + applyTags(); + const input = $('#tagList').lastElementChild.querySelector('input[type="text"]'); + input.focus(); + input.select(); + } catch (e) { toast(e.message); } +}); + +$('#resetTags').addEventListener('click', async () => { + if (!confirm('Remplacer tous les tags par les tags par défaut (warning, error, ok) ?')) return; + try { + state.tags = await api('/api/tags/reset', { method: 'POST' }); + renderTagList(); + applyTags(); + } catch (e) { toast(e.message); } +}); + +$('#tagsBtn').addEventListener('click', () => { renderTagList(); $('#tagsDlg').showModal(); }); +$('#closeTags').addEventListener('click', () => $('#tagsDlg').close()); +$('#tagsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); }); + +/* ================= Démarrage ================= */ + +$('#range').value = store.get('range', '1h'); +if (!$('#range').value) $('#range').value = '1h'; +$('#severity').value = store.get('severity', ''); + +(async () => { + await loadTags(); + loadFacets(); + loadStats(); + refresh(); + setInterval(loadStats, 5000); + setInterval(loadFacets, 60000); + setInterval(() => { if (state.live && liveAvailable() && !document.hidden) refreshHisto(); }, 30000); +})(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..79383ef --- /dev/null +++ b/web/index.html @@ -0,0 +1,105 @@ + + + + + + Logwell + + + + + +
+
+ + Logwell +
+ + + +
+ + + +
+ +
+ +
+ + + + + + +
+ +
+ + + + +
+ + +
+ + +
+ + +
+
+

Tags de couleur

+ +
+

Les mots-clés sont surlignés dans les messages. Les modifications sont enregistrées automatiquement. En cas de chevauchement, le tag le plus haut dans la liste l'emporte.

+
+
+ + +
+
+
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..d9c8d8f --- /dev/null +++ b/web/style.css @@ -0,0 +1,380 @@ +/* ---------- Thème : tout passe par ces variables ---------- */ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --panel-2: #f0f2f5; + --border: #e2e5ea; + --text: #1a1d23; + --muted: #6b7280; + --accent: #6366f1; + --accent-soft: rgba(99, 102, 241, .12); + --row-hover: #f5f6fa; + --hit: rgba(99, 102, 241, .18); + --shadow: 0 10px 30px rgba(15, 23, 42, .12); + + --sev-crit: #be123c; + --sev-err: #dc2626; + --sev-warning: #d97706; + --sev-notice: #2563eb; + --sev-info: #0d9488; + --sev-debug: #9ca3af; + + --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --radius: 10px; + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #0f1115; + --panel: #161a20; + --panel-2: #1d222a; + --border: #2a3039; + --text: #e6e8ec; + --muted: #8b93a1; + --accent: #818cf8; + --accent-soft: rgba(129, 140, 248, .16); + --row-hover: #1a1f26; + --hit: rgba(129, 140, 248, .28); + --shadow: 0 10px 30px rgba(0, 0, 0, .5); + --sev-crit: #fb7185; + --sev-err: #f87171; + --sev-warning: #fbbf24; + --sev-notice: #60a5fa; + --sev-info: #2dd4bf; + --sev-debug: #6b7280; + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f1115; + --panel: #161a20; + --panel-2: #1d222a; + --border: #2a3039; + --text: #e6e8ec; + --muted: #8b93a1; + --accent: #818cf8; + --accent-soft: rgba(129, 140, 248, .16); + --row-hover: #1a1f26; + --hit: rgba(129, 140, 248, .28); + --shadow: 0 10px 30px rgba(0, 0, 0, .5); + --sev-crit: #fb7185; + --sev-err: #f87171; + --sev-warning: #fbbf24; + --sev-notice: #60a5fa; + --sev-info: #2dd4bf; + --sev-debug: #6b7280; + color-scheme: dark; + } +} + +/* ---------- Base ---------- */ +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.45 var(--sans); + padding-bottom: 40px; +} +button, input, select { font: inherit; color: inherit; } +button { cursor: pointer; } +svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.muted { color: var(--muted); } +.small { font-size: 12.5px; } +[hidden] { display: none !important; } + +/* ---------- Barre du haut ---------- */ +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; gap: 16px; + padding: 10px 20px; + background: color-mix(in srgb, var(--panel) 88%, transparent); + backdrop-filter: saturate(160%) blur(10px); + -webkit-backdrop-filter: saturate(160%) blur(10px); + border-bottom: 1px solid var(--border); +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 16px; letter-spacing: -.01em; } +.brand svg { width: 26px; height: 26px; } +.brand svg rect { fill: var(--accent); stroke: none; } +.brand svg path { stroke: #fff; stroke-width: 3; } + +.search { + flex: 1; max-width: 720px; + display: flex; align-items: center; gap: 8px; + padding: 0 6px 0 12px; height: 38px; + background: var(--panel-2); + border: 1px solid transparent; + border-radius: var(--radius); + transition: border-color .15s, box-shadow .15s; +} +.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.search .ico { width: 17px; height: 17px; color: var(--muted); flex: none; } +.search input { + flex: 1; min-width: 0; height: 100%; + border: 0; outline: 0; background: transparent; + font-family: var(--mono); font-size: 13px; +} +.search input::-webkit-search-cancel-button { cursor: pointer; } + +.chip { + flex: none; + border: 1px solid var(--border); background: var(--panel); + border-radius: 7px; padding: 3px 9px; font-size: 12px; font-weight: 600; + color: var(--muted); +} +.chip.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } + +.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; } +.icon-btn { + width: 36px; height: 36px; display: grid; place-items: center; + border: 1px solid transparent; background: transparent; border-radius: 9px; color: var(--muted); +} +.icon-btn:hover { background: var(--panel-2); color: var(--text); } +.icon-btn svg { width: 19px; height: 19px; } +.icon-btn.danger:hover { color: var(--sev-err); } + +/* Soleil ou lune selon le thème effectif */ +#themeBtn .moon { display: none; } +:root[data-theme="dark"] #themeBtn .sun { display: none; } +:root[data-theme="dark"] #themeBtn .moon { display: block; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) #themeBtn .sun { display: none; } + :root:not([data-theme="light"]) #themeBtn .moon { display: block; } +} + +.btn { + display: inline-flex; align-items: center; gap: 7px; + height: 34px; padding: 0 12px; + border: 1px solid var(--border); border-radius: 9px; + background: var(--panel); font-weight: 550; font-size: 13px; +} +.btn:hover { background: var(--panel-2); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); } +.btn.ghost:hover { color: var(--text); background: var(--panel-2); } + +.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } +.live.on { color: var(--sev-info); border-color: color-mix(in srgb, var(--sev-info) 45%, var(--border)); } +.live.on .dot { background: var(--sev-info); animation: pulse 1.6s infinite; } +.live:disabled { opacity: .5; cursor: not-allowed; } +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sev-info) 60%, transparent); } + 70% { box-shadow: 0 0 0 7px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +.progress { position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; overflow: hidden; } +body.busy .progress::after { + content: ""; position: absolute; inset: 0; width: 30%; + background: var(--accent); + animation: slide 1s ease-in-out infinite; +} +@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(400%); } } + +/* ---------- Filtres ---------- */ +.filters { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; + padding: 12px 20px 8px; +} +.filters select { + height: 32px; padding: 0 28px 0 10px; + border: 1px solid var(--border); border-radius: 8px; + background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b93a1' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 8px center / 14px; + appearance: none; -webkit-appearance: none; + font-size: 13px; max-width: 220px; +} +.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); } +.spacer { flex: 1; } +#count { font-size: 12.5px; font-variant-numeric: tabular-nums; } + +/* ---------- Histogramme ---------- */ +.histo { padding: 0 20px 6px; } +.histo .bars { + height: 46px; display: flex; align-items: flex-end; gap: 1px; + border-bottom: 1px solid var(--border); +} +.histo .bars i { + flex: 1; min-width: 1px; background: var(--accent); opacity: .55; + border-radius: 2px 2px 0 0; +} +.histo .bars i:hover { opacity: 1; } +.histo .axis { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); padding-top: 3px; } + +/* ---------- Liste des logs ---------- */ +.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.list:empty { display: none; } + +.row { + display: grid; + grid-template-columns: 8.4rem 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr; + gap: 0 12px; align-items: baseline; + padding: 5px 14px 5px 11px; + border-left: 3px solid transparent; + border-bottom: 1px solid var(--border); + font-family: var(--mono); font-size: 12.5px; + cursor: pointer; +} +.row:last-child { border-bottom: 0; } +.row:hover { background: var(--row-hover); } +.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.row.new { animation: flash 1.2s ease-out; } +@keyframes flash { from { background: var(--accent-soft); } to { background: transparent; } } + +.row time { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } +.row time .ms { opacity: .6; } +.row .host, .row .app { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row .host { font-weight: 600; } +.row .app { color: var(--muted); } +.row .msg { white-space: pre-wrap; word-break: break-word; min-width: 0; } + +.sev { + justify-self: start; + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; + padding: 1px 6px; border-radius: 5px; + color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent); +} +.sev-crit { --sev: var(--sev-crit); } +.sev-err { --sev: var(--sev-err); } +.sev-warning { --sev: var(--sev-warning); } +.sev-notice { --sev: var(--sev-notice); } +.sev-info { --sev: var(--sev-info); } +.sev-debug { --sev: var(--sev-debug); } +.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); } +.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 4%, transparent); } + +mark.tag { + padding: 0 4px; border-radius: 4px; font-weight: 650; + box-decoration-break: clone; -webkit-box-decoration-break: clone; +} +mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding: 0 1px; } + +/* Détails d'un message */ +.details { + grid-column: 1 / -1; + margin: 8px 0 4px; padding: 10px 12px; + background: var(--panel-2); border-radius: 8px; + cursor: auto; font-size: 12px; +} +.details dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; } +.details dt { color: var(--muted); } +.details dd { margin: 0; word-break: break-all; } +.details .acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; font-family: var(--sans); } +.details .acts .btn { height: 28px; font-size: 12px; } + +/* ---------- États ---------- */ +.empty { text-align: center; padding: 60px 20px; } +.empty svg { width: 40px; height: 40px; color: var(--muted); opacity: .6; } +.empty p { margin: 6px 0; } + +.error-banner { + margin: 6px 20px 0; padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--sev-err) 40%, transparent); + background: color-mix(in srgb, var(--sev-err) 10%, transparent); + color: var(--sev-err); border-radius: var(--radius); + font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; +} + +.pill { + position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30; + border: 0; border-radius: 999px; padding: 7px 16px; + background: var(--accent); color: #fff; font-weight: 600; font-size: 13px; + box-shadow: var(--shadow); +} + +.status { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; + display: flex; gap: 16px; align-items: center; + padding: 6px 20px; font-size: 12px; + background: var(--panel); border-top: 1px solid var(--border); + font-variant-numeric: tabular-nums; +} +.conn::before { content: "●"; margin-right: 6px; color: var(--muted); } +.conn.ok::before { color: var(--sev-info); } +.conn.ko::before { color: var(--sev-err); } +#stats .bad { color: var(--sev-err); } + +.toast { + position: fixed; bottom: 48px; left: 50%; transform: translateX(-50%); z-index: 50; + background: var(--text); color: var(--bg); + padding: 8px 14px; border-radius: 8px; font-size: 13px; box-shadow: var(--shadow); +} + +/* ---------- Fenêtre des tags ---------- */ +dialog { + width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px); + padding: 0; border: 1px solid var(--border); border-radius: 14px; + background: var(--panel); color: var(--text); box-shadow: var(--shadow); +} +dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); } +.dlg { padding: 18px 20px 16px; } +.dlg header { display: flex; align-items: center; justify-content: space-between; } +.dlg h2 { margin: 0; font-size: 17px; } +.dlg > footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; } + +.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } +.tag-row { + display: grid; align-items: center; gap: 8px 10px; + grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px; + padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px; + background: var(--panel-2); +} +.tag-row.off { opacity: .55; } +.tag-row input[type="color"] { + width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px; + background: none; cursor: pointer; +} +.tag-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 3px; } +.tag-row input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 5px; } +.tag-row input[type="text"] { + height: 32px; padding: 0 10px; min-width: 0; + border: 1px solid var(--border); border-radius: 8px; background: var(--panel); + font-family: var(--mono); font-size: 13px; outline: 0; +} +.tag-row input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.tag-row .preview { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; } +.tag-row .opts { display: flex; gap: 4px; } +.opt { + display: inline-flex; align-items: center; height: 28px; padding: 0 8px; + border: 1px solid var(--border); border-radius: 7px; background: var(--panel); + font-size: 11.5px; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none; +} +.opt input { display: none; } +.opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; } +.tag-row .err:empty { display: none; } + +/* ---------- Mobile ---------- */ +@media (max-width: 760px) { + body { font-size: 13.5px; } + .topbar { flex-wrap: wrap; padding: 8px 16px; gap: 8px 10px; } + .search { order: 3; flex-basis: 100%; max-width: none; } + .live .lbl { display: none; } + .filters { padding: 10px 16px 6px; } + .filters select { flex: 1 1 calc(50% - 8px); max-width: none; } + .spacer { display: none; } + #count { flex-basis: 100%; } + .histo { padding: 0 16px 6px; } + .list, .error-banner { margin-left: 16px; margin-right: 16px; } + .row { + grid-template-columns: auto auto 1fr; + grid-template-areas: "time sev host" "msg msg msg"; + gap: 3px 8px; padding: 8px 12px 8px 10px; + } + .row time { grid-area: time; } + .row .sev { grid-area: sev; } + .row .host { grid-area: host; justify-self: end; max-width: 100%; } + .row .app { display: none; } + .row .msg { grid-area: msg; } + .details { grid-column: 1 / -1; } + .details dl { grid-template-columns: 1fr; } + .details dt { margin-top: 4px; } + .status { padding: 6px 16px; } + .tag-row { grid-template-columns: 38px 1fr 36px; } + .tag-row .preview { display: none; } + .tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; } + .tag-row [data-del] { grid-column: 3; grid-row: 1; } +}