diff --git a/.env.example b/.env.example index c0be8ed..e0c3abb 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -# Copiez ce fichier en .env et adaptez-le. +# Copy this file to .env and adjust it. SYSLOG_PORT=514 HTTP_PORT=8080 TZ=Europe/Paris -# Durée de conservation des logs (ex. 7d, 30d, 12w, 1y) +# How long logs are kept (e.g. 7d, 30d, 12w, 1y) RETENTION=30d -# Authentification de l'interface web (vide = désactivée) +# Web UI authentication (empty = disabled) AUTH_USER= AUTH_PASS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile index 5457418..c6d3b21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# ---- Compilation ---- +# ---- Build ---- FROM golang:1.23-alpine AS build WORKDIR /src COPY go.mod ./ @@ -6,14 +6,14 @@ COPY *.go ./ COPY web ./web RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream . -# ---- Image finale (~15 Mo) ---- +# ---- Final image (~15 MB) ---- FROM alpine:3.20 RUN adduser -D -H -u 10001 logstream \ && mkdir /data && chown logstream /data COPY --from=build /out/logstream /usr/local/bin/logstream USER logstream VOLUME /data -# 5514 en interne (port non privilégié), publié sur 514 par docker-compose +# 5514 inside the container (unprivileged port), published as 514 by 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/logstream"] diff --git a/README.md b/README.md index 7980662..70da5ce 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,102 @@ # Logstream -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). +A simple syslog sink: receives logs over UDP/TCP on port 514, stores them in +[VictoriaLogs](https://docs.victoriametrics.com/victorialogs/), and serves a clean web +interface (light/dark theme, live search, live view, color tags, English/French UI). ``` -équipements ──514 udp/tcp──▶ logstream (Go) ──lots HTTP──▶ VictoriaLogs - ▲ └── SSE (direct) ──▶ navigateur - └──── API / interface ◀──┘ +devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ VictoriaLogs + ▲ └── SSE (live) ──▶ browser + └──── API / UI ◀─────────┘ ``` -## Démarrage +## Getting started ```bash -cp .env.example .env # optionnel +cp .env.example .env # optional docker compose up -d --build -./tools/send-test-logs.sh # envoie 100 messages de test +./tools/send-test-logs.sh # sends 100 test messages ``` -Puis ouvrez . +Then open . -## Envoyer ses logs +## Sending logs -- **rsyslog** (Linux) : ajoutez `*.* @IP_DU_SERVEUR:514` (UDP) ou `*.* @@IP_DU_SERVEUR:514` (TCP) - dans `/etc/rsyslog.d/90-logstream.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 +- **rsyslog** (Linux): add `*.* @SERVER_IP:514` (UDP) or `*.* @@SERVER_IP:514` (TCP) + to `/etc/rsyslog.d/90-logstream.conf`, then run `systemctl restart rsyslog`. +- **Network gear, NAS, firewalls**: set the server IP and port 514 in their "remote syslog" settings. +- **Manual test**: `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) or `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. +Supported formats: RFC 3164 (BSD) and RFC 5424. Over TCP, both "one message per line" +and "octet counting" (RFC 6587) framing are accepted. -## Recherche +## Search -**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. +**Simple mode** (default): each word is matched as a case-insensitive substring of the +message, host and app. Words are combined with AND. -| Saisie | Effet | +| Input | Meaning | |---|---| -| `error disk` | contient « error » **et** « disk » | -| `"disk full"` | contient l'expression exacte | -| `error -timeout` | contient « error » mais pas « timeout » | +| `error disk` | contains "error" **and** "disk" | +| `"disk full"` | contains the exact phrase | +| `error -timeout` | contains "error" but not "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. +**LogsQL mode** (`Simple` / `LogsQL` button): the full VictoriaLogs query language, e.g. +`error AND host:web-01`, `app:~"ssh|nginx"`, or `* | stats by (host) count()`. +The live view is disabled in this mode. -Raccourcis : `/` place le curseur dans la recherche, `Échap` la vide. Un clic sur une ligne -affiche tous ses champs. +Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields. -## Tags de couleur +## Settings -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 `logstream-data`). Tags par défaut : `warning` (orange), -`error` (rouge), `ok` (vert). +The gear icon opens the settings panel: + +- **Language**: English or French. The choice is remembered in the browser. +- **Color tags**: each tag has a keyword, a background color (the text automatically + switches to black or white to stay readable) and options: whole word, match case, + regular expression, active. Tags are stored in `/data/tags.json` (`logstream-data` volume). + Default tags: `warning` (orange), `error` (red), `ok` (green). ## Configuration -| Variable | Défaut | Rôle | +| Variable | Default | Purpose | |---|---|---| -| `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 | +| `SYSLOG_PORT` | `514` | syslog port published on the host | +| `HTTP_PORT` | `8080` | web UI port | +| `RETENTION` | `30d` | how long VictoriaLogs keeps logs | +| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI | +| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) | +| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning | -## Débogage +## Debugging -- `docker compose logs -f logstream` : erreurs de réception et d'envoi vers VictoriaLogs. -- La barre du bas affiche les compteurs reçus / stockés / perdus et la dernière erreur de stockage. -- : interface native de VictoriaLogs pour tester des requêtes LogsQL. -- API : +- `docker compose logs -f logstream`: receive errors and errors sending to VictoriaLogs. +- The bottom bar shows received / stored / dropped counters and the last storage error. +- : VictoriaLogs' own UI to try LogsQL queries. +- 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/logs?q=error&range=1h&limit=5' # the response includes the generated LogsQL query 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 .` +- Running outside Docker (Go 1.22+): `VLOGS_URL=http://localhost:9428 DATA_DIR=./data SYSLOG_ADDR=:5514 go run .` -## Structure du code +## Code layout -| Fichier | Contenu | +| File | Contents | |---|---| -| `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 | +| `main.go` | configuration, startup, authentication | +| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing | +| `store.go` | batched inserts into VictoriaLogs and LogsQL queries | +| `query.go` | turns UI filters into LogsQL; live-view filter | +| `hub.go` | pushes new messages to browsers (SSE) | +| `tags.go` | color tag storage | +| `api.go` | `/api/*` HTTP routes | +| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) | -## Remarque +## Note -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. +With Docker Desktop (macOS/Windows), the source IP seen by the container for UDP packets is +the Docker gateway. The `host` field still comes from the syslog header, which normally +carries the sender's real name. diff --git a/api.go b/api.go index 2d21705..64f33a3 100644 --- a/api.go +++ b/api.go @@ -36,8 +36,18 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } +// writeErr returns {"error": "...", "code": "...", "detail": "..."}; the UI +// translates known codes and falls back to the English message. func writeErr(w http.ResponseWriter, status int, err error) { - writeJSON(w, status, map[string]string{"error": err.Error()}) + body := map[string]string{"error": err.Error()} + var ce *codedError + if errors.As(err, &ce) { + body["code"] = ce.code + if ce.detail != "" { + body["detail"] = ce.detail + } + } + writeJSON(w, status, body) } func (a *API) health(w http.ResponseWriter, r *http.Request) { @@ -75,7 +85,7 @@ var histSteps = map[string]struct { "24h": {"15m", 900}, "7d": {"2h", 7200}, "30d": {"12h", 43200}, } -// GET /api/histogram : nombre de messages par intervalle de temps. +// GET /api/histogram: message count per time bucket. func (a *API) histogram(w http.ResponseWriter, r *http.Request) { f := FilterFromRequest(r) step, ok := histSteps[f.Range] @@ -112,7 +122,7 @@ func toInt(v any) int64 { return 0 } -// GET /api/facets : hôtes et applis vus sur 7 jours, pour les listes de filtres. +// GET /api/facets: hosts and apps seen over 7 days, for the filter dropdowns. func (a *API) facets(w http.ResponseWriter, r *http.Request) { res := map[string][]string{} for _, field := range []string{"host", "app"} { @@ -134,12 +144,12 @@ func (a *API) facets(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, res) } -// GET /api/stream : flux SSE des nouveaux messages correspondant au filtre, -// envoyés par paquets toutes les 250 ms. +// GET /api/stream: SSE stream of new messages matching the filter, +// sent in batches every 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")) + writeErr(w, http.StatusBadRequest, &codedError{code: "live_logsql", msg: "live view is not available in LogsQL mode"}) return } m := f.Matcher() @@ -150,7 +160,7 @@ func (a *API) stream(w http.ResponseWriter, r *http.Request) { h.Set("Cache-Control", "no-cache") h.Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, ": connecté\n\n") + _, _ = fmt.Fprint(w, ": connected\n\n") _ = rc.Flush() ch := a.hub.Subscribe() @@ -177,7 +187,7 @@ func (a *API) stream(w http.ResponseWriter, r *http.Request) { case <-tick.C: var err error if len(pending) == 0 && skipped == 0 { - // Commentaire SSE toutes les 15 s pour garder la connexion ouverte. + // SSE comment every 15 s to keep the connection open. if idle++; idle < 60 { continue } diff --git a/docker-compose.yml b/docker-compose.yml index c93ac4a..ed6f2c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,13 +12,13 @@ services: environment: VLOGS_URL: http://victorialogs:9428 TZ: ${TZ:-Europe/Paris} - AUTH_USER: ${AUTH_USER:-} # laisser vide pour désactiver l'authentification + AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication AUTH_PASS: ${AUTH_PASS:-} volumes: - logstream-data:/data # tags.json victorialogs: - # Épinglez une version précise en production (voir hub.docker.com/r/victoriametrics/victoria-logs/tags) + # Pin a specific version in production (see hub.docker.com/r/victoriametrics/victoria-logs/tags) image: victoriametrics/victoria-logs:latest container_name: logstream-victorialogs restart: unless-stopped @@ -29,7 +29,7 @@ services: volumes: - vlogs-data:/vlogs ports: - # Interface de debug VictoriaLogs (http://localhost:9428/select/vmui), locale uniquement + # VictoriaLogs debug UI (http://localhost:9428/select/vmui), localhost only - "127.0.0.1:9428:9428" volumes: diff --git a/hub.go b/hub.go index f3ea4d0..48f2bd0 100644 --- a/hub.go +++ b/hub.go @@ -2,7 +2,7 @@ package main import "sync" -// Hub diffuse les messages reçus aux navigateurs connectés en direct (SSE). +// Hub fans out incoming messages to browsers following the live view (SSE). type Hub struct { mu sync.RWMutex subs map[chan *Entry]struct{} @@ -26,7 +26,7 @@ func (h *Hub) Unsubscribe(ch chan *Entry) { h.mu.Unlock() } -// Publish ne bloque jamais : un client trop lent perd simplement des messages. +// Publish never blocks: a slow client simply misses messages. func (h *Hub) Publish(e *Entry) { h.mu.RLock() defer h.mu.RUnlock() diff --git a/main.go b/main.go index 789a166..070474d 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ -// Logstream : puits de logs syslog (UDP/TCP) stocké dans VictoriaLogs, -// avec une interface web de consultation. +// Logstream: a syslog (UDP/TCP) sink stored in VictoriaLogs, +// with a web interface to browse and search the logs. package main import ( @@ -17,7 +17,7 @@ import ( "strconv" "syscall" "time" - _ "time/tzdata" // fuseaux horaires embarqués : la variable TZ fonctionne sans paquet système + _ "time/tzdata" // embedded time zones: TZ works without a system package ) //go:embed web @@ -67,7 +67,7 @@ func main() { tags, err := LoadTagStore(filepath.Join(cfg.dataDir, "tags.json")) if err != nil { - log.Fatalf("tags : %v", err) + log.Fatalf("tags: %v", err) } store := NewStore(cfg.vlogsURL, cfg.batchSize, cfg.queueSize, cfg.flushEvery) @@ -83,7 +83,7 @@ func main() { hub.Publish(e) } if err := StartSyslog(ctx, cfg.syslogAddr, sink); err != nil { - log.Fatalf("syslog : %v", err) + log.Fatalf("syslog: %v", err) } static, err := fs.Sub(webFS, "web") @@ -99,7 +99,7 @@ func main() { 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. + // Requests inherit the global context so SSE streams end on shutdown. BaseContext: func(net.Listener) context.Context { return ctx }, } go func() { @@ -109,15 +109,15 @@ func main() { _ = srv.Shutdown(shutdownCtx) }() - log.Printf("interface web sur %s, syslog sur %s (udp+tcp), stockage %s", cfg.httpAddr, cfg.syslogAddr, cfg.vlogsURL) + log.Printf("web UI on %s, syslog on %s (udp+tcp), storage %s", cfg.httpAddr, cfg.syslogAddr, cfg.vlogsURL) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("http : %v", err) + log.Fatalf("http: %v", err) } <-storeDone - log.Println("arrêt terminé") + log.Println("shutdown complete") } -// basicAuth protège l'interface si AUTH_USER est défini (sauf /healthz). +// basicAuth protects the UI when AUTH_USER is set (except /healthz). func basicAuth(user, pass string, next http.Handler) http.Handler { if user == "" { return next @@ -132,7 +132,7 @@ func basicAuth(user, pass string, next http.Handler) http.Handler { subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 { w.Header().Set("WWW-Authenticate", `Basic realm="logstream"`) - http.Error(w, "authentification requise", http.StatusUnauthorized) + http.Error(w, "authentication required", http.StatusUnauthorized) return } next.ServeHTTP(w, r) diff --git a/query.go b/query.go index ea4b9e3..d05ea45 100644 --- a/query.go +++ b/query.go @@ -7,14 +7,14 @@ import ( "strings" ) -// Filter décrit les critères de recherche envoyés par l'interface. +// Filter holds the search criteria sent by the UI. type Filter struct { - Mode string // "simple" ou "logsql" + Mode string // "simple" or "logsql" Text string - Range string // 5m, 15m, 1h, 6h, 24h, 7d, 30d ; autre valeur = pas de limite + Range string // 5m, 15m, 1h, 6h, 24h, 7d, 30d; anything else = no time limit Host string App string - Severity int // sévérité maximale incluse (0 = emerg … 7 = debug), -1 = toutes + Severity int // highest severity number included (0 = emerg … 7 = debug), -1 = all } var validRanges = map[string]bool{"5m": true, "15m": true, "1h": true, "6h": true, "24h": true, "7d": true, "30d": true} @@ -40,7 +40,7 @@ type term struct { neg bool } -// parseTerms découpe la recherche simple : mots, "phrases entre guillemets", -exclusions. +// parseTerms splits a simple search into words, "quoted phrases" and -exclusions. func parseTerms(s string) []term { var out []term for i := 0; i < len(s); { @@ -78,7 +78,7 @@ func parseTerms(s string) []term { return out } -// filterExpr traduit les critères (hors texte LogsQL brut) en filtre LogsQL. +// filterExpr turns the criteria (except raw LogsQL text) into a LogsQL filter. func (f Filter) filterExpr() string { var parts []string if validRanges[f.Range] { @@ -98,7 +98,7 @@ func (f Filter) filterExpr() string { 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. + // Case-insensitive substring search in the message, host and app. for _, t := range parseTerms(f.Text) { re := strconv.Quote("(?i)" + regexp.QuoteMeta(t.text)) expr := "(_msg:~" + re + " or host:~" + re + " or app:~" + re + ")" @@ -111,7 +111,7 @@ func (f Filter) filterExpr() string { return strings.Join(parts, " ") } -// LogsQL renvoie la partie filtre et les éventuels pipes (« | … ») saisis en mode LogsQL. +// LogsQL returns the filter part and any pipes ("| …") typed in LogsQL mode. func (f Filter) LogsQL() (filter, pipes string) { filter = f.filterExpr() if f.Mode == "logsql" && f.Text != "" { @@ -127,7 +127,7 @@ func (f Filter) LogsQL() (filter, pipes string) { return filter, pipes } -// splitPipes sépare le filtre des pipes, en ignorant les « | » entre guillemets. +// splitPipes separates the filter from the pipes, ignoring "|" inside quotes. func splitPipes(q string) (filter, pipes string) { var quote byte for i := 0; i < len(q); i++ { @@ -148,7 +148,7 @@ func splitPipes(q string) (filter, pipes string) { return strings.TrimSpace(q), "" } -// Matcher applique le même filtre que filterExpr aux messages du flux direct. +// Matcher applies the same filter as filterExpr to live messages. type Matcher struct { f Filter res []*regexp.Regexp diff --git a/store.go b/store.go index d6d8f90..142cd26 100644 --- a/store.go +++ b/store.go @@ -15,7 +15,7 @@ import ( "time" ) -// Store envoie les messages à VictoriaLogs par lots et l'interroge en LogsQL. +// Store sends messages to VictoriaLogs in batches and queries it with LogsQL. type Store struct { base string client *http.Client @@ -41,7 +41,7 @@ func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration) * return s } -// Enqueue ne bloque jamais : si la file est pleine, le message est compté comme perdu. +// Enqueue never blocks: when the queue is full, the message is counted as dropped. func (s *Store) Enqueue(e *Entry) { s.received.Add(1) select { @@ -51,8 +51,8 @@ func (s *Store) Enqueue(e *Entry) { } } -// Run vide la file vers VictoriaLogs jusqu'à l'annulation du contexte, -// puis envoie ce qui reste. +// Run drains the queue into VictoriaLogs until the context is cancelled, +// then flushes whatever is left. func (s *Store) Run(ctx context.Context) { ticker := time.NewTicker(s.flushEvery) defer ticker.Stop() @@ -65,7 +65,7 @@ func (s *Store) Run(ctx context.Context) { 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) + log.Printf("victorialogs: %d messages dropped: %v", len(batch), err) } else { s.ingested.Add(int64(len(batch))) s.lastErr.Store("") @@ -130,13 +130,13 @@ func (s *Store) post(u string, body []byte) error { 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))) + 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. +// Query runs a LogsQL query and returns one row per result. 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())) diff --git a/syslog.go b/syslog.go index c7d1c58..e64a468 100644 --- a/syslog.go +++ b/syslog.go @@ -20,7 +20,7 @@ var facilityNames = [24]string{ "local0", "local1", "local2", "local3", "local4", "local5", "local6", "local7", } -// Entry est un message syslog décodé. +// Entry is a decoded syslog message. type Entry struct { Time time.Time Host string @@ -29,13 +29,13 @@ type Entry struct { Facility string Severity string SevNum int - SD string // données structurées RFC 5424, brutes + SD string // raw RFC 5424 structured data Message string - Source string // IP de l'émetteur - Proto string // udp ou tcp + Source string // sender IP address + Proto string // udp or tcp } -// Record renvoie l'entrée au format stocké dans VictoriaLogs et renvoyé par l'API. +// Record returns the entry in the shape stored in VictoriaLogs and returned by the API. func (e *Entry) Record() map[string]string { r := map[string]string{ "_time": e.Time.UTC().Format(time.RFC3339Nano), @@ -63,15 +63,15 @@ func (e *Entry) setPri(pri int) { 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. +// ParseSyslog decodes an RFC 5424 or RFC 3164 (BSD) message. It never fails: +// a non-conforming message is kept as is. 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, "�") + s = strings.ToValidUTF8(s, "\uFFFD") } e := &Entry{Time: now, Host: source, Source: source, Proto: proto} - e.setPri(13) // user.notice, valeur par défaut de la RFC 3164 + e.setPri(13) // user.notice, the RFC 3164 default if len(s) > 2 && s[0] == '<' { if end := strings.IndexByte(s, '>'); end > 1 && end <= 4 { @@ -87,12 +87,12 @@ func ParseSyslog(raw []byte, source, proto string, now time.Time) *Entry { parse3164(e, s) } if e.Message == "" { - e.Message = "(vide)" + e.Message = "(empty)" } return e } -// parse5424 : TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG +// 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 { @@ -120,10 +120,10 @@ func parse5424(e *Entry, s string) { e.SD = rest[:n] rest = strings.TrimPrefix(rest[n:], " ") } - e.Message = strings.TrimPrefix(rest, "\uFEFF") + e.Message = strings.TrimPrefix(rest, "\uFEFF") // optional UTF-8 BOM } -// sdLen renvoie la longueur du bloc de données structurées « [..][..] ». +// sdLen returns the length of the structured data block "[..][..]". func sdLen(s string) int { i := 0 for i < len(s) && s[i] == '[' { @@ -150,11 +150,11 @@ func sdLen(s string) int { return i } -// parse3164 : « Mmm dd hh:mm:ss HOST TAG[PID]: MSG », ou horodatage ISO (rsyslog). +// parse3164: "Mmm dd hh:mm:ss HOST TAG[PID]: MSG", or an ISO timestamp (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. + // No year in the timestamp: use the current one, unless that lands in the future. 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)) { @@ -203,7 +203,7 @@ func parseHostTag(e *Entry, s string, withHost bool) { e.Message = s } -// StartSyslog démarre les écoutes UDP et TCP sur la même adresse. +// StartSyslog starts the UDP and TCP listeners on the same address. func StartSyslog(ctx context.Context, addr string, sink func(*Entry)) error { pc, err := net.ListenPacket("udp", addr) if err != nil { @@ -235,7 +235,7 @@ func serveUDP(ctx context.Context, pc net.PacketConn, sink func(*Entry)) { if ctx.Err() != nil { return } - log.Printf("syslog udp : %v", err) + log.Printf("syslog udp: %v", err) time.Sleep(100 * time.Millisecond) continue } @@ -250,7 +250,7 @@ func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) { if ctx.Err() != nil { return } - log.Printf("syslog tcp : %v", err) + log.Printf("syslog tcp: %v", err) time.Sleep(100 * time.Millisecond) continue } @@ -260,8 +260,8 @@ func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) { 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. +// handleTCP supports both RFC 6587 framings: octet counting +// ("123 <34>1 ...") and one message per line. func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) { defer conn.Close() stop := context.AfterFunc(ctx, func() { conn.Close() }) @@ -288,7 +288,7 @@ func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) { break } if c < '0' || c > '9' || n > maxFrame { - log.Printf("syslog tcp %s : trame invalide, connexion fermée", src) + log.Printf("syslog tcp %s: invalid frame, closing connection", src) return } n = n*10 + int(c-'0') @@ -307,7 +307,7 @@ func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) { if len(line) > 0 { sink(ParseSyslog(line, src, "tcp", time.Now())) } - // Ligne plus longue que le tampon : on ignore la suite. + // Line longer than the buffer: skip the rest of it. for err == bufio.ErrBufferFull { _, err = r.ReadSlice('\n') } diff --git a/tags.go b/tags.go index bc2eb76..d5dc81a 100644 --- a/tags.go +++ b/tags.go @@ -13,7 +13,7 @@ import ( "sync" ) -// Tag colore un mot-clé dans les messages affichés. +// Tag highlights a keyword in displayed messages. type Tag struct { ID string `json:"id"` Pattern string `json:"pattern"` @@ -32,28 +32,42 @@ func defaultTags() []Tag { } } +// codedError carries a stable code the UI can translate. +type codedError struct { + code string + msg string + detail string +} + +func (e *codedError) Error() string { + if e.detail != "" { + return e.msg + ": " + e.detail + } + return e.msg +} + var ( - errTagNotFound = errors.New("tag introuvable") + errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"} 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") + return &codedError{code: "pattern_required", msg: "the keyword is required"} } if !colorRe.MatchString(t.Color) { - return errors.New("couleur invalide (format #rrggbb attendu)") + return &codedError{code: "invalid_color", msg: "invalid color (expected #rrggbb)"} } if t.Regex { if _, err := regexp.Compile(t.Pattern); err != nil { - return fmt.Errorf("expression régulière invalide : %v", err) + return &codedError{code: "invalid_regex", msg: "invalid regular expression", detail: err.Error()} } } return nil } -// TagStore conserve les tags dans un fichier JSON (volume /data). +// TagStore keeps the tags in a JSON file (in the /data volume). type TagStore struct { mu sync.Mutex path string @@ -71,12 +85,12 @@ func LoadTagStore(path string) (*TagStore, error) { return nil, err } if err := json.Unmarshal(b, &s.tags); err != nil { - return nil, fmt.Errorf("%s : %w", path, err) + 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. +// save writes the file atomically; the caller holds the lock. func (s *TagStore) save() error { b, err := json.MarshalIndent(s.tags, "", " ") if err != nil { diff --git a/tools/send-test-logs.sh b/tools/send-test-logs.sh index 6e4e2fc..7f1f229 100755 --- a/tools/send-test-logs.sh +++ b/tools/send-test-logs.sh @@ -1,6 +1,6 @@ #!/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] +# Sends test syslog messages (RFC 3164 and RFC 5424) over UDP. +# Usage: ./tools/send-test-logs.sh [host] [port] [count] set -eu HOST=${1:-127.0.0.1} @@ -10,7 +10,7 @@ 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) +# "severity|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" @@ -44,4 +44,4 @@ for ((i = 0; i < COUNT; i++)); do printf '%s' "$line" > "/dev/udp/$HOST/$PORT" sleep 0.05 done -echo "$COUNT messages envoyés à $HOST:$PORT (udp)" +echo "$COUNT messages sent to $HOST:$PORT (udp)" diff --git a/web/app.js b/web/app.js index 5dddfd8..738a259 100644 --- a/web/app.js +++ b/web/app.js @@ -1,24 +1,137 @@ 'use strict'; -/* ================= Utilitaires ================= */ +/* ================= Translations ================= */ + +const I18N = { + en: { + locale: 'en-US', + searchAria: 'Search logs', + searchSimple: 'Search… error -timeout "disk full" (press /)', + searchLogsql: 'LogsQL: error AND host:web-01 | sort by (_time)', + modeTitle: 'Switch between simple search and LogsQL', + live: 'Live', + liveTitle: 'Show new messages live', + liveUnavailable: 'Live view is not available in LogsQL mode', + settings: 'Settings', + theme: 'Light / dark theme', + close: 'Close', + rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application', + histoAria: 'Log volume over time', + r5m: '5 min', r15m: '15 min', r1h: '1 hour', r6h: '6 hours', r24h: '24 hours', r7d: '7 days', r30d: '30 days', rAll: 'All', + sevAll: 'All severities', sev2: 'Critical and above', sev3: 'Errors and above', + sev4: 'Warnings and above', sev5: 'Notice and above', sev6: 'Info and above', + allHosts: 'All hosts', allApps: 'All apps', + emptyTitle: 'No matching logs.', + emptyHint: 'Widen the time range or send syslog messages to port 514 (UDP or TCP).', + count: (n) => `${n} ${n === 1 ? 'log' : 'logs'} shown`, + histoMax: (n) => `max ${n} / interval`, + searchError: 'Search error: ', + connLive: 'live', connConnecting: 'connecting…', connRetry: 'reconnecting…', + connPaused: 'live paused', connLogsql: 'live unavailable (LogsQL)', + newLogs: (n) => `↑ ${n} new ${n === 1 ? 'log' : 'logs'}`, + skipped: (n) => `${n} messages not shown (rate too high)`, + stats: (s) => `received ${s.received} · stored ${s.ingested} · dropped ${s.dropped} · queue ${s.queue}`, + storageErr: 'storage: ', + unreachable: 'server unreachable', + fTime: 'timestamp', fMsg: 'message', fPid: 'pid', fSd: 'structured data', + filterHost: 'Filter on this host', filterApp: 'Filter on this app', + copyMsg: 'Copy message', copyJson: 'Copy as JSON', copied: 'Copied', + language: 'Language', + tagsTitle: 'Color tags', + tagsHelp: 'Keywords are highlighted in messages. Changes are saved automatically. When tags overlap, the one highest in the list wins.', + addTag: '+ Add tag', resetTags: 'Restore default tags', + optWhole: 'whole word', optWholeTitle: 'Only color whole words (avoids "ok" in "token")', + optCaseTitle: 'Match case', optRegexTitle: 'Regular expression', + optActive: 'active', optActiveTitle: 'Enable this tag', + colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.', + newTag: 'new', + confirmDelete: (p) => `Delete tag "${p}"?`, + confirmReset: 'Replace all tags with the defaults (warning, error, ok)?', + tagsLoadErr: 'Tags: ', + err_pattern_required: 'The keyword is required', + err_invalid_color: 'Invalid color (expected #rrggbb)', + err_invalid_regex: 'Invalid regular expression', + err_tag_not_found: 'Tag not found', + err_live_logsql: 'Live view is not available in LogsQL mode', + }, + fr: { + locale: 'fr-FR', + searchAria: 'Rechercher dans les logs', + searchSimple: 'Rechercher… error -timeout "disk full" (touche /)', + searchLogsql: 'LogsQL : error AND host:web-01 | sort by (_time)', + modeTitle: 'Basculer entre recherche simple et LogsQL', + live: 'Direct', + liveTitle: 'Afficher les nouveaux messages en direct', + liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL', + settings: 'Paramètres', + theme: 'Thème clair / sombre', + close: 'Fermer', + rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application', + histoAria: 'Volume de logs dans le temps', + r5m: '5 min', r15m: '15 min', r1h: '1 heure', r6h: '6 heures', r24h: '24 heures', r7d: '7 jours', r30d: '30 jours', rAll: 'Tout', + sevAll: 'Toutes sévérités', sev2: 'Critiques et +', sev3: 'Erreurs et +', + sev4: 'Warnings et +', sev5: 'Notice et +', sev6: 'Info et +', + allHosts: 'Tous les hôtes', allApps: 'Toutes les applis', + emptyTitle: 'Aucun log ne correspond.', + emptyHint: 'Élargissez la période ou envoyez des messages syslog sur le port 514 (UDP ou TCP).', + count: (n) => `${n} ${n > 1 ? 'logs affichés' : 'log affiché'}`, + histoMax: (n) => `max ${n} / intervalle`, + searchError: 'Erreur de recherche : ', + connLive: 'direct', connConnecting: 'connexion…', connRetry: 'reconnexion…', + connPaused: 'direct en pause', connLogsql: 'direct indisponible (LogsQL)', + newLogs: (n) => `↑ ${n} ${n > 1 ? 'nouveaux logs' : 'nouveau log'}`, + skipped: (n) => `${n} messages non affichés (débit trop élevé)`, + stats: (s) => `reçus ${s.received} · stockés ${s.ingested} · perdus ${s.dropped} · file ${s.queue}`, + storageErr: 'stockage : ', + unreachable: 'serveur injoignable', + fTime: 'horodatage', fMsg: 'message', fPid: 'pid', fSd: 'données structurées', + filterHost: 'Filtrer sur cet hôte', filterApp: 'Filtrer sur cette appli', + copyMsg: 'Copier le message', copyJson: 'Copier en JSON', copied: 'Copié', + language: 'Langue', + tagsTitle: 'Tags de couleur', + tagsHelp: '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.', + addTag: '+ Ajouter un tag', resetTags: 'Rétablir les tags par défaut', + optWhole: 'mot entier', optWholeTitle: 'Ne colorer que les mots entiers (évite « ok » dans « token »)', + optCaseTitle: 'Respecter la casse', optRegexTitle: 'Expression régulière', + optActive: 'actif', optActiveTitle: 'Activer ce tag', + colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.', + newTag: 'nouveau', + confirmDelete: (p) => `Supprimer le tag « ${p} » ?`, + confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?', + tagsLoadErr: 'Tags : ', + err_pattern_required: 'Le mot-clé est obligatoire', + err_invalid_color: 'Couleur invalide (format #rrggbb attendu)', + err_invalid_regex: 'Expression régulière invalide', + err_tag_not_found: 'Tag introuvable', + err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL', + }, +}; + +/* ================= Utilities ================= */ 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 PAGE_SIZE = 500; // rows loaded per search +const MAX_ROWS = 2000; // rows kept on screen in live mode 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('logstream.' + k); return v === null ? def : v; } catch { return def; } }, - set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch { /* stockage indisponible */ } }, + set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch { /* storage unavailable */ } }, }; +let lang = I18N[store.get('lang', 'en')] ? store.get('lang', 'en') : 'en'; +function t(key, arg) { + const v = I18N[lang][key] ?? I18N.en[key] ?? key; + return typeof v === 'function' ? v(arg) : v; +} + 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'); +const debounce = (fn, ms) => { let tm; return (...a) => { clearTimeout(tm); tm = setTimeout(() => fn(...a), ms); }; }; +const fmtNum = (n) => Number(n || 0).toLocaleString(t('locale')); -// Date.parse n'accepte que 3 décimales partout : on tronque les nanosecondes. +// Date.parse only accepts 3 decimals everywhere: drop the nanoseconds. const parseTime = (s) => new Date(String(s || '').replace(/(\.\d{3})\d+/, '$1')); async function api(url, opts = {}) { @@ -29,43 +142,83 @@ async function api(url, opts = {}) { 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); + try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ } + if (!res.ok) { + // Known error codes are translated; otherwise show the server message. + let msg = (data && data.error) || text || res.statusText; + if (data && data.code && I18N[lang]['err_' + data.code]) { + msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : ''); + } + throw new Error(msg); + } 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'); + // A modal dialog sits above everything: move the toast into it so it stays visible. + const dlg = $('#settingsDlg'); (dlg.open ? dlg : document.body).append(el); el.textContent = msg; el.hidden = false; - clearTimeout(toast.t); - toast.t = setTimeout(() => { el.hidden = true; }, 1800); + clearTimeout(toast.tm); + toast.tm = setTimeout(() => { el.hidden = true; }, 1800); } -/* ================= État ================= */ +/* ================= State ================= */ 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 + rows: [], // displayed records, newest first + pending: [], // live messages received while scrolled down tags: [], - matchers: [], // tags compilés + termes recherchés + matchers: [], // compiled tags + search terms mode: store.get('mode', 'simple'), live: store.get('live', '1') === '1', es: null, reqId: 0, + histo: null, + tookMs: null, + conn: ['', 'connPaused'], + stats: null, }; -const recOf = new WeakMap(); // élément .row -> enregistrement +const recOf = new WeakMap(); // .row element -> record const list = $('#list'); -/* ================= Thème ================= */ +/* ================= Language ================= */ + +function applyLang() { + document.documentElement.lang = lang; + for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); + for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle); + for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria)); + for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang)); +} + +function setLang(next) { + if (next === lang || !I18N[next]) return; + lang = next; + store.set('lang', lang); + applyLang(); + syncControls(); + setConn(...state.conn); + renderList(); + updateCount(state.tookMs); + renderHisto(state.histo); + renderStats(); + renderTagList(); +} + +$('#langSwitch').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-lang]'); + if (b) setLang(b.dataset.lang); +}); + +/* ================= Theme ================= */ function effectiveTheme() { - const t = document.documentElement.dataset.theme; - if (t) return t; + const th = document.documentElement.dataset.theme; + if (th) return th; return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } $('#themeBtn').addEventListener('click', () => { @@ -74,7 +227,7 @@ $('#themeBtn').addEventListener('click', () => { store.set('theme', next); }); -/* ================= Filtres ================= */ +/* ================= Filters ================= */ function params() { const p = new URLSearchParams(); @@ -97,19 +250,17 @@ 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 /)'; + $('#q').placeholder = logsql ? t('searchLogsql') : t('searchSimple'); 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'; + btn.title = liveAvailable() ? t('liveTitle') : t('liveUnavailable'); for (const id of ['severity', 'host', 'app']) $('#' + id).classList.toggle('set', $('#' + id).value !== ''); } -/* ================= Tags et surlignage ================= */ +/* ================= Tags and highlighting ================= */ -// Couleur de texte lisible (noir ou blanc) selon la luminosité du fond. +// Readable text color (black or white) for a given background. function textOn(hex) { const n = parseInt(hex.slice(1), 16); const yiq = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000; @@ -120,17 +271,17 @@ 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 = `(?` }); + out.push({ re: new RegExp(src, 'gu' + (tag.caseSensitive ? '' : 'i')), html: `` }); } catch (e) { - console.warn('Tag ignoré (expression invalide) :', t.pattern, e.message); + console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message); } } - // Les termes de la recherche simple sont soulignés discrètement, avec la priorité la plus basse. + // Simple-search terms get a subtle highlight, with the lowest priority. if (state.mode === 'simple') { for (const tok of $('#q').value.match(/-?"[^"]*"|\S+/g) || []) { if (tok.startsWith('-')) continue; @@ -158,21 +309,22 @@ function highlight(text) { let out = ''; let pos = 0; for (const sp of spans) { - if (sp.s < pos) continue; // chevauchement : le premier arrivé l'emporte + if (sp.s < pos) continue; // overlap: first one wins out += esc(text.slice(pos, sp.s)) + sp.html + esc(text.slice(sp.s, sp.e)) + ''; pos = sp.e; } return out + esc(text.slice(pos)); } -/* ================= Rendu de la liste ================= */ +/* ================= List rendering ================= */ const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' }; function fmtTime(s) { const d = parseTime(s); if (isNaN(d)) return esc(s); - return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; + const day = lang === 'fr' ? `${pad(d.getDate())}/${pad(d.getMonth() + 1)}` : `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`; + return `${day} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; } function rowHTML(r, isNew) { @@ -187,7 +339,7 @@ function rowHTML(r, isNew) { + ''; } -// Associe les n premiers (ou derniers) éléments .row à leurs enregistrements. +// Map the .row elements starting at `first` to their records. function bindRows(first, rows) { let el = first; for (const r of rows) { @@ -200,7 +352,7 @@ function bindRows(first, rows) { function renderList() { list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join(''); bindRows(list.firstElementChild, state.rows); - $('#empty').hidden = state.rows.length > 0; + $('#empty').hidden = state.rows.length > 0 || !$('#error').hidden; } function rehighlight() { @@ -224,9 +376,9 @@ function prependRows(rows) { } function updateCount(tookMs) { - const n = state.rows.length; + if (!$('#error').hidden) { $('#count').textContent = ''; return; } const took = tookMs != null ? ` · ${tookMs} ms` : ''; - $('#count').textContent = `${fmtNum(n)} ${n > 1 ? 'logs affichés' : 'log affiché'}${took}`; + $('#count').textContent = t('count', state.rows.length).replace(/^\d+/, fmtNum(state.rows.length)) + took; } function detailsHTML(r) { @@ -234,15 +386,17 @@ function detailsHTML(r) { const hidden = new Set(['_stream_id', '_stream', 'sevnum']); const keys = order.filter((k) => r[k] != null && r[k] !== '') .concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort()); - const label = { _time: 'horodatage', _msg: 'message', procid: 'pid', sd: 'données structurées' }; - const val = (k) => (k === '_time' ? `${esc(parseTime(r[k]).toLocaleString('fr-FR'))} (${esc(r[k])})` : esc(r[k])); + const label = { _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') }; + const val = (k) => (k === '_time' + ? `${esc(parseTime(r[k]).toLocaleString(t('locale')))} (${esc(r[k])})` + : esc(r[k])); return '
' + keys.map((k) => `
${esc(label[k] || k)}
${val(k)}
`).join('') + '
' - + (r.host ? '' : '') - + (r.app ? '' : '') - + '' - + '' + + (r.host ? `` : '') + + (r.app ? `` : '') + + `` + + `` + '
'; } @@ -250,14 +404,14 @@ async function copy(text) { try { await navigator.clipboard.writeText(text); } catch { - // Repli pour les pages servies en HTTP simple (hors localhost). + // Fallback for pages served over plain HTTP (not localhost). const ta = Object.assign(document.createElement('textarea'), { value: text }); document.body.append(ta); ta.select(); document.execCommand('copy'); ta.remove(); } - toast('Copié'); + toast(t('copied')); } function setSelect(id, value) { @@ -279,7 +433,7 @@ list.addEventListener('click', (ev) => { return; } if (ev.target.closest('.details')) return; - if (String(window.getSelection())) return; // on sélectionne du texte : ne pas replier + if (String(window.getSelection())) return; // selecting text: don't collapse const open = row.classList.toggle('open'); const det = row.querySelector('.details'); if (open && !det) row.insertAdjacentHTML('beforeend', detailsHTML(r)); @@ -289,7 +443,7 @@ list.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && ev.target.classList.contains('row')) ev.target.click(); }); -/* ================= Histogramme ================= */ +/* ================= Histogram ================= */ function renderHisto(h) { const el = $('#histo'); @@ -305,24 +459,26 @@ function renderHisto(h) { let max = 1; for (let k = start; k <= end; k++) max = Math.max(max, counts.get(k) || 0); + const loc = t('locale'); 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' }); + return stepMs >= 864e5 ? d.toLocaleDateString(loc) : d.toLocaleString(loc, { 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 += ``; + bars += ``; } - el.innerHTML = `
${bars}
${label(start)}max ${fmtNum(max)} / intervalle${label(end)}
`; + el.innerHTML = `
${bars}
${label(start)}${t('histoMax', fmtNum(max))}${label(end)}
`; } async function refreshHisto() { - try { renderHisto(await api('/api/histogram?' + params())); } catch { renderHisto(null); } + try { state.histo = await api('/api/histogram?' + params()); } catch { state.histo = null; } + renderHisto(state.histo); } -/* ================= Recherche ================= */ +/* ================= Search ================= */ function setError(msg) { const el = $('#error'); @@ -339,19 +495,19 @@ async function refresh() { try { const res = await api(`/api/logs?${params()}&limit=${PAGE_SIZE}`); if (id !== state.reqId) return; + setError(null); state.rows = res.rows || []; state.pending = []; + state.tookMs = res.took_ms; $('#newPill').hidden = true; renderList(); - updateCount(res.took_ms); - setError(null); + updateCount(state.tookMs); } catch (e) { if (id !== state.reqId) return; + setError(t('searchError') + e.message); state.rows = []; renderList(); - $('#empty').hidden = true; - $('#count').textContent = ''; - setError('Erreur de recherche : ' + e.message); + updateCount(); } finally { if (id === state.reqId) document.body.classList.remove('busy'); } @@ -379,39 +535,40 @@ $('#mode').addEventListener('click', () => { }); document.addEventListener('keydown', (ev) => { - if (ev.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) && !$('#tagsDlg').open) { + if (ev.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) && !$('#settingsDlg').open) { ev.preventDefault(); $('#q').focus(); } }); -/* ================= Direct (SSE) ================= */ +/* ================= Live view (SSE) ================= */ -function setConn(cls, text) { +function setConn(cls, key) { + state.conn = [cls, key]; const el = $('#conn'); el.className = 'conn ' + cls; - el.textContent = text; + el.textContent = t(key); } function connectLive() { if (state.es) { state.es.close(); state.es = null; } if (!state.live || !liveAvailable()) { - setConn('', state.live ? 'direct indisponible (LogsQL)' : 'direct en pause'); + setConn('', state.live ? 'connLogsql' : 'connPaused'); return; } const es = new EventSource('/api/stream?' + params()); state.es = es; - setConn('', 'connexion…'); - es.onopen = () => setConn('ok', 'direct'); - es.onerror = () => setConn('ko', 'reconnexion…'); + setConn('', 'connConnecting'); + es.onopen = () => setConn('ok', 'connLive'); + es.onerror = () => setConn('ko', 'connRetry'); 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é)`); + const rows = (data.rows || []).reverse(); // newest first + if (data.skipped) toast(t('skipped', fmtNum(data.skipped))); 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.textContent = t('newLogs', state.pending.length).replace(/\d+/, fmtNum(state.pending.length)); pill.hidden = false; } else { prependRows(rows); @@ -435,11 +592,11 @@ window.addEventListener('scroll', debounce(() => { if (window.scrollY <= 80) flu $('#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 + if (state.live) refresh(); // catch up on what arrived while paused else { syncControls(); connectLive(); } }); -/* ================= Listes hôtes / applis, statistiques ================= */ +/* ================= Host / app lists, stats ================= */ async function loadFacets() { try { @@ -451,43 +608,48 @@ async function loadFacets() { 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 */ } + } catch { /* VictoriaLogs not ready yet: retried later */ } +} + +function renderStats() { + const el = $('#stats'); + const s = state.stats; + if (!s) { el.textContent = t('unreachable'); return; } + const n = { received: fmtNum(s.received), ingested: fmtNum(s.ingested), dropped: '%DROPPED%', queue: fmtNum(s.queue) }; + const dropped = s.dropped ? `${fmtNum(s.dropped)}` : fmtNum(0); + let html = esc(t('stats', n)).replace('%DROPPED%', dropped); + if (s.lastError) html += ` · ${esc(t('storageErr') + s.lastError)}`; + el.innerHTML = html; } async function loadStats() { - try { - const s = await api('/api/stats'); - const err = s.lastError ? ` · stockage : ${esc(s.lastError)}` : ''; - const dropped = s.dropped ? `perdus ${fmtNum(s.dropped)}` : 'perdus 0'; - $('#stats').innerHTML = `reçus ${fmtNum(s.received)} · stockés ${fmtNum(s.ingested)} · ${dropped} · file ${fmtNum(s.queue)}${err}`; - } catch { - $('#stats').textContent = 'serveur injoignable'; - } + try { state.stats = await api('/api/stats'); } catch { state.stats = null; } + renderStats(); } -/* ================= Gestion des tags ================= */ +/* ================= Tag management ================= */ 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); } + try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); } compileMatchers(); } -function tagRowHTML(t) { +function tagRowHTML(tag) { const opt = (field, label, title) => - ``; - return `
- - - ${esc(t.pattern || 'aperçu')} + ``; + return `
+ + + ${esc(tag.pattern || t('preview'))}
- ${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')} + ${opt('wholeWord', t('optWhole'), t('optWholeTitle'))} + ${opt('caseSensitive', 'Aa', t('optCaseTitle'))} + ${opt('regex', '.*', t('optRegexTitle'))} + ${opt('enabled', t('optActive'), t('optActiveTitle'))}
-
@@ -495,16 +657,16 @@ function tagRowHTML(t) { } function renderTagList() { - $('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || '

Aucun tag.

'; + $('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || `

