ajout anglais/francais

This commit is contained in:
cedric committed 2026-09-28 14:24:10 +02:00
1 parent a1c53892f0
commit 35d51defc8
16 files changed
+533 -315

No files matched your search

+3 -3
View File
@@ -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=
+1
View File
@@ -0,0 +1 @@
.env
+3 -3
View File
@@ -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"]
+64 -64
View File
@@ -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 <http://localhost:8080>.
Then open <http://localhost:8080>.
## 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.
- <http://localhost:9428/select/vmui> : 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.
- <http://localhost:9428/select/vmui>: 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.
+18 -8
View File
@@ -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
}
+3 -3
View File
@@ -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:
+2 -2
View File
@@ -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()
+11 -11
View File
@@ -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)
+10 -10
View File
@@ -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
+7 -7
View File
@@ -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()))
+22 -22
View File
@@ -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')
}
+22 -8
View File
@@ -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 {
+4 -4
View File
@@ -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)"
+283 -120
View File
@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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 = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
for (const tag of state.tags) {
if (!tag.enabled || !tag.pattern) continue;
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
if (tag.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)}">` });
out.push({ re: new RegExp(src, 'gu' + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` });
} 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)) + '</mark>';
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())}<span class="ms">.${pad(d.getMilliseconds(), 3)}</span>`;
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())}<span class="ms">.${pad(d.getMilliseconds(), 3)}</span>`;
}
function rowHTML(r, isNew) {
@@ -187,7 +339,7 @@ function rowHTML(r, isNew) {
+ '</article>';
}
// 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'))} <span class="muted">(${esc(r[k])})</span>` : 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')))} <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>'
+ (r.host ? `<button class="btn" data-act="host">${esc(t('filterHost'))}</button>` : '')
+ (r.app ? `<button class="btn" data-act="app">${esc(t('filterApp'))}</button>` : '')
+ `<button class="btn" data-act="copy">${esc(t('copyMsg'))}</button>`
+ `<button class="btn" data-act="json">${esc(t('copyJson'))}</button>`
+ '</div></div>';
}
@@ -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 += `<i style="height:${hgt}%" title="${label(k)} : ${fmtNum(n)}"></i>`;
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>`;
el.innerHTML = `<div class="bars">${bars}</div><div class="axis"><span>${label(start)}</span><span>${t('histoMax', fmtNum(max))}</span><span>${label(end)}</span></div>`;
}
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 ? `<span class="bad">${fmtNum(s.dropped)}</span>` : fmtNum(0);
let html = esc(t('stats', n)).replace('%DROPPED%', dropped);
if (s.lastError) html += ` · <span class="bad">${esc(t('storageErr') + s.lastError)}</span>`;
el.innerHTML = html;
}
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';
}
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) =>
`<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>
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`;
return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}">
<input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
<span class="preview"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.pattern || t('preview'))}</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')}
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))}
${opt('caseSensitive', 'Aa', t('optCaseTitle'))}
${opt('regex', '.*', t('optRegexTitle'))}
${opt('enabled', t('optActive'), t('optActiveTitle'))}
</div>
<button class="icon-btn danger" type="button" data-del title="Supprimer" aria-label="Supprimer">
<button class="icon-btn danger" type="button" data-del title="${esc(t('del'))}" aria-label="${esc(t('del'))}">
<svg viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
</button>
<div class="err"></div>
@@ -495,16 +657,16 @@ function tagRowHTML(t) {
}
function renderTagList() {
$('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || '<p class="muted small">Aucun tag.</p>';
$('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || `<p class="muted small">${esc(t('noTags'))}</p>`;
}
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', '');
+58 -40
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="fr">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,8 +7,13 @@
<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('logstream.theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
// Apply the saved theme and language before first paint to avoid a flash.
try {
var t = localStorage.getItem('logstream.theme');
if (t) document.documentElement.dataset.theme = t;
var l = localStorage.getItem('logstream.lang');
if (l) document.documentElement.lang = l;
} catch (e) {}
</script>
</head>
<body>
@@ -20,18 +25,19 @@
<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>
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-aria="searchAria">
<button id="mode" class="chip" type="button" data-i18n-title="modeTitle">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 id="live" class="btn live" type="button">
<span class="dot"></span><span class="lbl" data-i18n="live">Live</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 id="settingsBtn" class="icon-btn" type="button" data-i18n-title="settings" data-i18n-aria="settings">
<!-- Gear icon (Lucide "settings", ISC license) -->
<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button id="themeBtn" class="icon-btn" type="button" title="Thème clair / sombre" aria-label="Changer de thème">
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
<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>
@@ -40,31 +46,31 @@
</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 id="range" data-i18n-aria="rangeAria">
<option value="5m" data-i18n="r5m">5 min</option>
<option value="15m" data-i18n="r15m">15 min</option>
<option value="1h" data-i18n="r1h" selected>1 hour</option>
<option value="6h" data-i18n="r6h">6 hours</option>
<option value="24h" data-i18n="r24h">24 hours</option>
<option value="7d" data-i18n="r7d">7 days</option>
<option value="30d" data-i18n="r30d">30 days</option>
<option value="all" data-i18n="rAll">All</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 id="severity" data-i18n-aria="severityAria">
<option value="" data-i18n="sevAll">All severities</option>
<option value="2" data-i18n="sev2">Critical and above</option>
<option value="3" data-i18n="sev3">Errors and above</option>
<option value="4" data-i18n="sev4">Warnings and above</option>
<option value="5" data-i18n="sev5">Notice and above</option>
<option value="6" data-i18n="sev6">Info and above</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>
<select id="host" data-i18n-aria="hostAria"><option value="" data-i18n="allHosts">All hosts</option></select>
<select id="app" data-i18n-aria="appAria"><option value="" data-i18n="allApps">All apps</option></select>
<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>
<section id="histo" class="histo" data-i18n-aria="histoAria"></section>
<div id="error" class="error-banner" hidden></div>
<button id="newPill" class="pill" type="button" hidden></button>
@@ -72,8 +78,8 @@
<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>
<p><strong data-i18n="emptyTitle">No matching logs.</strong></p>
<p class="muted" data-i18n="emptyHint">Widen the time range or send syslog messages to port 514 (UDP or TCP).</p>
</div>
<footer class="status">
@@ -81,20 +87,32 @@
<span id="stats" class="muted"></span>
</footer>
<dialog id="tagsDlg" aria-labelledby="tagsTitle">
<dialog id="settingsDlg" aria-labelledby="settingsTitle">
<div class="dlg">
<header>
<h2 id="tagsTitle">Tags de couleur</h2>
<button id="closeTags" class="icon-btn" type="button" aria-label="Fermer">
<h2 id="settingsTitle" data-i18n="settings">Settings</h2>
<button id="closeSettings" class="icon-btn" type="button" data-i18n-aria="close">
<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>
<section class="set-section">
<h3 data-i18n="language">Language</h3>
<div id="langSwitch" class="seg" role="radiogroup" data-i18n-aria="language">
<button type="button" role="radio" data-lang="en">English</button>
<button type="button" role="radio" data-lang="fr">Français</button>
</div>
</section>
<section class="set-section">
<h3 data-i18n="tagsTitle">Color tags</h3>
<p class="muted small" data-i18n="tagsHelp"></p>
<div id="tagList" class="tag-list"></div>
<footer>
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
</footer>
</section>
</div>
</dialog>
+22 -10
View File
@@ -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 {