commit 8200c2bc87f40f8980cf456a2c898a73aa62b3c3 Author: Cedric Date: Mon Sep 28 12:19:29 2026 +0200 First commit 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; } +}