First commit
This commit is contained in:
commit
8200c2bc87
17 files changed
+2592
No files matched your search
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
.git
|
||||
tools
|
||||
*.md
|
||||
@@ -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=
|
||||
+19
@@ -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"]
|
||||
@@ -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 <http://localhost:8080>.
|
||||
|
||||
## 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.
|
||||
- <http://localhost:9428/select/vmui> : 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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<<attempt) * 500 * time.Millisecond) // 1s, 2s, 4s, 8s
|
||||
}
|
||||
if err = s.post(u, buf.Bytes()); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) post(u string, body []byte) error {
|
||||
resp, err := s.client.Post(u, "application/stream+json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("HTTP %d : %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query exécute une requête LogsQL et renvoie une ligne par résultat.
|
||||
func (s *Store) Query(ctx context.Context, query string) ([]map[string]any, error) {
|
||||
form := url.Values{"query": {query}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/select/logsql/query", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("%s", strings.TrimSpace(string(msg)))
|
||||
}
|
||||
|
||||
rows := []map[string]any{}
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 64*1024), 16<<20)
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal(line, &row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, sc.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Stats() map[string]any {
|
||||
return map[string]any{
|
||||
"received": s.received.Load(),
|
||||
"ingested": s.ingested.Load(),
|
||||
"dropped": s.dropped.Load(),
|
||||
"queue": len(s.in),
|
||||
"lastError": s.lastErr.Load(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var severityNames = [8]string{"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug"}
|
||||
|
||||
var facilityNames = [24]string{
|
||||
"kern", "user", "mail", "daemon", "auth", "syslog", "lpr", "news",
|
||||
"uucp", "cron", "authpriv", "ftp", "ntp", "security", "console", "solaris-cron",
|
||||
"local0", "local1", "local2", "local3", "local4", "local5", "local6", "local7",
|
||||
}
|
||||
|
||||
// Entry est un message syslog décodé.
|
||||
type Entry struct {
|
||||
Time time.Time
|
||||
Host string
|
||||
App string
|
||||
ProcID string
|
||||
Facility string
|
||||
Severity string
|
||||
SevNum int
|
||||
SD string // données structurées RFC 5424, brutes
|
||||
Message string
|
||||
Source string // IP de l'émetteur
|
||||
Proto string // udp ou tcp
|
||||
}
|
||||
|
||||
// Record renvoie l'entrée au format stocké dans VictoriaLogs et renvoyé par l'API.
|
||||
func (e *Entry) Record() map[string]string {
|
||||
r := map[string]string{
|
||||
"_time": e.Time.UTC().Format(time.RFC3339Nano),
|
||||
"_msg": e.Message,
|
||||
"host": e.Host,
|
||||
"app": e.App,
|
||||
"facility": e.Facility,
|
||||
"severity": e.Severity,
|
||||
"sevnum": strconv.Itoa(e.SevNum),
|
||||
"source": e.Source,
|
||||
"proto": e.Proto,
|
||||
}
|
||||
if e.ProcID != "" {
|
||||
r["procid"] = e.ProcID
|
||||
}
|
||||
if e.SD != "" {
|
||||
r["sd"] = e.SD
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (e *Entry) setPri(pri int) {
|
||||
e.SevNum = pri % 8
|
||||
e.Severity = severityNames[pri%8]
|
||||
e.Facility = facilityNames[pri/8]
|
||||
}
|
||||
|
||||
// ParseSyslog décode un message RFC 5424 ou RFC 3164 (BSD). Il ne renvoie
|
||||
// jamais d'erreur : un message non conforme est conservé tel quel.
|
||||
func ParseSyslog(raw []byte, source, proto string, now time.Time) *Entry {
|
||||
s := strings.TrimRight(string(raw), "\r\n\x00 ")
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, "�")
|
||||
}
|
||||
e := &Entry{Time: now, Host: source, Source: source, Proto: proto}
|
||||
e.setPri(13) // user.notice, valeur par défaut de la RFC 3164
|
||||
|
||||
if len(s) > 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Executable
+47
@@ -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)"
|
||||
+586
@@ -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 = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
|
||||
try {
|
||||
out.push({ re: new RegExp(src, 'gu' + (t.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(t.color)}">` });
|
||||
} 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: '<mark class="hit">' });
|
||||
}
|
||||
}
|
||||
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)) + '</mark>';
|
||||
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())}<span class="ms">.${pad(d.getMilliseconds(), 3)}</span>`;
|
||||
}
|
||||
|
||||
function rowHTML(r, isNew) {
|
||||
const sev = r.severity || '';
|
||||
const cls = SEV_CLASS[sev] || 'info';
|
||||
return `<article class="row sev-${cls}${isNew ? ' new' : ''}" tabindex="0">`
|
||||
+ `<time>${fmtTime(r._time)}</time>`
|
||||
+ `<span class="sev">${esc(sev || '—')}</span>`
|
||||
+ `<span class="host" title="${esc(r.host)}">${esc(r.host)}</span>`
|
||||
+ `<span class="app" title="${esc(r.app)}">${esc(r.app)}</span>`
|
||||
+ `<div class="msg">${highlight(r._msg)}</div>`
|
||||
+ '</article>';
|
||||
}
|
||||
|
||||
// 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'))} <span class="muted">(${esc(r[k])})</span>` : esc(r[k]));
|
||||
return '<div class="details"><dl>'
|
||||
+ keys.map((k) => `<dt>${esc(label[k] || k)}</dt><dd>${val(k)}</dd>`).join('')
|
||||
+ '</dl><div class="acts">'
|
||||
+ (r.host ? '<button class="btn" data-act="host">Filtrer sur cet hôte</button>' : '')
|
||||
+ (r.app ? '<button class="btn" data-act="app">Filtrer sur cette appli</button>' : '')
|
||||
+ '<button class="btn" data-act="copy">Copier le message</button>'
|
||||
+ '<button class="btn" data-act="json">Copier en JSON</button>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
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 += `<i style="height:${hgt}%" title="${label(k)} : ${fmtNum(n)}"></i>`;
|
||||
}
|
||||
el.innerHTML = `<div class="bars">${bars}</div><div class="axis"><span>${label(start)}</span><span>max ${fmtNum(max)} / intervalle</span><span>${label(end)}</span></div>`;
|
||||
}
|
||||
|
||||
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 ? ` · <span class="bad">stockage : ${esc(s.lastError)}</span>` : '';
|
||||
const dropped = s.dropped ? `<span class="bad">perdus ${fmtNum(s.dropped)}</span>` : '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) =>
|
||||
`<label class="opt" title="${title}"><input type="checkbox" data-f="${field}"${t[field] ? ' checked' : ''}>${label}</label>`;
|
||||
return `<div class="tag-row${t.enabled ? '' : ' off'}" data-id="${esc(t.id)}">
|
||||
<input type="color" value="${esc(t.color)}" data-f="color" aria-label="Couleur">
|
||||
<input type="text" value="${esc(t.pattern)}" data-f="pattern" placeholder="mot-clé" spellcheck="false" aria-label="Mot-clé">
|
||||
<span class="preview"><mark class="tag" style="${tagStyle(t.color)}">${esc(t.pattern || 'aperçu')}</mark></span>
|
||||
<div class="opts">
|
||||
${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')}
|
||||
</div>
|
||||
<button class="icon-btn danger" type="button" data-del title="Supprimer" aria-label="Supprimer">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
|
||||
</button>
|
||||
<div class="err"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTagList() {
|
||||
$('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || '<p class="muted small">Aucun tag.</p>';
|
||||
}
|
||||
|
||||
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);
|
||||
})();
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Logwell</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script>
|
||||
// Applique le thème mémorisé avant l'affichage, pour éviter un flash.
|
||||
try { var t = localStorage.getItem('logwell.theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
||||
<span>Logwell</span>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<input id="q" type="search" autocomplete="off" spellcheck="false" aria-label="Rechercher dans les logs">
|
||||
<button id="mode" class="chip" type="button" title="Basculer entre recherche simple et LogsQL">Simple</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="live" class="btn live" type="button" title="Afficher les nouveaux messages en direct">
|
||||
<span class="dot"></span><span class="lbl">Direct</span>
|
||||
</button>
|
||||
<button id="tagsBtn" class="icon-btn" type="button" title="Tags de couleur" aria-label="Tags de couleur">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></svg>
|
||||
</button>
|
||||
<button id="themeBtn" class="icon-btn" type="button" title="Thème clair / sombre" aria-label="Changer de thème">
|
||||
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="progress" aria-hidden="true"></div>
|
||||
</header>
|
||||
|
||||
<section class="filters">
|
||||
<select id="range" aria-label="Période">
|
||||
<option value="5m">5 min</option>
|
||||
<option value="15m">15 min</option>
|
||||
<option value="1h" selected>1 heure</option>
|
||||
<option value="6h">6 heures</option>
|
||||
<option value="24h">24 heures</option>
|
||||
<option value="7d">7 jours</option>
|
||||
<option value="30d">30 jours</option>
|
||||
<option value="all">Tout</option>
|
||||
</select>
|
||||
<select id="severity" aria-label="Sévérité">
|
||||
<option value="">Toutes sévérités</option>
|
||||
<option value="2">Critiques et +</option>
|
||||
<option value="3">Erreurs et +</option>
|
||||
<option value="4">Warnings et +</option>
|
||||
<option value="5">Notice et +</option>
|
||||
<option value="6">Info et +</option>
|
||||
</select>
|
||||
<select id="host" aria-label="Hôte"><option value="">Tous les hôtes</option></select>
|
||||
<select id="app" aria-label="Application"><option value="">Toutes les applis</option></select>
|
||||
<span class="spacer"></span>
|
||||
<span id="count" class="muted"></span>
|
||||
</section>
|
||||
|
||||
<section id="histo" class="histo" aria-label="Volume de logs dans le temps"></section>
|
||||
|
||||
<div id="error" class="error-banner" hidden></div>
|
||||
<button id="newPill" class="pill" type="button" hidden></button>
|
||||
|
||||
<main id="list" class="list"></main>
|
||||
<div id="empty" class="empty" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/></svg>
|
||||
<p><strong>Aucun log ne correspond.</strong></p>
|
||||
<p class="muted">Élargissez la période ou envoyez des messages syslog sur le port 514 (UDP ou TCP).</p>
|
||||
</div>
|
||||
|
||||
<footer class="status">
|
||||
<span id="conn" class="conn"></span>
|
||||
<span id="stats" class="muted"></span>
|
||||
</footer>
|
||||
|
||||
<dialog id="tagsDlg" aria-labelledby="tagsTitle">
|
||||
<div class="dlg">
|
||||
<header>
|
||||
<h2 id="tagsTitle">Tags de couleur</h2>
|
||||
<button id="closeTags" class="icon-btn" type="button" aria-label="Fermer">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<p class="muted small">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.</p>
|
||||
<div id="tagList" class="tag-list"></div>
|
||||
<footer>
|
||||
<button id="addTag" class="btn primary" type="button">+ Ajouter un tag</button>
|
||||
<button id="resetTags" class="btn ghost" type="button">Rétablir les tags par défaut</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+380
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user