${esc(t('noTags'))}

`; } const saveTimers = {}; -function scheduleSave(t, rowEl) { - clearTimeout(saveTimers[t.id]); - saveTimers[t.id] = setTimeout(async () => { +function scheduleSave(tag, rowEl) { + clearTimeout(saveTimers[tag.id]); + saveTimers[tag.id] = setTimeout(async () => { const errEl = rowEl.querySelector('.err'); try { - await api('/api/tags/' + encodeURIComponent(t.id), { method: 'PUT', body: t }); + await api('/api/tags/' + encodeURIComponent(tag.id), { method: 'PUT', body: tag }); errEl.textContent = ''; } catch (e) { errEl.textContent = e.message; @@ -518,14 +680,14 @@ $('#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 tag = state.tags.find((x) => x.id === rowEl.dataset.id); + if (!tag) return; + tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value; + rowEl.classList.toggle('off', !tag.enabled); const mark = rowEl.querySelector('.preview mark'); - mark.textContent = t.pattern || 'aperçu'; - mark.setAttribute('style', tagStyle(t.color)); - scheduleSave(t, rowEl); + mark.textContent = tag.pattern || t('preview'); + mark.setAttribute('style', tagStyle(tag.color)); + scheduleSave(tag, rowEl); applyTags(); }); @@ -533,11 +695,11 @@ $('#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; + const tag = state.tags.find((x) => x.id === rowEl.dataset.id); + if (!tag || !confirm(t('confirmDelete', tag.pattern))) return; try { - await api('/api/tags/' + encodeURIComponent(t.id), { method: 'DELETE' }); - state.tags = state.tags.filter((x) => x !== t); + await api('/api/tags/' + encodeURIComponent(tag.id), { method: 'DELETE' }); + state.tags = state.tags.filter((x) => x !== tag); renderTagList(); applyTags(); } catch (e) { toast(e.message); } @@ -546,8 +708,8 @@ $('#tagList').addEventListener('click', async (ev) => { $('#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); + const tag = await api('/api/tags', { method: 'POST', body: { pattern: t('newTag'), color, wholeWord: true, enabled: true } }); + state.tags.push(tag); renderTagList(); applyTags(); const input = $('#tagList').lastElementChild.querySelector('input[type="text"]'); @@ -557,7 +719,7 @@ $('#addTag').addEventListener('click', async () => { }); $('#resetTags').addEventListener('click', async () => { - if (!confirm('Remplacer tous les tags par les tags par défaut (warning, error, ok) ?')) return; + if (!confirm(t('confirmReset'))) return; try { state.tags = await api('/api/tags/reset', { method: 'POST' }); renderTagList(); @@ -565,12 +727,13 @@ $('#resetTags').addEventListener('click', async () => { } 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(); }); +$('#settingsBtn').addEventListener('click', () => { renderTagList(); $('#settingsDlg').showModal(); }); +$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close()); +$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); }); -/* ================= Démarrage ================= */ +/* ================= Startup ================= */ +applyLang(); $('#range').value = store.get('range', '1h'); if (!$('#range').value) $('#range').value = '1h'; $('#severity').value = store.get('severity', ''); diff --git a/web/index.html b/web/index.html index 462c355..3a49bbf 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + @@ -7,8 +7,13 @@ @@ -20,18 +25,19 @@
- - - @@ -40,31 +46,31 @@
- + + + + + + + + - + + + + + + - - + +
-
+
@@ -72,8 +78,8 @@
@@ -81,20 +87,32 @@
- +
-

Tags de couleur

-
-

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

-
-
- - -
+ +
+

Language

+
+ + +
+
+ +
+

Color tags

+

+
+ +
diff --git a/web/style.css b/web/style.css index d9c8d8f..ab3ed49 100644 --- a/web/style.css +++ b/web/style.css @@ -1,4 +1,4 @@ -/* ---------- Thème : tout passe par ces variables ---------- */ +/* ---------- Theme: everything goes through these variables ---------- */ :root { --bg: #f6f7f9; --panel: #ffffff; @@ -86,7 +86,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; .small { font-size: 12.5px; } [hidden] { display: none !important; } -/* ---------- Barre du haut ---------- */ +/* ---------- Top bar ---------- */ .topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 16px; @@ -136,7 +136,7 @@ svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; .icon-btn svg { width: 19px; height: 19px; } .icon-btn.danger:hover { color: var(--sev-err); } -/* Soleil ou lune selon le thème effectif */ +/* Sun or moon depending on the effective theme */ #themeBtn .moon { display: none; } :root[data-theme="dark"] #themeBtn .sun { display: none; } :root[data-theme="dark"] #themeBtn .moon { display: block; } @@ -175,7 +175,7 @@ body.busy .progress::after { } @keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(400%); } } -/* ---------- Filtres ---------- */ +/* ---------- Filters ---------- */ .filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px 8px; @@ -191,7 +191,7 @@ body.busy .progress::after { .spacer { flex: 1; } #count { font-size: 12.5px; font-variant-numeric: tabular-nums; } -/* ---------- Histogramme ---------- */ +/* ---------- Histogram ---------- */ .histo { padding: 0 20px 6px; } .histo .bars { height: 46px; display: flex; align-items: flex-end; gap: 1px; @@ -204,7 +204,7 @@ body.busy .progress::after { .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 ---------- */ +/* ---------- Log list ---------- */ .list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } .list:empty { display: none; } @@ -252,7 +252,7 @@ mark.tag { } mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding: 0 1px; } -/* Détails d'un message */ +/* Message details */ .details { grid-column: 1 / -1; margin: 8px 0 4px; padding: 10px 12px; @@ -265,7 +265,7 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding: .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 ---------- */ +/* ---------- States ---------- */ .empty { text-align: center; padding: 60px 20px; } .empty svg { width: 40px; height: 40px; color: var(--muted); opacity: .6; } .empty p { margin: 6px 0; } @@ -303,7 +303,7 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding: padding: 8px 14px; border-radius: 8px; font-size: 13px; box-shadow: var(--shadow); } -/* ---------- Fenêtre des tags ---------- */ +/* ---------- Settings dialog ---------- */ dialog { width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px); padding: 0; border: 1px solid var(--border); border-radius: 14px; @@ -313,7 +313,19 @@ 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; } +.set-section { margin-top: 18px; } +.set-section + .set-section { padding-top: 16px; border-top: 1px solid var(--border); } +.set-section h3 { margin: 0 0 8px; font-size: 13px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.set-section p { margin: 0; } +.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; } + +.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; } +.seg button { + border: 0; background: transparent; border-radius: 7px; + height: 30px; padding: 0 14px; font-size: 13px; font-weight: 600; color: var(--muted); +} +.seg button:hover { color: var(--text); } +.seg button[aria-checked="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); } .tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } .tag-row {