Author SHA1 Message Date
claude BotandClaude Opus 5.5 aff7cf8839 Stream and Time range display modes with start/end fields
A segmented control at the start of the filter bar switches between
Stream (sliding duration, live view) and Time range (start and end
dates in the chosen time zone, previous/next and zoom-out buttons).
Timeline clicks and drags switch to Time range mode; the mode and
range are kept across reloads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 20:37:05 +02:00
claude Bot 389a679c9f Merge pull request 'Statistiques de la base dans la zone de danger' (#18) from feat/statistiques-base into main 2026-10-03 17:13:43 +02:00
claude BotandClaude Opus 5.5 8b5ee73ee1 Database statistics in Settings > Data
New GET /api/dbstats reads VictoriaLogs /metrics (stored lines, size on
disk, raw size, free space, partitions, retention) and two LogsQL queries
(period covered, distinct hosts and apps, lines of the last 24 h and hour),
cached for 30 s. The danger zone shows them, sizes in KB/MB/GB or Ko/Mo/Go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 17:08:00 +02:00
claude Bot e901de442c Merge pull request 'Réglages plus grands et liste des filtres plus dense' (#17) from feat/reglages-mise-en-page into main 2026-10-03 16:39:58 +02:00
claude Bot 2f84bc8deb Merge pull request 'Sécurité : purge désactivée par défaut, rôle lecture seule, en-têtes et limites TCP' (#16) from feat/securite into main 2026-10-03 16:39:49 +02:00
claude Bot 524f5dc0fd Merge pull request 'Tampon disque, positions Docker sans perte et DNS inverse non bloquant' (#15) from feat/fiabilite-ingestion into main 2026-10-03 16:39:40 +02:00
cedricandClaude Opus 5.5 4f4cb3e02b Settings: larger dialog, sections as cards in columns, dense filter list
- The dialog uses most of the screen (up to 1280 px wide, full height);
  sections become cards laid out in two columns when there is room.
- Settings > Filters: compact tag rows in one wide column, a toolbar that
  stays in view (add, presets, filter, reset), a filter on keyword, label
  or code, and a tag count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:33:48 +02:00
cedricandClaude Opus 5.5 42f6137391 Safer defaults, read-only role, security headers and syslog TCP limits
- ALLOW_PURGE is now false by default; the UI shows a banner when there is
  no authentication.
- Read-only role: AUTH_VIEWER_USER/AUTH_VIEWER_PASS in local mode, or
  OIDC_ADMIN_GROUP in OIDC mode; changes get 403 and the admin settings
  are greyed out.
- Content-Security-Policy (inline scripts allowed by hash) and other
  security headers; cross-site changes are refused.
- Syslog TCP: at most SYSLOG_TCP_MAX_CONNS connections, closed after
  SYSLOG_TCP_IDLE of silence; HTTP idle timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:30:40 +02:00
cedricandClaude Opus 5.5 3504263992 Disk buffer for batches VictoriaLogs cannot take, lossless Docker positions, non-blocking reverse DNS
- Batches that fail go to /data/spool (SPOOL_MAX_MB, 1 GiB by default) and
  are sent again oldest first; retries no longer block the store loop and
  follow the shutdown context.
- Docker and host logs wait for room in a full queue instead of being
  dropped; the Docker position only moves once a line is stored or spooled.
- Reverse DNS no longer holds up the syslog listeners, with an LRU cache
  and a cap on concurrent lookups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:26:18 +02:00
claude Bot 3466a29692 Merge pull request 'Préréglages de tags : nouveaux groupes et fichier presets.json modifiable' (#14) from feat/filtres-systeme into main 2026-10-03 16:07:40 +02:00
cedricandClaude Opus 5.5 3c25b1e4e2 Default tags: keep warning and error, move ok to the Log levels preset
Existing tags.json files are unchanged; only new installs and "Restore
default tags" get the shorter list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:07:30 +02:00
cedricandClaude Opus 5.5 688a7dc2e6 Load color tag presets from an editable presets.json file
The presets move from app.js to presets.json, built into the binary and
served by /api/presets. PRESETS_FILE (default /data/presets.json) replaces
the list when present; it is read again each time Settings opens and the
built-in list is used if it is invalid. docs/presets.md (EN/FR) explains
each preset and the file format.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:05:47 +02:00
cedricandClaude Opus 5.5 4225a2c870 More color tag presets: system, applications and general
The preset menu is grouped (HTTP/HTTPS, System, Applications, General) and
gains SSH/logins, sudo, kernel, systemd, firewall/fail2ban, Docker,
databases, log levels and IPv4 addresses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:01:02 +02:00
claude Bot 1adb25e403 Merge pull request 'Badges de codes de filtre plus foncés, police des badges de sévérité' (#13) from feat/codes-filtres-style into main 2026-10-03 15:56:15 +02:00
cedricandClaude Opus 5.5 7c26d0128a Darker tag code badges, in the same font as the severity badges
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:52:46 +02:00
claude Bot 64c21b1f70 Merge pull request 'Densité compacte et polices étroites intégrées' (#12) from feat/mode-compact into main 2026-10-03 15:45:29 +02:00
cedricandClaude Opus 5.5 236c936a9d Built-in Ubuntu Mono and Inconsolata Condensed fonts
Inconsolata Condensed (Inconsolata pinned at width 75, 0.4em per
character) is now the narrowest option. Ubuntu Mono moves from Bunny
Fonts to the built-in fonts, so it works offline too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:44:02 +02:00
cedricandClaude Opus 5.5 8228bc140f Keep tag code badges from making compact rows taller
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:39:58 +02:00
cedricandClaude Opus 5.5 9ea1371696 Compact density and built-in Iosevka font for denser log display
Settings > Interface > Log display gets a Density switch (Normal/Compact)
that tightens row padding and line height, and the font list gets Iosevka,
a narrow SIL OFL monospace font served from web/fonts (Latin subset,
13 KB per weight) so it works offline, unlike the Bunny Fonts ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:39:33 +02:00
claude Bot 974c20e45c Merge pull request 'Codes à 2 chiffres pour les tags de couleur, affichés sur les lignes de log' (#11) from feat/codes-filtres into main 2026-10-03 15:37:04 +02:00
cedricandClaude Opus 5.5 c664f1eaaf Two-digit code per color tag, shown as badges on matching log lines
Each tag gets a stable code (01-99) assigned by the server and stored in
tags.json; existing tags get one when the file is loaded. A fixed-width
Filters column shows the codes of the active tags found in each message
(room for 3, then 2 + "+N", all listed in the tooltip).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:35:20 +02:00
41 changed files with 3214 additions and 280 deletions

No files matched your search

+13 -1
View File
@@ -9,9 +9,17 @@ AUTH_MODE=local
# local mode: user and password (empty = no authentication) # local mode: user and password (empty = no authentication)
AUTH_USER= AUTH_USER=
AUTH_PASS= AUTH_PASS=
# local mode: optional read-only account (can search and export, cannot change tags, sources or purge)
AUTH_VIEWER_USER=
AUTH_VIEWER_PASS=
# local mode: PNG logo shown on the login page, path inside the container (empty = no logo). # local mode: PNG logo shown on the login page, path inside the container (empty = no logo).
# Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png # Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png
LOGIN_LOGO= LOGIN_LOGO=
# Ready-made color tags offered in Settings > Filters (see docs/presets.md).
# Empty: /data/presets.json if present, else the built-in list. To use your own file,
# mount it in docker-compose.yml, e.g. ./presets.json:/config/presets.json:ro
PRESETS_FILE=
# Session lifetime, both modes (e.g. 8h, 24h) # Session lifetime, both modes (e.g. 8h, 24h)
SESSION_TTL=12h SESSION_TTL=12h
# oidc mode: issuer URL exactly as the provider announces it # oidc mode: issuer URL exactly as the provider announces it
@@ -23,12 +31,16 @@ OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
# Requested scopes (openid is always added) # Requested scopes (openid is always added)
OIDC_SCOPES=openid profile email OIDC_SCOPES=openid profile email
# oidc mode: only members of this group are admins, the others are read-only (empty = everyone is admin).
# The groups come from the ID token claim OIDC_GROUPS_CLAIM (default groups)
OIDC_ADMIN_GROUP=
OIDC_GROUPS_CLAIM=groups
# Reverse DNS: show host names instead of IP addresses (on/off) # Reverse DNS: show host names instead of IP addresses (on/off)
RDNS=on RDNS=on
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver # DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
DNS_SERVER= DNS_SERVER=
# Allow "Delete all logs" in Settings (true/false) # Allow "Delete all logs" in Settings (true/false)
ALLOW_PURGE=true ALLOW_PURGE=false
# Maximum number of rows in a CSV export # Maximum number of rows in a CSV export
EXPORT_MAX=100000 EXPORT_MAX=100000
# Collect the logs of the Docker containers of this machine (on/off) # Collect the logs of the Docker containers of this machine (on/off)
+1 -1
View File
@@ -4,7 +4,7 @@
FROM golang:1.27.1-alpine3.24 AS build FROM golang:1.27.1-alpine3.24 AS build
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod ./
COPY *.go ./ COPY *.go presets.json ./
COPY web ./web COPY web ./web
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream .
+78 -27
View File
@@ -18,8 +18,10 @@ devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ Vi
![Schéma logique de Logstream](docs/architecture.png) ![Schéma logique de Logstream](docs/architecture.png)
- **Ingestion** : les messages syslog (UDP/TCP) et les logs des conteneurs Docker passent tous - **Ingestion** : les messages syslog (UDP/TCP) et les logs des conteneurs Docker passent tous
par `sink()` (résolution DNS inverse des hôtes donnés par leur IP), puis par la file du par `sink()` (résolution DNS inverse des hôtes donnés par leur IP, sans bloquer la
`Store`, qui les envoie par lots à VictoriaLogs. réception), puis par la file du `Store`, qui les envoie par lots à VictoriaLogs. Quand
VictoriaLogs est injoignable, les lots sont gardés sur disque (`/data/spool`, jusqu'à
`SPOOL_MAX_MB`) et renvoyés, les plus anciens d'abord, dès son retour.
- **Direct** : `sink()` publie aussi chaque message dans le `Hub`, qui le diffuse aux - **Direct** : `sink()` publie aussi chaque message dans le `Hub`, qui le diffuse aux
navigateurs en SSE. navigateurs en SSE.
- **Recherche** : l'API HTTP traduit les filtres de l'interface en requêtes LogsQL envoyées à - **Recherche** : l'API HTTP traduit les filtres de l'interface en requêtes LogsQL envoyées à
@@ -78,8 +80,8 @@ par exemple `error AND host:web-01`, `app:~"ssh|nginx"` ou `* | stats by (host)
Le direct est désactivé dans ce mode. Le direct est désactivé dans ce mode.
Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur), Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur),
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application,
et le message. Un clic sur un hôte ou une application filtre dessus. les codes des tags trouvés et le message. Un clic sur un hôte ou une application filtre dessus.
Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont
l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même
@@ -100,6 +102,16 @@ restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Inter
Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
présentation sur deux lignes, sans colonnes. présentation sur deux lignes, sans colonnes.
## Modes Flux et Période
Le premier contrôle de la barre de filtres bascule entre deux modes d'affichage :
- **Flux** : les derniers logs sur une durée glissante (5 min à 30 jours, ou tout), avec le direct.
- **Période** : les logs entre une date de début et une date de fin, saisies dans le fuseau choisi
dans les Paramètres. ◀ et ▶ passent à la période précédente ou suivante de même durée, la loupe
la double autour de son milieu. Le direct se met en pause ; le mode et la période sont conservés
au rechargement.
## Frise ## Frise
La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure
@@ -107,10 +119,9 @@ de réception** sur l'horloge du serveur (elle correspond donc aux heures affich
lignes). lignes).
- Au survol d'un intervalle : ses bornes, son total et le détail par sévérité. - Au survol d'un intervalle : ses bornes, son total et le détail par sévérité.
- Un clic sur une barre zoome sur cet intervalle ; un glisser sur plusieurs barres zoome sur la - Un clic sur une barre affiche cet intervalle en mode Période ; un glisser sur plusieurs barres
sélection. La plage de temps affiche alors la période zoomée (« × Annuler le zoom » ou le affiche la sélection. « × Revenir au flux » repasse en mode Flux. La liste, les compteurs et
choix d'une autre plage en sort) ; la liste, les compteurs et l'export CSV suivent le zoom, l'export CSV suivent la période.
et le direct se met en pause.
- En direct, le dernier intervalle grandit à l'arrivée des messages, et la frise se recharge à - En direct, le dernier intervalle grandit à l'arrivée des messages, et la frise se recharge à
chaque nouvel intervalle. Rien n'est rafraîchi tant que l'onglet du navigateur est masqué ; chaque nouvel intervalle. Rien n'est rafraîchi tant que l'onglet du navigateur est masqué ;
la frise se met à jour dès qu'il redevient visible. la frise se met à jour dès qu'il redevient visible.
@@ -153,8 +164,10 @@ colorés et exportés comme les messages syslog :
quand les conteneurs sont nombreux. Les nouveaux conteneurs sont suivis automatiquement, sauf quand les conteneurs sont nombreux. Les nouveaux conteneurs sont suivis automatiquement, sauf
si cette option est désactivée. Les choix sont enregistrés par service compose (ou nom de si cette option est désactivée. Les choix sont enregistrés par service compose (ou nom de
conteneur) dans `/data/docker.json` : ils survivent aux recréations. conteneur) dans `/data/docker.json` : ils survivent aux recréations.
- Logstream mémorise la position lue dans chaque conteneur (`/data/docker-state.json`) : après - Logstream mémorise la position de la dernière ligne stockée pour chaque conteneur
un redémarrage, il reprend sans perdre ni dupliquer de lignes. Un conteneur vu pour la (`/data/docker-state.json`) : après un redémarrage, il reprend sans perdre de lignes. La
position n'avance qu'une fois la ligne dans VictoriaLogs ou dans le tampon disque, et une file
pleine ralentit la lecture au lieu de perdre des lignes. Un conteneur vu pour la
première fois est lu à partir de `DOCKER_BACKFILL` en arrière (1 heure par défaut). première fois est lu à partir de `DOCKER_BACKFILL` en arrière (1 heure par défaut).
- Logstream lui-même et le proxy ci-dessous ne sont jamais collectés ; ajoutez l'étiquette - Logstream lui-même et le proxy ci-dessous ne sont jamais collectés ; ajoutez l'étiquette
`logstream.exclude=true` à tout autre conteneur pour l'exclure définitivement. `logstream.exclude=true` à tout autre conteneur pour l'exclure définitivement.
@@ -225,32 +238,47 @@ couleur, est mémorisé par navigateur.
automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, respect 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 stockés sur le serveur dans de la casse, expression régulière, actif. Les tags sont stockés sur le serveur dans
`/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs. `/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs.
Tags par défaut (pastel) : `warning` (orange), `error` (rouge), `ok` (vert). Les tags par Tags par défaut (pastel) : `warning` (orange) et `error` (rouge) ; `ok` (vert) est dans le
préréglage *Niveaux de log*. Les tags par
défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux
couleurs pastel. couleurs pastel.
Le menu *+ Préréglage…* ajoute des tags tout faits pour les logs d'accès HTTP/HTTPS (nginx et Le menu *+ Préréglage…* ajoute des tags tout faits : logs d'accès HTTP/HTTPS (codes de statut,
Apache common/combined, Traefik CLF et JSON, Caddy JSON, HAProxy httplog) : codes de statut méthodes, sondes, robots, erreurs TLS et proxy), logs système (SSH, sudo, noyau, systemd,
(2xx vert, 3xx bleu, 4xx orange, 5xx rouge), méthodes, sondes et attaques (`wp-login.php`, pare-feu), applications (Docker, bases de données) et motifs généraux (niveaux de log,
`/.env`, `../`…), robots et scripts, erreurs TLS et proxy. Les tags déjà présents ne sont pas adresses IPv4). Les tags déjà présents ne sont pas ajoutés en double, et les tags ajoutés se
ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression modifient comme les autres. La liste vient d'un fichier texte modifiable (`PRESETS_FILE`) :
voir [docs/presets.fr.md](docs/presets.fr.md) pour le détail de chaque préréglage et le
format du fichier. Dans une expression
régulière, un groupe nommé `hl` (`(?<hl>…)`) ne colore que cette partie de la correspondance : régulière, un groupe nommé `hl` (`(?<hl>…)`) ne colore que cette partie de la correspondance :
les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour. les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour.
Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste
attaché au tag jusqu'à sa suppression (les tags créés par les versions précédentes en reçoivent
un aussi). La colonne *Filtres* de la liste affiche, en badges gris, les codes des tags actifs
trouvés dans chaque message ; elle a la place pour 3, au-delà elle en affiche 2 et `+N`, et
l'infobulle les liste tous.
- **Interface** - **Interface**
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le - *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
bouton soleil/lune de l'en-tête bascule entre clair et sombre. bouton soleil/lune de l'en-tête bascule entre clair et sombre.
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande) et police : - *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande), densité
la police monospace du système, ou l'une des 12 polices libres conçues pour le texte dense (normale, ou compacte pour afficher environ 50 % de lignes en plus à l'écran) et police :
la police monospace du système, l'une des 3 polices étroites intégrées, servies par
LogStream lui-même et utilisables hors ligne (Inconsolata Condensed, la plus étroite,
Iosevka et Ubuntu Mono), ou l'une des 11 polices libres conçues pour le texte dense
(JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, (JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono,
Ubuntu Mono, Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). Elles sont Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). Ces 11 polices sont
chargées par le navigateur depuis [Bunny Fonts](https://fonts.bunny.net), un service chargées par le navigateur depuis [Bunny Fonts](https://fonts.bunny.net), un service
européen de polices respectueux de la vie privée ; sans accès à internet, la police du européen de polices respectueux de la vie privée ; sans accès à internet, la police du
système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels
quels. quels. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed.
- **Données** : « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut - **Données** : chiffres de la base lus dans VictoriaLogs (actualisés au plus toutes les 30 s) :
lignes stockées, taille sur disque (index compris), taille brute et taux de compression,
période couverte avec la rétention, lignes des dernières 24 h et de la dernière heure, hôtes
et applications distincts, espace disque libre. Les tailles sont en Ko/Mo/Go (KB/MB/GB en
anglais). « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`) ; mettez être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`). La fonction est
`ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
peut purger : activez l'[authentification](#authentification) si l'interface est accessible alors purger : activez l'[authentification](#authentification) si l'interface est accessible
à d'autres. à d'autres.
## Authentification ## Authentification
@@ -259,6 +287,7 @@ couleur, est mémorisé par navigateur.
- **`local`** (par défaut) : une page de connexion avec le compte `AUTH_USER` / `AUTH_PASS` ; laissez-les - **`local`** (par défaut) : une page de connexion avec le compte `AUTH_USER` / `AUTH_PASS` ; laissez-les
vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà). vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà).
L'interface affiche alors un bandeau d'avertissement, que l'on peut fermer.
- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…), - **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
flux « authorization code » avec PKCE. flux « authorization code » avec PKCE.
@@ -269,6 +298,10 @@ déconnexion (en haut à droite) y met fin. Les échecs de connexion sont écrit
l'adresse du client (`auth: failed login for "bob" from 192.0.2.7`). Les scripts peuvent toujours l'adresse du client (`auth: failed login for "bob" from 192.0.2.7`). Les scripts peuvent toujours
appeler l'API avec des identifiants HTTP Basic (`curl -u utilisateur:motdepasse`). appeler l'API avec des identifiants HTTP Basic (`curl -u utilisateur:motdepasse`).
Un **compte en lecture seule** optionnel, `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS`, peut chercher,
suivre le direct et exporter, mais pas modifier les tags, les sources ni purger : ces réglages
sont grisés dans son interface et l'API répond `403`.
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
son chemin dans `LOGIN_LOGO` : son chemin dans `LOGIN_LOGO` :
@@ -309,6 +342,16 @@ l'application). Les connexions sont écrites dans les logs de logstream (`oidc:
Avec une URL de retour en `https`, les cookies ne sont envoyés qu'en HTTPS : logstream doit être Avec une URL de retour en `https`, les cookies ne sont envoyés qu'en HTTPS : logstream doit être
joint à travers un reverse proxy TLS. joint à travers un reverse proxy TLS.
Pour donner un accès en lecture seule à certains utilisateurs, définissez `OIDC_ADMIN_GROUP` (par
exemple `logstream-admins`) : seuls ses membres sont administrateurs, les autres sont en lecture
seule. Les groupes sont lus dans la revendication `groups` du jeton d'identité
(`OIDC_GROUPS_CLAIM` pour en utiliser une autre) ; dans Keycloak, ajoutez au client un mapper
« Group Membership » (le `/` initial est ignoré).
Quel que soit le mode, chaque réponse porte des en-têtes de sécurité (Content-Security-Policy,
X-Frame-Options…), et l'API refuse les modifications envoyées depuis un autre site (requêtes
intersites).
## Noms d'hôtes (DNS inverse) ## Noms d'hôtes (DNS inverse)
Quand un équipement envoie son adresse IP comme nom d'hôte (ou pas de nom d'hôte du tout), Quand un équipement envoie son adresse IP comme nom d'hôte (ou pas de nom d'hôte du tout),
@@ -331,16 +374,22 @@ résolutions.
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs | | `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) | | `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
| `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification | | `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification |
| `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS` | vide | compte optionnel en lecture seule (mode `local`) |
| `LOGIN_LOGO` | vide | PNG affiché sur la page de connexion, chemin dans le conteneur (mode `local`) | | `LOGIN_LOGO` | vide | PNG affiché sur la page de connexion, chemin dans le conteneur (mode `local`) |
| `SESSION_TTL` | `12h` | durée de la session (les deux modes ; `OIDC_SESSION_TTL` fonctionne toujours) | | `SESSION_TTL` | `12h` | durée de la session (les deux modes ; `OIDC_SESSION_TTL` fonctionne toujours) |
| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) | | `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | vide | client déclaré dans le fournisseur | | `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | vide | client déclaré dans le fournisseur |
| `OIDC_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` | | `OIDC_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | scopes demandés | | `OIDC_SCOPES` | `openid profile email` | scopes demandés |
| `OIDC_ADMIN_GROUP` | vide | seuls les membres de ce groupe sont administrateurs, les autres en lecture seule (vide = tout le monde est administrateur) |
| `OIDC_GROUPS_CLAIM` | `groups` | revendication du jeton d'identité qui liste les groupes |
| `RDNS` | `on` | résoudre les hôtes donnés par leur IP en noms DNS | | `RDNS` | `on` | résoudre les hôtes donnés par leur IP en noms DNS |
| `DNS_SERVER` | vide | serveur DNS pour les résolutions inverses (`ip` ou `ip:port`) | | `DNS_SERVER` | vide | serveur DNS pour les résolutions inverses (`ip` ou `ip:port`) |
| `ALLOW_PURGE` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres | | `ALLOW_PURGE` | `false` | autoriser « Supprimer tous les logs » dans les Paramètres |
| `SYSLOG_TCP_MAX_CONNS` | `512` | connexions syslog TCP ouvertes en même temps ; au-delà, elles sont refusées |
| `SYSLOG_TCP_IDLE` | `30m` | une connexion syslog TCP silencieuse pendant cette durée est fermée (les émetteurs se reconnectent) |
| `EXPORT_MAX` | `100000` | nombre maximal de lignes dans un export CSV | | `EXPORT_MAX` | `100000` | nombre maximal de lignes dans un export CSV |
| `PRESETS_FILE` | `/data/presets.json` | fichier des préréglages de tags ; liste intégrée s'il est absent (voir [docs/presets.fr.md](docs/presets.fr.md)) |
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux | | `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux |
| `DOCKER_HOST` | `tcp://docker-proxy:2375` dans compose | adresse de l'API Docker (`unix:///var/run/docker.sock` hors compose) | | `DOCKER_HOST` | `tcp://docker-proxy:2375` dans compose | adresse de l'API Docker (`unix:///var/run/docker.sock` hors compose) |
| `DOCKER_BACKFILL` | `1h` | historique lu pour un conteneur vu pour la première fois | | `DOCKER_BACKFILL` | `1h` | historique lu pour un conteneur vu pour la première fois |
@@ -349,13 +398,14 @@ résolutions.
| `HOST_LOGS_ROOT` | `/host` | emplacement de montage des répertoires de l'hôte | | `HOST_LOGS_ROOT` | `/host` | emplacement de montage des répertoires de l'hôte |
| `TZ` | `Europe/Paris` | fuseau horaire des horodatages RFC 3164 (qui n'en portent pas) | | `TZ` | `Europe/Paris` | fuseau horaire des horodatages RFC 3164 (qui n'en portent pas) |
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | réglage de l'ingestion | | `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | réglage de l'ingestion |
| `SPOOL_MAX_MB` | `1024` | taille du tampon disque des lots refusés par VictoriaLogs (`0` = pas de tampon : 15 s de tentatives, puis perte) |
## Débogage ## Débogage
- `docker compose logs -f logstream` : erreurs de réception et erreurs d'envoi vers - `docker compose logs -f logstream` : erreurs de réception et erreurs d'envoi vers
VictoriaLogs. VictoriaLogs.
- La barre du bas affiche les compteurs reçus / stockés / perdus et la dernière erreur de - La barre du bas affiche les compteurs reçus / stockés / perdus, les messages en attente dans
stockage. le tampon disque et la dernière erreur de stockage.
- <http://localhost:9428/select/vmui> : l'interface de VictoriaLogs, pour essayer des requêtes - <http://localhost:9428/select/vmui> : l'interface de VictoriaLogs, pour essayer des requêtes
LogsQL. LogsQL.
- API : - API :
@@ -407,6 +457,7 @@ Pour mettre à jour l'une d'elles :
| `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources | | `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources |
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` | | `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
| `tags.go` | stockage des tags de couleur | | `tags.go` | stockage des tags de couleur |
| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée |
| `api.go` | routes HTTP `/api/*` | | `api.go` | routes HTTP `/api/*` |
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion | | `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion |
+74 -25
View File
@@ -17,7 +17,9 @@ devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ Vi
![Schéma logique de Logstream](docs/architecture.png) ![Schéma logique de Logstream](docs/architecture.png)
- **Ingestion**: syslog (UDP/TCP) and Docker container logs both go through `sink()` (reverse DNS - **Ingestion**: syslog (UDP/TCP) and Docker container logs both go through `sink()` (reverse DNS
on IP hosts), then the `Store` queue, which sends them in batches to VictoriaLogs. on IP hosts, without holding up the listeners), then the `Store` queue, which sends them in
batches to VictoriaLogs. When VictoriaLogs is unreachable, batches are kept on disk
(`/data/spool`, up to `SPOOL_MAX_MB`) and sent again, oldest first, once it is back.
- **Live view**: `sink()` also publishes each message to the `Hub`, which streams it to the - **Live view**: `sink()` also publishes each message to the `Hub`, which streams it to the
browsers over SSE. browsers over SSE.
- **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs. - **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs.
@@ -72,7 +74,7 @@ message, host and app. Words are combined with AND.
The live view is disabled in this mode. The live view is disabled in this mode.
Each row shows, from left to right: the **reception time** (server clock), the timestamp Each row shows, from left to right: the **reception time** (server clock), the timestamp
found in the message itself (`msg_time`), severity, host, app and message. Click a host or an found in the message itself (`msg_time`), severity, host, app, codes of the tags found and message. Click a host or an
app to filter on it. app to filter on it.
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
@@ -90,15 +92,25 @@ width; the message takes the remaining space. Widths are remembered by the brows
(**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its (**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its
two-line layout without columns. two-line layout without columns.
## Stream and Time range modes
The first control of the filter bar switches between two display modes:
- **Stream**: the latest logs over a sliding duration (5 min to 30 days, or all), with the live
view.
- **Time range**: the logs between a start and an end date, typed in the time zone chosen in
Settings. ◀ and ▶ move to the previous or next range of the same length, the magnifier doubles
it around its middle. The live view pauses; the mode and the range are kept across reloads.
## Timeline ## Timeline
The timeline above the list shows the volume of logs per interval, counted by **reception The timeline above the list shows the volume of logs per interval, counted by **reception
time** on the server clock (so it matches the times shown in the rows). time** on the server clock (so it matches the times shown in the rows).
- Hover an interval: its bounds, total and detail per severity. - Hover an interval: its bounds, total and detail per severity.
- Click a bar to zoom on that interval, or drag across several bars to zoom on the selection. - Click a bar to show that interval in Time range mode, or drag across several bars to show the
The time range then shows the zoomed period ("× Reset zoom" or any other range leaves it); selection; "× Back to stream" returns to Stream mode. The list, the counters and the CSV export
the list, the counters and the CSV export follow the zoom, and the live view pauses. follow the range.
- In live mode the last interval grows as messages arrive, and the timeline reloads at each new - In live mode the last interval grows as messages arrive, and the timeline reloads at each new
interval. Nothing is refreshed while the browser tab is hidden; it catches up when shown again. interval. Nothing is refreshed while the browser tab is hidden; it catches up when shown again.
@@ -137,8 +149,10 @@ colored and exported like syslog messages:
to the labels shown) help with many containers. New containers are followed automatically to the labels shown) help with many containers. New containers are followed automatically
unless that option is turned off. Choices are saved per compose service (or container name) unless that option is turned off. Choices are saved per compose service (or container name)
in `/data/docker.json`, so they survive re-creations. in `/data/docker.json`, so they survive re-creations.
- Logstream remembers the position read in each container (`/data/docker-state.json`): after a - Logstream remembers the position of the last line stored for each container
restart it resumes without losing or duplicating lines. A container seen for the first time (`/data/docker-state.json`): after a restart it resumes without losing lines. The position
only moves once a line is in VictoriaLogs or in the disk buffer, and a full queue slows the
reading down instead of dropping lines. A container seen for the first time
is read from `DOCKER_BACKFILL` ago (1 hour by default). is read from `DOCKER_BACKFILL` ago (1 hour by default).
- Logstream itself and the proxy below are never collected; add the label - Logstream itself and the proxy below are never collected; add the label
`logstream.exclude=true` to any other container to exclude it for good. `logstream.exclude=true` to any other container to exclude it for good.
@@ -205,28 +219,41 @@ remembered per browser.
switches to black or white to stay readable) and options: whole word, match case, switches to black or white to stay readable) and options: whole word, match case,
regular expression, active. Tags are stored on the server in `/data/tags.json` regular expression, active. Tags are stored on the server in `/data/tags.json`
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel): (`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
`warning` (orange), `error` (red), `ok` (green). Default tags still using the colors of `warning` (orange) and `error` (red); `ok` (green) is in the *Log levels* preset. Default
earlier versions are switched to the pastel ones automatically. tags still using the colors of earlier versions are switched to the pastel ones
The *+ Preset…* menu adds ready-made tags for HTTP/HTTPS access logs (nginx and Apache automatically.
common/combined, Traefik CLF and JSON, Caddy JSON, HAProxy httplog): status codes (2xx green, The *+ Preset…* menu adds ready-made tags: HTTP/HTTPS access logs (status codes, methods,
3xx blue, 4xx orange, 5xx red), methods, probes and attacks (`wp-login.php`, `/.env`, probes, bots, TLS and proxy errors), system logs (SSH, sudo, kernel, systemd, firewall),
`../`…), bots and scripts, TLS and proxy errors. Tags already in the list are skipped, and the applications (Docker, databases) and general patterns (log levels, IPv4 addresses). Tags
added tags can be edited like any other. In a regular expression, a group named `hl` already in the list are skipped, and the added tags can be edited like any other. The list
comes from a text file you can edit (`PRESETS_FILE`): see [docs/presets.md](docs/presets.md)
for each preset and the file format. In a regular expression, a group named `hl`
(`(?<hl>…)`) colors only that part of the match: the presets use it to color the status code (`(?<hl>…)`) colors only that part of the match: the presets use it to color the status code
or the method, not the text around it. or the method, not the text around it.
Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag
until the tag is deleted (codes are also given to tags created by earlier versions). The
*Filters* column of the log list shows, as grey badges, the codes of the active tags found in
each message; it has room for 3, beyond that it shows 2 and `+N`, and the tooltip lists them
all.
- **Interface** - **Interface**
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon - *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
button in the header switches between light and dark. button in the header switches between light and dark.
- *Log display*: font size (tiny, small, medium, large) and font: the system monospace - *Log display*: font size (tiny, small, medium, large), density (normal, or compact to
font, or one of 12 free fonts made for dense text (JetBrains Mono, Fira Code, Source fit about 50% more lines on screen) and font: the system monospace font, one of 3 narrow
Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat built-in fonts served by LogStream itself, which work offline (Inconsolata Condensed, the
Mono, Noto Sans Mono, Victor Mono, DM Mono). They are loaded by the browser from narrowest, Iosevka and Ubuntu Mono), or one of 11 free fonts made for dense text (JetBrains
Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Inconsolata,
Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). These 11 are loaded by the browser from
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without [Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as typed. internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as
- **Data**: "Delete all logs" permanently erases every stored log (you must type typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
- **Data**: database figures read from VictoriaLogs (refreshed at most every 30 s): stored
lines, size on disk (index included), raw size and compression ratio, period covered with
the retention, lines of the last 24 h and last hour, distinct hosts and apps, free disk
space. Sizes use KB/MB/GB (Ko/Mo/Go in French). "Delete all logs" permanently erases every stored log (you must type
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable` `PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
(already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature. (already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
is reachable by others. is reachable by others.
## Authentication ## Authentication
@@ -234,7 +261,8 @@ remembered per browser.
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open): `AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open):
- **`local`** (default): a login page with the account `AUTH_USER` / `AUTH_PASS`; leave them - **`local`** (default): a login page with the account `AUTH_USER` / `AUTH_PASS`; leave them
empty to have no authentication (for instance behind a reverse proxy that already checks). empty to have no authentication (for instance behind a reverse proxy that already checks). The
UI then shows a warning banner, which can be closed.
- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…), - **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
authorization code flow with PKCE. authorization code flow with PKCE.
@@ -244,6 +272,10 @@ ends when `AUTH_USER` or `AUTH_PASS` changes; the log out button (top right) end
are written in the logs with the client address (`auth: failed login for "bob" from 192.0.2.7`). are written in the logs with the client address (`auth: failed login for "bob" from 192.0.2.7`).
Scripts can still call the API with HTTP Basic credentials (`curl -u user:pass`). Scripts can still call the API with HTTP Basic credentials (`curl -u user:pass`).
An optional **read-only account**, `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS`, can search, follow
the live view and export, but cannot change tags, sources or purge: those settings are greyed
out in its UI and the API answers `403`.
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it: To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
```yaml ```yaml
@@ -281,6 +313,14 @@ Every user the provider accepts for this client can log in: restrict access in t
in the logstream logs (`oidc: alice logged in`). With an `https` redirect URL, the cookies are in the logstream logs (`oidc: alice logged in`). With an `https` redirect URL, the cookies are
only sent over HTTPS: logstream must be reached through a TLS reverse proxy. only sent over HTTPS: logstream must be reached through a TLS reverse proxy.
To give read-only access to some users, set `OIDC_ADMIN_GROUP` (for instance
`logstream-admins`): only its members are admins, the others are read-only. The groups are read
from the `groups` claim of the ID token (`OIDC_GROUPS_CLAIM` to use another one); in Keycloak,
add a "Group Membership" mapper to the client (a leading `/` is ignored).
Whatever the mode, every answer carries security headers (Content-Security-Policy,
X-Frame-Options…), and the API refuses changes sent from another site (cross-site requests).
## Host names (reverse DNS) ## Host names (reverse DNS)
When a device sends its IP address as host name (or no host name at all), Logstream looks up When a device sends its IP address as host name (or no host name at all), Logstream looks up
@@ -301,16 +341,22 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs | | `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) | | `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
| `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication | | `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
| `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS` | empty | optional read-only account (`local` mode) |
| `LOGIN_LOGO` | empty | PNG shown on the login page, path inside the container (`local` mode) | | `LOGIN_LOGO` | empty | PNG shown on the login page, path inside the container (`local` mode) |
| `SESSION_TTL` | `12h` | session lifetime (both modes; `OIDC_SESSION_TTL` still works) | | `SESSION_TTL` | `12h` | session lifetime (both modes; `OIDC_SESSION_TTL` still works) |
| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) | | `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider | | `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider |
| `OIDC_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` | | `OIDC_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | requested scopes | | `OIDC_SCOPES` | `openid profile email` | requested scopes |
| `OIDC_ADMIN_GROUP` | empty | only members of this group are admins, the others read-only (empty = everyone is admin) |
| `OIDC_GROUPS_CLAIM` | `groups` | ID token claim that lists the groups |
| `RDNS` | `on` | resolve IP hosts to DNS names | | `RDNS` | `on` | resolve IP hosts to DNS names |
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) | | `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings | | `ALLOW_PURGE` | `false` | allow "Delete all logs" in Settings |
| `SYSLOG_TCP_MAX_CONNS` | `512` | syslog TCP connections open at once; more are refused |
| `SYSLOG_TCP_IDLE` | `30m` | a syslog TCP connection silent this long is closed (senders reconnect) |
| `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export | | `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export |
| `PRESETS_FILE` | `/data/presets.json` | color tag presets file; the built-in list when missing (see [docs/presets.md](docs/presets.md)) |
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers | | `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
| `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) | | `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) |
| `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time | | `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time |
@@ -319,11 +365,13 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `HOST_LOGS_ROOT` | `/host` | where the host directories are mounted | | `HOST_LOGS_ROOT` | `/host` | where the host directories are mounted |
| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) | | `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) |
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning | | `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning |
| `SPOOL_MAX_MB` | `1024` | disk buffer size for batches VictoriaLogs could not take (`0` = no buffer: retried for 15 s, then dropped) |
## Debugging ## Debugging
- `docker compose logs -f logstream`: receive errors and errors sending to VictoriaLogs. - `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. - The bottom bar shows received / stored / dropped counters, the messages waiting in the disk
buffer, and the last storage error.
- <http://localhost:9428/select/vmui>: VictoriaLogs' own UI to try LogsQL queries. - <http://localhost:9428/select/vmui>: VictoriaLogs' own UI to try LogsQL queries.
- API: - API:
```bash ```bash
@@ -374,6 +422,7 @@ To update one of them:
| `syslogserver.go` | syslog listeners opened and closed from Settings > Sources | | `syslogserver.go` | syslog listeners opened and closed from Settings > Sources |
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower | | `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
| `tags.go` | color tag storage | | `tags.go` | color tag storage |
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
| `api.go` | `/api/*` HTTP routes | | `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`), and in `web/login.html` for the login page | | `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
+19
View File
@@ -16,12 +16,14 @@ type API struct {
store *Store store *Store
hub *Hub hub *Hub
tags *TagStore tags *TagStore
presets string // presets file, built-in presets when missing
rdns *ReverseDNS rdns *ReverseDNS
allowPurge bool allowPurge bool
exportMax int exportMax int
docker *DockerManager // nil when DOCKER_LOGS is off docker *DockerManager // nil when DOCKER_LOGS is off
syslog *SyslogServer syslog *SyslogServer
host *HostLogs host *HostLogs
dbstats dbStatsCache
} }
func (a *API) Routes(mux *http.ServeMux) { func (a *API) Routes(mux *http.ServeMux) {
@@ -31,9 +33,11 @@ func (a *API) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/facets", a.facets) mux.HandleFunc("GET /api/facets", a.facets)
mux.HandleFunc("GET /api/stream", a.stream) mux.HandleFunc("GET /api/stream", a.stream)
mux.HandleFunc("GET /api/stats", a.stats) mux.HandleFunc("GET /api/stats", a.stats)
mux.HandleFunc("GET /api/dbstats", a.dbStats)
mux.HandleFunc("GET /api/tags", a.listTags) mux.HandleFunc("GET /api/tags", a.listTags)
mux.HandleFunc("POST /api/tags", a.createTag) mux.HandleFunc("POST /api/tags", a.createTag)
mux.HandleFunc("POST /api/tags/reset", a.resetTags) mux.HandleFunc("POST /api/tags/reset", a.resetTags)
mux.HandleFunc("GET /api/presets", a.listPresets)
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag) mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag) mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag)
mux.HandleFunc("GET /api/purge", a.purgeStatus) mux.HandleFunc("GET /api/purge", a.purgeStatus)
@@ -312,10 +316,25 @@ func (a *API) stats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s) writeJSON(w, http.StatusOK, s)
} }
// GET /api/dbstats[?refresh=1]: size and content of the VictoriaLogs base
// (Settings > Data), cached for 30 s.
func (a *API) dbStats(w http.ResponseWriter, r *http.Request) {
st, err := a.dbstats.get(r.Context(), a.store, r.URL.Query().Get("refresh") == "1")
if err != nil {
writeErr(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, st)
}
func (a *API) listTags(w http.ResponseWriter, r *http.Request) { func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.tags.List()) writeJSON(w, http.StatusOK, a.tags.List())
} }
func (a *API) listPresets(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, loadPresets(a.presets))
}
func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) { func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) {
var t Tag var t Tag
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t) err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t)
+51 -15
View File
@@ -42,6 +42,10 @@ const (
type authConfig struct { type authConfig struct {
mode string mode string
user, pass string // local mode user, pass string // local mode
viewerUser string // local mode: optional read-only account
viewerPass string
adminGroup string // oidc: only members of this group are admins (empty: everyone)
groupsClaim string // oidc: ID token claim listing the groups
issuer string issuer string
clientID string clientID string
clientSecret string clientSecret string
@@ -124,6 +128,9 @@ func newOIDC(c authConfig) (*OIDC, error) {
if !strings.Contains(" "+c.scopes+" ", " openid ") { if !strings.Contains(" "+c.scopes+" ", " openid ") {
c.scopes = "openid " + c.scopes c.scopes = "openid " + c.scopes
} }
if c.groupsClaim == "" {
c.groupsClaim = "groups"
}
return &OIDC{ return &OIDC{
cfg: c, cfg: c,
callback: ru.Path, callback: ru.Path,
@@ -159,6 +166,7 @@ func sessionKey(dir string) []byte {
type session struct { type session struct {
User string `json:"u"` User string `json:"u"`
Exp int64 `json:"e"` Exp int64 `json:"e"`
Viewer bool `json:"v,omitempty"` // read-only user
} }
// writeAuthRequired answers API calls without a session; the UI turns it into a reload // writeAuthRequired answers API calls without a session; the UI turns it into a reload
@@ -191,9 +199,12 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var s session var s session
if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp { if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp {
if r.URL.Path == "/auth/me" { if r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User}) writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User, "role": roleName(s.Viewer)})
return return
} }
if s.Viewer {
r = asViewer(r)
}
o.next.ServeHTTP(w, r) o.next.ServeHTTP(w, r)
return return
} }
@@ -257,16 +268,16 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
} }
http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure}) http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
user, err := o.exchange(r, q.Get("code"), ls) user, viewer, err := o.exchange(r, q.Get("code"), ls)
if err != nil { if err != nil {
log.Printf("oidc: login failed: %v", err) log.Printf("oidc: login failed: %v", err)
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden) http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
return return
} }
log.Printf("oidc: %s logged in", user) log.Printf("oidc: %s logged in (%s)", user, roleName(viewer))
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Name: sessionCookie,
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}), Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix(), Viewer: viewer}),
Path: "/", Path: "/",
MaxAge: int(o.cfg.sessionTTL.Seconds()), MaxAge: int(o.cfg.sessionTTL.Seconds()),
HttpOnly: true, HttpOnly: true,
@@ -286,14 +297,15 @@ func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }
// exchange trades the code for tokens and returns the user name from the verified ID token. // exchange trades the code for tokens and returns the user name from the verified ID
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) { // token, and whether the user is read-only (not in OIDC_ADMIN_GROUP).
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, bool, error) {
if code == "" { if code == "" {
return "", errors.New("no code in the callback") return "", false, errors.New("no code in the callback")
} }
meta, err := o.discover() meta, err := o.discover()
if err != nil { if err != nil {
return "", err return "", false, err
} }
form := url.Values{ form := url.Values{
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
@@ -309,7 +321,7 @@ func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, er
} }
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode())) req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil { if err != nil {
return "", err return "", false, err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
@@ -318,29 +330,53 @@ func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, er
} }
res, err := o.client.Do(req) res, err := o.client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("token endpoint: %w", err) return "", false, fmt.Errorf("token endpoint: %w", err)
} }
defer res.Body.Close() defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body)) return "", false, fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
} }
var tok struct { var tok struct {
IDToken string `json:"id_token"` IDToken string `json:"id_token"`
} }
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" { if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" {
return "", errors.New("token endpoint: no id_token in the response") return "", false, errors.New("token endpoint: no id_token in the response")
} }
claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce) claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce)
if err != nil { if err != nil {
return "", err return "", false, err
} }
viewer := o.cfg.adminGroup != "" && !hasGroup(claims[o.cfg.groupsClaim], o.cfg.adminGroup)
for _, k := range []string{"preferred_username", "email", "name", "sub"} { for _, k := range []string{"preferred_username", "email", "name", "sub"} {
if v, _ := claims[k].(string); v != "" { if v, _ := claims[k].(string); v != "" {
return v, nil return v, viewer, nil
} }
} }
return "", errors.New("id_token: no sub") return "", false, errors.New("id_token: no sub")
}
// hasGroup tells whether the groups claim (a list, or a single string) holds
// group; a leading "/" (Keycloak group paths) is ignored.
func hasGroup(claim any, group string) bool {
group = strings.TrimPrefix(group, "/")
var groups []string
switch v := claim.(type) {
case string:
groups = strings.Fields(strings.ReplaceAll(v, ",", " "))
case []any:
for _, g := range v {
if s, ok := g.(string); ok {
groups = append(groups, s)
}
}
}
for _, g := range groups {
if strings.TrimPrefix(g, "/") == group {
return true
}
}
return false
} }
// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token. // verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token.
+38 -16
View File
@@ -23,6 +23,8 @@ var loginFailDelay = time.Second // slows down password guessing
type Local struct { type Local struct {
user, pass string user, pass string
viewerUser string // optional read-only account
viewerPass string
ttl time.Duration ttl time.Duration
logo string // LOGIN_LOGO, served at /auth/logo logo string // LOGIN_LOGO, served at /auth/logo
key []byte key []byte
@@ -32,14 +34,17 @@ type Local struct {
func newLocal(c authConfig) *Local { func newLocal(c authConfig) *Local {
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session. // The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
m := hmac.New(sha256.New, sessionKey(c.dataDir)) m := hmac.New(sha256.New, sessionKey(c.dataDir))
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass)) m.Write([]byte("local\x00" + c.user + "\x00" + c.pass + "\x00" + c.viewerUser + "\x00" + c.viewerPass))
if c.loginLogo != "" { if c.loginLogo != "" {
if _, err := os.Stat(c.loginLogo); err != nil { if _, err := os.Stat(c.loginLogo); err != nil {
log.Printf("auth: LOGIN_LOGO: %v", err) log.Printf("auth: LOGIN_LOGO: %v", err)
} }
} }
log.Printf("local authentication enabled (user %s)", c.user) log.Printf("local authentication enabled (user %s)", c.user)
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)} if c.viewerUser != "" {
log.Printf("local read-only account enabled (user %s)", c.viewerUser)
}
return &Local{user: c.user, pass: c.pass, viewerUser: c.viewerUser, viewerPass: c.viewerPass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
} }
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -58,12 +63,17 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, loginPage, http.StatusFound) http.Redirect(w, r, loginPage, http.StatusFound)
return return
} }
user, ok := l.sessionUser(r) s, ok := l.sessionUser(r)
if !ok { if !ok {
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) { if u, p, basic := r.BasicAuth(); basic {
user, ok = u, true if viewer, valid := l.check(u, p); valid {
s, ok = session{User: u, Viewer: viewer}, true
} }
} }
}
if ok && s.Viewer {
r = asViewer(r)
}
switch { switch {
case r.URL.Path == loginPage: case r.URL.Path == loginPage:
if ok { if ok {
@@ -73,7 +83,7 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
l.next.ServeHTTP(w, r) l.next.ServeHTTP(w, r)
case ok && r.URL.Path == "/auth/me": case ok && r.URL.Path == "/auth/me":
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user}) writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": s.User, "role": roleName(s.Viewer)})
case ok: case ok:
l.next.ServeHTTP(w, r) l.next.ServeHTTP(w, r)
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me": case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
@@ -94,7 +104,8 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
} }
user, pass := r.PostFormValue("user"), r.PostFormValue("pass") user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
ret := safeReturn(r.PostFormValue("r")) ret := safeReturn(r.PostFormValue("r"))
if !l.check(user, pass) { viewer, valid := l.check(user, pass)
if !valid {
log.Printf("auth: failed login for %q from %s", user, clientIP(r)) log.Printf("auth: failed login for %q from %s", user, clientIP(r))
time.Sleep(loginFailDelay) time.Sleep(loginFailDelay)
q := url.Values{"e": {"1"}} q := url.Values{"e": {"1"}}
@@ -104,10 +115,10 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther) http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
return return
} }
log.Printf("auth: %s logged in from %s", user, clientIP(r)) log.Printf("auth: %s logged in from %s (%s)", user, clientIP(r), roleName(viewer))
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Name: sessionCookie,
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}), Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix(), Viewer: viewer}),
Path: "/", Path: "/",
MaxAge: int(l.ttl.Seconds()), MaxAge: int(l.ttl.Seconds()),
HttpOnly: true, HttpOnly: true,
@@ -117,19 +128,30 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, ret, http.StatusSeeOther) http.Redirect(w, r, ret, http.StatusSeeOther)
} }
func (l *Local) sessionUser(r *http.Request) (string, bool) { func (l *Local) sessionUser(r *http.Request) (session, bool) {
var s session var s session
c, err := r.Cookie(sessionCookie) c, err := r.Cookie(sessionCookie)
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp { if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
return "", false return session{}, false
} }
return s.User, true return s, true
} }
func (l *Local) check(user, pass string) bool { // check validates a user and password: the admin account, or the read-only one
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user)) // (viewer=true) when AUTH_VIEWER_USER is set.
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass)) func (l *Local) check(user, pass string) (viewer, ok bool) {
return u&p == 1 if same(user, l.user) && same(pass, l.pass) {
return false, true
}
if l.viewerUser != "" && same(user, l.viewerUser) && same(pass, l.viewerPass) {
return true, true
}
return false, false
}
// same compares in constant time, so the answer time says nothing of the secret.
func same(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
} }
// serveLogo sends LOGIN_LOGO; without it the login page hides the image. // serveLogo sends LOGIN_LOGO; without it the login page hides the image.
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// DBStats describes what VictoriaLogs holds (Settings > Data). Sizes are in
// bytes; a field VictoriaLogs did not give stays at its zero value.
type DBStats struct {
Rows int64 `json:"rows"` // stored log lines
DiskBytes int64 `json:"diskBytes"` // compressed data + index, on disk
IndexBytes int64 `json:"indexBytes"` // index part of DiskBytes
RawBytes int64 `json:"rawBytes"` // data before compression
FreeBytes int64 `json:"freeBytes"` // free space on the VictoriaLogs volume
Days int64 `json:"days"` // per-day partitions
Retention string `json:"retention"` // -retentionPeriod, e.g. "30d"
Oldest string `json:"oldest,omitempty"`
Newest string `json:"newest,omitempty"`
Last1h int64 `json:"last1h"`
Last24h int64 `json:"last24h"`
Hosts int64 `json:"hosts"`
Apps int64 `json:"apps"`
QueryError string `json:"queryError,omitempty"` // the LogsQL part failed, the metrics are still valid
Updated string `json:"updated"`
}
// dbStatsCache keeps the last answer for a while: the LogsQL part reads the
// whole base, which is too heavy to repeat each time the tab is opened.
type dbStatsCache struct {
mu sync.Mutex
at time.Time
last *DBStats
}
const dbStatsTTL = 30 * time.Second
func (c *dbStatsCache) get(ctx context.Context, s *Store, force bool) (*DBStats, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.last != nil && !force && time.Since(c.at) < dbStatsTTL {
return c.last, nil
}
st, err := s.DBStats(ctx)
if err != nil {
return nil, err
}
c.last, c.at = st, time.Now()
return st, nil
}
// DBStats reads the storage metrics of VictoriaLogs (/metrics), then counts
// with LogsQL what the metrics do not tell (period, recent volume, sources).
func (s *Store) DBStats(ctx context.Context) (*DBStats, error) {
st, err := s.storageMetrics(ctx)
if err != nil {
return nil, err
}
st.Updated = time.Now().UTC().Format(time.RFC3339)
if st.Rows == 0 {
return st, nil
}
qctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
rows, err := s.Query(qctx, "* | stats min(_time) oldest, max(_time) newest, count_uniq(host) hosts, count_uniq(app) apps")
if err == nil && len(rows) > 0 {
r := rows[0]
st.Oldest, _ = r["oldest"].(string)
st.Newest, _ = r["newest"].(string)
st.Hosts, st.Apps = toInt(r["hosts"]), toInt(r["apps"])
rows, err = s.Query(qctx, "_time:24h | stats count() last24h, count() if (_time:1h) last1h")
if err == nil && len(rows) > 0 {
st.Last24h, st.Last1h = toInt(rows[0]["last24h"]), toInt(rows[0]["last1h"])
}
}
if err != nil {
st.QueryError = err.Error()
}
return st, nil
}
func (s *Store) storageMetrics(ctx context.Context) (*DBStats, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/metrics", nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("VictoriaLogs /metrics: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
return parseStorageMetrics(resp.Body)
}
// parseStorageMetrics picks the storage figures out of the Prometheus text
// format of VictoriaLogs' /metrics (app/vlstorage).
func parseStorageMetrics(r io.Reader) (*DBStats, error) {
st := &DBStats{}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1<<20)
for sc.Scan() {
line := sc.Text()
if line == "" || line[0] == '#' {
continue
}
name, labels, value := splitMetric(line)
switch name {
case "vl_storage_rows":
st.Rows += int64(value)
case "vl_data_size_bytes":
st.DiskBytes += int64(value)
if labels["type"] == "indexdb" {
st.IndexBytes += int64(value)
}
case "vl_uncompressed_data_size_bytes":
st.RawBytes += int64(value)
case "vl_free_disk_space_bytes":
st.FreeBytes = int64(value)
case "vl_partitions":
st.Days = int64(value)
case "flag":
if labels["name"] == "retentionPeriod" {
st.Retention = labels["value"]
}
}
}
return st, sc.Err()
}
// splitMetric splits `name{a="x",b="y"} 12` into its parts. Label values with
// escaped quotes are not expected in the metrics read here.
func splitMetric(line string) (string, map[string]string, float64) {
sp := strings.LastIndexByte(line, ' ')
if sp < 0 {
return "", nil, 0
}
value, _ := strconv.ParseFloat(line[sp+1:], 64)
head := line[:sp]
name, rest, ok := strings.Cut(head, "{")
if !ok {
return head, nil, value
}
labels := map[string]string{}
rest = strings.TrimSuffix(rest, "}")
for rest != "" {
k, v, ok := strings.Cut(rest, `="`)
if !ok {
break
}
val, after, _ := strings.Cut(v, `"`)
labels[strings.TrimSpace(k)] = val
rest = strings.TrimPrefix(after, ",")
}
return name, labels, value
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Excerpt of VictoriaLogs v1.52 /metrics.
const vlMetrics = `# a comment
vl_free_disk_space_bytes{path="/vlogs"} 52428800000
vl_storage_rows{type="storage/inmemory"} 120
vl_storage_rows{type="storage/small"} 3000
vl_storage_rows{type="storage/big"} 1000000
vl_partitions 12
vl_data_size_bytes{type="indexdb"} 2048
vl_data_size_bytes{type="storage"} 1048576
vl_compressed_data_size_bytes{type="storage/big"} 1000000
vl_uncompressed_data_size_bytes{type="storage/inmemory"} 4096
vl_uncompressed_data_size_bytes{type="storage/small"} 100000
vl_uncompressed_data_size_bytes{type="storage/big"} 20000000
flag{name="retentionPeriod", value="30d", is_set="true"} 1
flag{name="httpListenAddr", value=":9428", is_set="true"} 1
`
func TestParseStorageMetrics(t *testing.T) {
st, err := parseStorageMetrics(strings.NewReader(vlMetrics))
if err != nil {
t.Fatal(err)
}
want := DBStats{Rows: 1003120, DiskBytes: 1050624, IndexBytes: 2048, RawBytes: 20104096, FreeBytes: 52428800000, Days: 12, Retention: "30d"}
if *st != want {
t.Fatalf("got %+v\nwant %+v", *st, want)
}
}
// statsVL answers /metrics and the LogsQL queries of DBStats.
type statsVL struct{ queries int }
func (v *statsVL) RoundTrip(r *http.Request) (*http.Response, error) {
body := vlMetrics
if r.URL.Path == "/select/logsql/query" {
v.queries++
_ = r.ParseForm()
if strings.HasPrefix(r.PostForm.Get("query"), "_time:24h") {
body = `{"last24h":"5000","last1h":"300"}` + "\n"
} else {
body = `{"oldest":"2026-09-21T08:00:00Z","newest":"2026-10-03T15:00:00Z","hosts":"4","apps":"17"}` + "\n"
}
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
}
func TestDBStatsHandler(t *testing.T) {
vl := &statsVL{}
store := NewStore("http://vl", 10, 10, time.Second, nil)
store.client.Transport = vl
store.streamClient.Transport = vl
a := &API{store: store}
get := func(url string) DBStats {
rec := httptest.NewRecorder()
a.dbStats(rec, httptest.NewRequest("GET", url, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var st DBStats
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil {
t.Fatal(err)
}
return st
}
st := get("/api/dbstats")
if st.Rows != 1003120 || st.Oldest != "2026-09-21T08:00:00Z" || st.Hosts != 4 || st.Apps != 17 || st.Last24h != 5000 || st.Last1h != 300 || st.QueryError != "" {
t.Fatalf("unexpected stats: %+v", st)
}
get("/api/dbstats")
if vl.queries != 2 {
t.Fatalf("second call within the TTL should be cached, got %d queries", vl.queries)
}
get("/api/dbstats?refresh=1")
if vl.queries != 4 {
t.Fatalf("refresh=1 should query again, got %d queries", vl.queries)
}
}
+8 -1
View File
@@ -17,16 +17,21 @@ services:
AUTH_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte ci-dessous) ou oidc AUTH_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte ci-dessous) ou oidc
AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
AUTH_PASS: ${AUTH_PASS:-} AUTH_PASS: ${AUTH_PASS:-}
AUTH_VIEWER_USER: ${AUTH_VIEWER_USER:-} # compte en lecture seule (optionnel)
AUTH_VIEWER_PASS: ${AUTH_VIEWER_PASS:-}
LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes) LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes)
PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md)
OIDC_ISSUER: ${OIDC_ISSUER:-} OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-} OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email} OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-} # vide = tout le monde est admin, sinon les autres sont en lecture seule
OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups}
SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc) SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
RDNS: ${RDNS:-on} # resol dns RDNS: ${RDNS:-on} # resol dns
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
ALLOW_PURGE: ${ALLOW_PURGE:-true} ALLOW_PURGE: ${ALLOW_PURGE:-false} # true pour autoriser « Supprimer tous les logs »
EXPORT_MAX: ${EXPORT_MAX:-100000} EXPORT_MAX: ${EXPORT_MAX:-100000}
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker
DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API Docker DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API Docker
@@ -41,6 +46,8 @@ services:
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile - /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
# logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env # logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env
# - ./logo.png:/config/logo.png:ro # - ./logo.png:/config/logo.png:ro
# prereglages de tags personnalises, avec PRESETS_FILE=/config/presets.json dans .env
# - ./presets.json:/config/presets.json:ro
labels: labels:
logstream.exclude: "true" # pas de collect des logs logstream logstream.exclude: "true" # pas de collect des logs logstream
+10 -5
View File
@@ -62,7 +62,7 @@ type DockerManager struct {
hostName string hostName string
containers []DockerContainer containers []DockerContainer
followers map[string]*follower // container ID -> running follower followers map[string]*follower // container ID -> running follower
checkpoint map[string]time.Time // container ID -> timestamp of the last line read checkpoint map[string]time.Time // container ID -> timestamp of the last line stored
dirty bool dirty bool
connected bool connected bool
lastErr string lastErr string
@@ -389,16 +389,19 @@ func (m *DockerManager) follow(ctx context.Context, c DockerContainer) {
} }
} }
func sleepCtx(ctx context.Context, d time.Duration) { // sleepCtx waits for d, or returns false if the context ends first.
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d) t := time.NewTimer(d)
defer t.Stop() defer t.Stop()
select { select {
case <-ctx.Done(): case <-ctx.Done():
return false
case <-t.C: case <-t.C:
return true
} }
} }
// since returns where to resume reading: just after the last line read, or // since returns where to resume reading: just after the last line stored, or
// DOCKER_BACKFILL ago for a container seen for the first time. // DOCKER_BACKFILL ago for a container seen for the first time.
func (m *DockerManager) since(id string) time.Time { func (m *DockerManager) since(id string) time.Time {
m.mu.Lock() m.mu.Lock()
@@ -530,8 +533,6 @@ func (m *DockerManager) emit(c DockerContainer, stream string, line []byte) {
} else if t, err := time.Parse(time.RFC3339Nano, s); err == nil { } else if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
ts, s = t, "" ts, s = t, ""
} }
defer m.setCheckpoint(c.ID, ts)
s = ansiRe.ReplaceAllString(s, "") s = ansiRe.ReplaceAllString(s, "")
if !utf8.ValidString(s) { if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, string(utf8.RuneError)) s = strings.ToValidUTF8(s, string(utf8.RuneError))
@@ -575,6 +576,10 @@ func (m *DockerManager) emit(c DockerContainer, stream string, line []byte) {
"compose_service": c.Service, "compose_service": c.Service,
"stream": stream, "stream": stream,
}, },
// Docker keeps the logs: wait for room in the queue rather than drop the
// line, and resume after it only once it is stored.
Wait: true,
Done: func() { m.setCheckpoint(c.ID, ts) },
}) })
} }
+114
View File
@@ -0,0 +1,114 @@
[English](presets.md) · **Français**
# Préréglages de tags de couleur
Dans **Paramètres › Filtres**, le menu **+ Préréglage…** ajoute d'un clic un groupe de tags de
couleur tout faits. Les tags ajoutés sont des tags ordinaires : vous pouvez changer leur couleur,
leur motif ou leurs options, ou les supprimer. Un tag dont le motif est déjà dans la liste n'est
pas ajouté en double.
La liste vient d'un fichier texte, [`presets.json`](../presets.json), intégré à LogStream. Vous
pouvez le remplacer par votre propre fichier (voir [Utiliser votre propre fichier](#utiliser-votre-propre-fichier)).
## Préréglages intégrés
### HTTP/HTTPS
Ces préréglages lisent les logs d'accès de nginx et Apache (formats common et combined), Traefik
(CLF et JSON), Caddy (JSON) et HAProxy (`option httplog`).
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Codes HTTP | `HTTP 2xx` vert, `HTTP 3xx` bleu, `HTTP 4xx` orange, `HTTP 5xx` rouge | seulement le code de statut, par exemple `404` dans `"GET /x HTTP/1.1" 404 153`, `"status":404` ou `"DownstreamStatus":404`. Les autres nombres de la ligne (taille, chemin) ne sont pas touchés. |
| Méthodes HTTP | `GET/HEAD/OPTIONS` gris, `POST/PUT/PATCH` violet, `DELETE` rose | seulement la méthode dans `"GET /chemin` ou `"method":"GET"` (en majuscules uniquement) |
| Sondes et attaques | `sondes / attaques` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
| Robots et scripts | `robots / scripts` | les mots finissant par `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
| Erreurs TLS/HTTPS et proxy | `erreurs TLS`, `erreurs proxy` | échecs de handshake TLS, certificats expirés ou refusés, `x509:` ; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
### Système
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| SSH et connexions | `échecs de connexion` rouge, `connexions` vert | sshd/PAM : `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`… ; `Accepted publickey`, `session opened for user`, `New session … of user` |
| Commandes sudo | `commandes sudo` | la commande lancée, par exemple `COMMAND=/usr/bin/apt` |
| Noyau : OOM, plantages, disques | `mémoire épuisée`, `erreurs noyau` | `Out of memory`, `oom-killer`, `Killed process 4242` ; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
| Services systemd | `services en échec` rouge, `démarrage/arrêt de service` vert | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly` ; `Started`, `Stopping`, `Reloaded`, `Reached target` |
| Pare-feu et fail2ban | `pare-feu` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
### Applications
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Docker et conteneurs | `problèmes de conteneur` | `exited with code 137` (codes non nuls seulement), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
| Bases de données | `erreurs base de données` | PostgreSQL et MySQL/MariaDB : `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
### Général
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Niveaux de log | `fatal / critique` rouge, `info / notice` bleu, `debug / trace` gris, `ok` vert | ces mots en mots entiers, quelle que soit la casse (les tags par défaut `warning` et `error` couvrent le reste) |
| Adresses IPv4 | `adresses IPv4` | `192.168.1.20`, `203.0.113.9`… Les numéros de version à quatre parties comme `1.2.3.4` sont aussi colorés. |
Quand des tags se chevauchent, celui placé le plus haut dans la liste l'emporte : les
préréglages ajoutés après les tags par défaut ne les masquent donc jamais.
## Utiliser votre propre fichier
LogStream lit le fichier indiqué par `PRESETS_FILE`, `/data/presets.json` par défaut (dans le
volume `logstream-data`). S'il n'existe pas, la liste intégrée est utilisée. Le fichier est relu
à chaque ouverture des Paramètres : pas besoin de redémarrer après une modification.
Avec docker-compose, le plus simple est de garder le fichier à côté de `docker-compose.yml` :
1. Copiez [`presets.json`](../presets.json) depuis ce dépôt et modifiez-le.
2. Dans `docker-compose.yml`, décommentez la ligne `- ./presets.json:/config/presets.json:ro`.
3. Dans `.env`, mettez `PRESETS_FILE=/config/presets.json`, puis lancez `docker compose up -d`.
Si le fichier est invalide (erreur JSON, expression régulière ou couleur incorrecte, id en
double), les Paramètres affichent l'erreur et la liste intégrée est utilisée jusqu'à correction.
## Format du fichier
Le fichier est une liste JSON de groupes. Chaque groupe a un nom et une liste de préréglages ;
chaque préréglage a un `id`, un nom et ses tags.
```json
[
{
"group": { "en": "My apps", "fr": "Mes applis" },
"presets": [
{
"id": "monappli",
"name": "Mon appli",
"tags": [
{ "label": "paiement refusé", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
{ "label": "commande", "color": "#86efac", "pattern": "order #\\d+" },
{ "label": "lent", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
]
}
]
}
]
```
| Champ | Obligatoire | Signification |
| --- | --- | --- |
| `group` | oui | nom du groupe dans le menu |
| `id` | oui | identifiant unique du préréglage |
| `name` | oui | nom du préréglage dans le menu |
| `tags[].pattern` | oui | ce qu'il faut colorer : une expression régulière, ou du texte simple avec `"regex": false` |
| `tags[].color` | oui | couleur de fond, `#rrggbb` |
| `tags[].label` | non | nom affiché dans la liste des tags à la place du motif |
| `tags[].regex` | non | `true` par défaut |
| `tags[].wholeWord` | non | mots entiers seulement, `false` par défaut |
| `tags[].caseSensitive` | non | respecter la casse, `false` par défaut |
Les noms et libellés sont soit un seul texte pour toutes les langues (`"Mon appli"`), soit un
texte par langue (`{ "en": "My app", "fr": "Mon appli" }`) ; une langue absente se rabat sur
l'anglais.
Les expressions régulières doivent fonctionner à la fois dans le navigateur (JavaScript) et en
Go, qui les vérifie : évitez les assertions arrière `(?<=…)`, avant `(?=…)` et les références
arrière `\1`. En JSON, chaque barre oblique inverse s'écrit deux fois : `\d` devient `"\\d"`. Un
groupe nommé `hl`, `(?<hl>…)`, ne colore que cette partie de la correspondance, comme le font
les préréglages HTTP avec `(?<hl>5\\d\\d)`.
+111
View File
@@ -0,0 +1,111 @@
**English** · [Français](presets.fr.md)
# Color tag presets
In **Settings › Filters**, the **+ Preset…** menu adds a group of ready-made color tags in one
click. Added tags are ordinary tags: you can change their color, pattern or options, or delete
them. A tag whose pattern is already in the list is not added twice.
The list comes from a text file, [`presets.json`](../presets.json), built into LogStream. You
can replace it with your own file (see [Using your own file](#using-your-own-file)).
## Built-in presets
### HTTP/HTTPS
These presets read access logs from nginx and Apache (common and combined formats), Traefik
(CLF and JSON), Caddy (JSON) and HAProxy (`option httplog`).
| Preset | Tags | What gets colored |
| --- | --- | --- |
| HTTP status codes | `HTTP 2xx` green, `HTTP 3xx` blue, `HTTP 4xx` orange, `HTTP 5xx` red | only the status code, e.g. `404` in `"GET /x HTTP/1.1" 404 153`, `"status":404` or `"DownstreamStatus":404`. Other numbers on the line (size, path) are left alone. |
| HTTP methods | `GET/HEAD/OPTIONS` grey, `POST/PUT/PATCH` purple, `DELETE` pink | only the method in `"GET /path` or `"method":"GET"` (upper case only) |
| Probes and attacks | `probes / attacks` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
| Bots and scripts | `bots / scripts` | words ending in `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
| TLS/HTTPS and proxy errors | `TLS errors`, `proxy errors` | TLS handshake failures, expired or rejected certificates, `x509:`; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
### System
| Preset | Tags | What gets colored |
| --- | --- | --- |
| SSH and logins | `login failures` red, `logins` green | sshd/PAM: `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`…; `Accepted publickey`, `session opened for user`, `New session … of user` |
| sudo commands | `sudo commands` | the command run, e.g. `COMMAND=/usr/bin/apt` |
| Kernel: OOM, crashes, disks | `out of memory`, `kernel errors` | `Out of memory`, `oom-killer`, `Killed process 4242`; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
| systemd services | `failed services` red, `service start/stop` green | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly`; `Started`, `Stopping`, `Reloaded`, `Reached target` |
| Firewall and fail2ban | `firewall` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
### Applications
| Preset | Tags | What gets colored |
| --- | --- | --- |
| Docker and containers | `container problems` | `exited with code 137` (non-zero codes only), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
| Databases | `database errors` | PostgreSQL and MySQL/MariaDB: `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
### General
| Preset | Tags | What gets colored |
| --- | --- | --- |
| Log levels | `fatal / critical` red, `info / notice` blue, `debug / trace` grey, `ok` green | these words as whole words, any case (the default `warning` and `error` tags cover the rest) |
| IPv4 addresses | `IPv4 addresses` | `192.168.1.20`, `203.0.113.9`… Four-part version numbers such as `1.2.3.4` are colored too. |
When tags overlap, the one highest in the tag list wins, so presets added after the default
tags never hide them.
## Using your own file
LogStream reads the file named by `PRESETS_FILE`, `/data/presets.json` by default (in the
`logstream-data` volume). When the file does not exist, the built-in list is used. The file is
read again each time Settings is opened: no restart is needed after an edit.
With docker-compose, the simplest is to keep the file next to `docker-compose.yml`:
1. Copy [`presets.json`](../presets.json) from this repository and edit it.
2. In `docker-compose.yml`, uncomment the line `- ./presets.json:/config/presets.json:ro`.
3. In `.env`, set `PRESETS_FILE=/config/presets.json`, then run `docker compose up -d`.
If the file is invalid (JSON error, bad regular expression or color, duplicate id), Settings
shows the error and the built-in list is used until the file is fixed.
## File format
The file is a JSON list of groups. Each group has a name and a list of presets; each preset has
an `id`, a name and its tags.
```json
[
{
"group": { "en": "My apps", "fr": "Mes applis" },
"presets": [
{
"id": "myapp",
"name": "My app",
"tags": [
{ "label": "payment failed", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
{ "label": "order", "color": "#86efac", "pattern": "order #\\d+" },
{ "label": "slow", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
]
}
]
}
]
```
| Field | Required | Meaning |
| --- | --- | --- |
| `group` | yes | name of the group in the menu |
| `id` | yes | unique identifier of the preset |
| `name` | yes | name of the preset in the menu |
| `tags[].pattern` | yes | what to color: a regular expression, or plain text with `"regex": false` |
| `tags[].color` | yes | background color, `#rrggbb` |
| `tags[].label` | no | name shown in the tag list instead of the pattern |
| `tags[].regex` | no | `true` by default |
| `tags[].wholeWord` | no | only match whole words, `false` by default |
| `tags[].caseSensitive` | no | match case, `false` by default |
Names and labels are either one text for every language (`"My app"`) or one text per language
(`{ "en": "My app", "fr": "Mon appli" }`); a missing language falls back to English.
Regular expressions must work both in the browser (JavaScript) and in Go, which checks them:
avoid look-behind `(?<=…)`, look-ahead `(?=…)` and back-references `\1`. In JSON, every
backslash is written twice: `\d` becomes `"\\d"`. A group named `hl`, `(?<hl>…)`, colors only
that part of the match, as the HTTP presets do with `(?<hl>5\\d\\d)`.
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"io/fs"
"net/http"
"net/url"
"regexp"
"strings"
)
// Request guards shared by every auth mode: security headers, a cross-site
// request check, and the read-only role.
type viewerKey struct{}
// asViewer marks the request as made by a read-only user.
func asViewer(r *http.Request) *http.Request {
return r.WithContext(context.WithValue(r.Context(), viewerKey{}, true))
}
func isViewer(r *http.Request) bool {
v, _ := r.Context().Value(viewerKey{}).(bool)
return v
}
func roleName(viewer bool) string {
if viewer {
return "viewer"
}
return "admin"
}
func isSafeMethod(m string) bool {
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}
// readOnly refuses the API calls that change something (tags, sources, purge)
// to read-only users. Without authentication everyone is admin.
func readOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isViewer(r) && !isSafeMethod(r.Method) && strings.HasPrefix(r.URL.Path, "/api/") {
writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"})
return
}
next.ServeHTTP(w, r)
})
}
// crossSite tells whether a request that changes something comes from another
// site (a form or script on a third-party page), using the headers browsers
// add; tools such as curl send neither and are let through.
func crossSite(r *http.Request) bool {
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "none":
return false
case "":
default: // same-site, cross-site
return true
}
o := r.Header.Get("Origin")
if o == "" {
return false
}
u, err := url.Parse(o)
return err != nil || !strings.EqualFold(u.Host, r.Host)
}
// secure adds the security headers to every answer and refuses cross-site
// changes. Without authentication it also answers /auth/me, so the UI can
// warn that anyone on the network has full access.
func secure(next http.Handler, csp string, authOn bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", csp)
if !isSafeMethod(r.Method) && crossSite(r) {
writeErr(w, http.StatusForbidden, &codedError{code: "cross_site", msg: "cross-site request refused"})
return
}
if !authOn && r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "none", "role": "admin"})
return
}
next.ServeHTTP(w, r)
})
}
var inlineScript = regexp.MustCompile(`(?s)<script>(.*?)</script>`)
// contentSecurityPolicy allows the UI's own files, the inline scripts of the
// embedded pages (by hash) and the optional Bunny Fonts.
func contentSecurityPolicy(static fs.FS) string {
scripts := []string{"'self'"}
for _, page := range []string{"index.html", "login.html"} {
b, err := fs.ReadFile(static, page)
if err != nil {
continue
}
for _, m := range inlineScript.FindAllSubmatch(b, -1) {
sum := sha256.Sum256(m[1])
scripts = append(scripts, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'")
}
}
return strings.Join([]string{
"default-src 'self'",
"script-src " + strings.Join(scripts, " "),
// Inline style attributes carry the tag and project colors.
"style-src 'self' 'unsafe-inline' https://fonts.bunny.net",
"font-src 'self' https://fonts.bunny.net",
"img-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
}, "; ")
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"errors"
"io/fs"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
var echo = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) })
func TestSecureHeadersAndCrossSite(t *testing.T) {
h := secure(echo, "default-src 'self'", false)
cases := []struct {
method string
hdr map[string]string
want int
}{
{"GET", map[string]string{"Sec-Fetch-Site": "cross-site"}, 200}, // reading is fine
{"POST", nil, 200}, // curl, scripts
{"POST", map[string]string{"Sec-Fetch-Site": "same-origin"}, 200},
{"POST", map[string]string{"Sec-Fetch-Site": "cross-site"}, 403},
{"DELETE", map[string]string{"Sec-Fetch-Site": "same-site"}, 403},
{"POST", map[string]string{"Origin": "http://logs.lan:8080"}, 200},
{"POST", map[string]string{"Origin": "https://evil.example"}, 403},
{"PUT", map[string]string{"Origin": "null"}, 403},
}
for _, c := range cases {
r := httptest.NewRequest(c.method, "http://logs.lan:8080/api/purge", nil)
for k, v := range c.hdr {
r.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != c.want {
t.Errorf("%s %v: %d, want %d", c.method, c.hdr, rec.Code, c.want)
}
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("%s %v: security headers missing", c.method, c.hdr)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
if !strings.Contains(rec.Body.String(), `"mode":"none"`) || !strings.Contains(rec.Body.String(), `"role":"admin"`) {
t.Errorf("/auth/me without auth: %s", rec.Body)
}
rec = httptest.NewRecorder()
secure(echo, "", true).ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
if rec.Body.String() != "app /auth/me" {
t.Errorf("/auth/me with auth answered by the guard: %s", rec.Body)
}
}
func TestContentSecurityPolicyHashesInlineScripts(t *testing.T) {
static, _ := fs.Sub(webFS, "web")
csp := contentSecurityPolicy(static)
// index.html and login.html each have inline scripts.
if n := strings.Count(csp, "'sha256-"); n < 3 {
t.Errorf("%d script hashes in %q", n, csp)
}
for _, want := range []string{"frame-ancestors 'none'", "connect-src 'self'", "https://fonts.bunny.net"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q", want)
}
}
}
func TestLocalViewerIsReadOnly(t *testing.T) {
loginFailDelay = 0
h, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", viewerUser: "guest", viewerPass: "ro", dataDir: t.TempDir()}, readOnly(echo))
if err != nil {
t.Fatal(err)
}
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Transport: hosts{"app.test": h}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
app := "http://app.test"
login(t, c, app, "guest", "ro", "/")
if code, body := get(t, c, app+"/auth/me"); code != 200 || !strings.Contains(body, `"role":"viewer"`) {
t.Fatalf("me: %d %s", code, body)
}
if code, _ := get(t, c, app+"/api/logs"); code != 200 {
t.Errorf("viewer reading logs: %d", code)
}
res, err := c.Post(app+"/api/purge", "application/json", strings.NewReader(`{"confirm":"PURGE"}`))
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusForbidden {
t.Errorf("viewer purge: %d, want 403", res.StatusCode)
}
// The admin account still changes things, also through Basic auth.
req, _ := http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
req.SetBasicAuth("admin", "pw")
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != 200 {
t.Errorf("admin change: %d", res.StatusCode)
}
req, _ = http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
req.SetBasicAuth("guest", "ro")
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != http.StatusForbidden {
t.Errorf("viewer change through Basic auth: %d", res.StatusCode)
}
}
func TestOIDCAdminGroup(t *testing.T) {
for _, tc := range []struct {
groups any
role string
}{
{[]any{"staff", "/logstream-admins"}, "admin"},
{[]any{"staff"}, "viewer"},
{nil, "viewer"},
} {
idp := newFakeIdP(t)
idp.claims = func(c map[string]any) {
if tc.groups != nil {
c["groups"] = tc.groups
}
}
h, err := newAuth(authConfig{
mode: "oidc", issuer: "http://idp.test/realm", clientID: "logstream", clientSecret: "s3cret",
redirectURL: "http://app.test/auth/callback", dataDir: t.TempDir(), adminGroup: "logstream-admins",
}, readOnly(echo))
if err != nil {
t.Fatal(err)
}
netw := hosts{"app.test": h, "idp.test": idp.mux}
h.(*OIDC).client.Transport = netw
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Transport: netw}
get(t, c, "http://app.test/")
if _, body := get(t, c, "http://app.test/auth/me"); !strings.Contains(body, `"role":"`+tc.role+`"`) {
t.Errorf("groups %v: %s, want role %s", tc.groups, body, tc.role)
}
}
}
func TestHasGroup(t *testing.T) {
if !hasGroup("ops logstream-admins", "/logstream-admins") || !hasGroup([]any{"a", "b"}, "b") || hasGroup(42, "b") {
t.Error("hasGroup")
}
}
func TestTCPIdleTimeout(t *testing.T) {
a, b := net.Pipe()
defer b.Close()
c := idleConn{a, 30 * time.Millisecond}
go func() { _, _ = b.Write([]byte("x")) }()
buf := make([]byte, 1)
if _, err := c.Read(buf); err != nil {
t.Fatal(err)
}
start := time.Now()
if _, err := c.Read(buf); !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("silent connection: %v, want a deadline error", err)
}
if time.Since(start) > time.Second {
t.Error("deadline not applied")
}
}
+2 -2
View File
@@ -153,7 +153,7 @@ func TestHistogramHandler(t *testing.T) {
{"_time":"2000-01-01T00:00:00Z","severity":"info","hits":"9"} {"_time":"2000-01-01T00:00:00Z","severity":"info","hits":"9"}
`, recent.Format(time.RFC3339), recent.Format(time.RFC3339), older.Format(time.RFC3339)) `, recent.Format(time.RFC3339), recent.Format(time.RFC3339), older.Format(time.RFC3339))
}} }}
store := NewStore("http://vl", 10, 10, time.Second) store := NewStore("http://vl", 10, 10, time.Second, nil)
store.streamClient.Transport = vl store.streamClient.Transport = vl
a := &API{store: store} a := &API{store: store}
@@ -207,7 +207,7 @@ func TestHistogramDayInParis(t *testing.T) {
{"_time":"2026-09-21T20:00:00Z","severity":"info","hits":"100"} {"_time":"2026-09-21T20:00:00Z","severity":"info","hits":"100"}
` `
}} }}
store := NewStore("http://vl", 10, 10, time.Second) store := NewStore("http://vl", 10, 10, time.Second, nil)
store.streamClient.Transport = vl store.streamClient.Transport = vl
a := &API{store: store} a := &API{store: store}
from := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC) from := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
+2
View File
@@ -327,6 +327,7 @@ func (h *HostLogs) readFile(path, name string, off int64) (int64, error) {
} }
e.SourceType = "host" e.SourceType = "host"
e.Extra = map[string]string{"log_file": "/var/log/" + name} e.Extra = map[string]string{"log_file": "/var/log/" + name}
e.Wait = true
h.sink(e) h.sink(e)
h.count(1, 0) h.count(1, 0)
} }
@@ -400,6 +401,7 @@ func (h *HostLogs) emitJournal(je *journalEntry) {
Proto: "journal", Proto: "journal",
SourceType: "host", SourceType: "host",
Extra: map[string]string{"unit": f["_SYSTEMD_UNIT"]}, Extra: map[string]string{"unit": f["_SYSTEMD_UNIT"]},
Wait: true,
}) })
} }
+43 -6
View File
@@ -41,6 +41,7 @@ type config struct {
batchSize int batchSize int
queueSize int queueSize int
flushEvery time.Duration flushEvery time.Duration
spoolMax int64
} }
func getenv(key, def string) string { func getenv(key, def string) string {
@@ -57,6 +58,14 @@ func getenvInt(key string, def int) int {
return def return def
} }
// getenvIntZero is getenvInt that also accepts 0 (to turn a feature off).
func getenvIntZero(key string, def int) int {
if v, err := strconv.Atoi(os.Getenv(key)); err == nil && v >= 0 {
return v
}
return def
}
func getenvBool(key string, def bool) bool { func getenvBool(key string, def bool) bool {
switch strings.ToLower(os.Getenv(key)) { switch strings.ToLower(os.Getenv(key)) {
case "1", "true", "yes", "on": case "1", "true", "yes", "on":
@@ -84,6 +93,10 @@ func main() {
mode: getenv("AUTH_MODE", "local"), mode: getenv("AUTH_MODE", "local"),
user: os.Getenv("AUTH_USER"), user: os.Getenv("AUTH_USER"),
pass: os.Getenv("AUTH_PASS"), pass: os.Getenv("AUTH_PASS"),
viewerUser: os.Getenv("AUTH_VIEWER_USER"),
viewerPass: os.Getenv("AUTH_VIEWER_PASS"),
adminGroup: os.Getenv("OIDC_ADMIN_GROUP"),
groupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"),
issuer: os.Getenv("OIDC_ISSUER"), issuer: os.Getenv("OIDC_ISSUER"),
clientID: os.Getenv("OIDC_CLIENT_ID"), clientID: os.Getenv("OIDC_CLIENT_ID"),
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"), clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
@@ -95,7 +108,7 @@ func main() {
}, },
rdns: getenvBool("RDNS", true), rdns: getenvBool("RDNS", true),
dnsServer: os.Getenv("DNS_SERVER"), dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true), allowPurge: getenvBool("ALLOW_PURGE", false),
exportMax: getenvInt("EXPORT_MAX", 100000), exportMax: getenvInt("EXPORT_MAX", 100000),
dockerLogs: getenvBool("DOCKER_LOGS", false), dockerLogs: getenvBool("DOCKER_LOGS", false),
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"), dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
@@ -105,6 +118,7 @@ func main() {
batchSize: getenvInt("BATCH_SIZE", 1000), batchSize: getenvInt("BATCH_SIZE", 1000),
queueSize: getenvInt("QUEUE_SIZE", 100000), queueSize: getenvInt("QUEUE_SIZE", 100000),
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond, flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
spoolMax: int64(getenvIntZero("SPOOL_MAX_MB", 1024)) << 20,
} }
cfg.auth.dataDir = cfg.dataDir cfg.auth.dataDir = cfg.dataDir
@@ -117,7 +131,14 @@ func main() {
log.Fatalf("tags: %v", err) log.Fatalf("tags: %v", err)
} }
store := NewStore(cfg.vlogsURL, cfg.batchSize, cfg.queueSize, cfg.flushEvery) var spool *Spool
if cfg.spoolMax > 0 {
if spool, err = OpenSpool(filepath.Join(cfg.dataDir, "spool"), cfg.spoolMax); err != nil {
log.Printf("disk buffer disabled: %v", err)
spool = nil
}
}
store := NewStore(cfg.vlogsURL, cfg.batchSize, cfg.queueSize, cfg.flushEvery, spool)
storeDone := make(chan struct{}) storeDone := make(chan struct{})
go func() { go func() {
store.Run(ctx) store.Run(ctx)
@@ -128,12 +149,19 @@ func main() {
rdns := NewReverseDNS(cfg.rdns, cfg.dnsServer) rdns := NewReverseDNS(cfg.rdns, cfg.dnsServer)
sink := func(e *Entry) { sink := func(e *Entry) {
// Host sent as an IP (or no host in the header): replace it with its DNS name. // Host sent as an IP (or no host in the header): replace it with its DNS name.
// A new IP waits at most 300 ms; slower lookups finish in the background. // A new IP waits at most 300 ms, without holding up the listener; slower
if name := rdns.Lookup(e.Host, 300*time.Millisecond); name != "" { // lookups finish in the background.
rdns.Resolve(e.Host, 300*time.Millisecond, func(name string) {
if name != "" {
e.HostIP, e.Host = e.Host, name e.HostIP, e.Host = e.Host, name
} }
store.Enqueue(e) store.Enqueue(e)
hub.Publish(e) hub.Publish(e)
})
}
tcpMaxConns = getenvInt("SYSLOG_TCP_MAX_CONNS", tcpMaxConns)
if d := getenvDuration("SYSLOG_TCP_IDLE", tcpIdle); d > 0 {
tcpIdle = d
} }
// Listening errors (port already used…) are shown in Settings > Sources. // Listening errors (port already used…) are shown in Settings > Sources.
syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink) syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink)
@@ -143,7 +171,8 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
api := &API{store: store, hub: hub, tags: tags, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv} presets := getenv("PRESETS_FILE", filepath.Join(cfg.dataDir, "presets.json"))
api := &API{store: store, hub: hub, tags: tags, presets: presets, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv}
if cfg.dockerLogs { if cfg.dockerLogs {
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink) dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
if err != nil { if err != nil {
@@ -160,17 +189,25 @@ func main() {
api.Routes(mux) api.Routes(mux)
mux.Handle("GET /", http.FileServer(http.FS(static))) mux.Handle("GET /", http.FileServer(http.FS(static)))
handler, err := newAuth(cfg.auth, mux) handler, err := newAuth(cfg.auth, readOnly(mux))
if err != nil { if err != nil {
log.Fatalf("auth: %v", err) log.Fatalf("auth: %v", err)
} }
if o, ok := handler.(*OIDC); ok { if o, ok := handler.(*OIDC); ok {
go o.checkProvider() go o.checkProvider()
} }
_, local := handler.(*Local)
_, oidc := handler.(*OIDC)
authOn := local || oidc
if !authOn {
log.Printf("warning: no authentication (AUTH_USER is empty): anyone who can reach %s can read the logs and change the settings", cfg.httpAddr)
}
handler = secure(handler, contentSecurityPolicy(static), authOn)
srv := &http.Server{ srv := &http.Server{
Addr: cfg.httpAddr, Addr: cfg.httpAddr,
Handler: handler, Handler: handler,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
// Requests inherit the global context so SSE streams end on shutdown. // Requests inherit the global context so SSE streams end on shutdown.
BaseContext: func(net.Listener) context.Context { return ctx }, BaseContext: func(net.Listener) context.Context { return ctx },
} }
+110
View File
@@ -0,0 +1,110 @@
package main
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"os"
)
// Built-in tag presets, used when no presets file is found (PRESETS_FILE,
// /data/presets.json by default). See docs/presets.md.
//
//go:embed presets.json
var builtinPresets []byte
// i18nText is a text per language ({"en": "...", "fr": "..."}) or one plain
// string for every language.
type i18nText map[string]string
func (t *i18nText) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
*t = i18nText{"en": s}
return nil
}
var m map[string]string
if err := json.Unmarshal(b, &m); err != nil {
return errors.New("expected a string or an object of strings per language")
}
*t = m
return nil
}
type PresetTag struct {
Label i18nText `json:"label,omitempty"`
Pattern string `json:"pattern"`
Color string `json:"color"`
Regex *bool `json:"regex,omitempty"` // regular expression unless false
WholeWord bool `json:"wholeWord,omitempty"`
CaseSensitive bool `json:"caseSensitive,omitempty"`
}
type Preset struct {
ID string `json:"id"`
Name i18nText `json:"name"`
Tags []PresetTag `json:"tags"`
}
type PresetGroup struct {
Group i18nText `json:"group"`
Presets []Preset `json:"presets"`
}
// parsePresets decodes and checks a presets file with the same rules as the
// tags, so that every preset can be added as is.
func parsePresets(b []byte) ([]PresetGroup, error) {
var groups []PresetGroup
if err := json.Unmarshal(b, &groups); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, g := range groups {
for _, p := range g.Presets {
if p.ID == "" || seen[p.ID] {
return nil, fmt.Errorf("preset %q: missing or duplicate id", p.ID)
}
seen[p.ID] = true
if len(p.Tags) == 0 {
return nil, fmt.Errorf("preset %q: no tags", p.ID)
}
for i, pt := range p.Tags {
t := Tag{Pattern: pt.Pattern, Color: pt.Color, Regex: pt.Regex == nil || *pt.Regex}
if err := t.validate(); err != nil {
return nil, fmt.Errorf("preset %q, tag %d: %w", p.ID, i+1, err)
}
}
}
}
return groups, nil
}
// presetsResponse is what GET /api/presets returns.
type presetsResponse struct {
Source string `json:"source"` // "file" or "builtin"
File string `json:"file"`
Error string `json:"error,omitempty"` // the file is invalid: built-in presets are used
Groups []PresetGroup `json:"groups"`
}
// loadPresets reads the presets file on every call, so that edits apply
// without a restart, and falls back to the built-in presets.
func loadPresets(path string) presetsResponse {
res := presetsResponse{Source: "builtin", File: path}
if path != "" {
b, err := os.ReadFile(path)
switch {
case err == nil:
if res.Groups, err = parsePresets(b); err == nil {
res.Source = "file"
return res
}
res.Error = err.Error()
case !errors.Is(err, os.ErrNotExist):
res.Error = err.Error()
}
}
res.Groups, _ = parsePresets(builtinPresets) // checked by the tests
return res
}
+244
View File
@@ -0,0 +1,244 @@
[
{
"group": "HTTP/HTTPS",
"presets": [
{
"id": "http_status",
"name": { "en": "HTTP status codes", "fr": "Codes HTTP" },
"tags": [
{
"label": "HTTP 2xx",
"color": "#86efac",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>2\\d\\d)\\b"
},
{
"label": "HTTP 3xx",
"color": "#93c5fd",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>3\\d\\d)\\b"
},
{
"label": "HTTP 4xx",
"color": "#fdba74",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>4\\d\\d)\\b"
},
{
"label": "HTTP 5xx",
"color": "#f87171",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>5\\d\\d)\\b"
}
]
},
{
"id": "http_methods",
"name": { "en": "HTTP methods", "fr": "Méthodes HTTP" },
"tags": [
{
"label": "GET/HEAD/OPTIONS",
"color": "#cbd5e1",
"caseSensitive": true,
"pattern": "\"(?<hl>GET|HEAD|OPTIONS)[ \"]"
},
{
"label": "POST/PUT/PATCH",
"color": "#c4b5fd",
"caseSensitive": true,
"pattern": "\"(?<hl>POST|PUT|PATCH)[ \"]"
},
{
"label": "DELETE",
"color": "#f9a8d4",
"caseSensitive": true,
"pattern": "\"(?<hl>DELETE)[ \"]"
}
]
},
{
"id": "http_probes",
"name": { "en": "Probes and attacks", "fr": "Sondes et attaques" },
"tags": [
{
"label": { "en": "probes / attacks", "fr": "sondes / attaques" },
"color": "#fda4af",
"pattern": "(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|<script|union(?:\\s|%20|\\+)+select)"
}
]
},
{
"id": "http_bots",
"name": { "en": "Bots and scripts", "fr": "Robots et scripts" },
"tags": [
{
"label": { "en": "bots / scripts", "fr": "robots / scripts" },
"color": "#fde68a",
"pattern": "\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b"
}
]
},
{
"id": "http_errors",
"name": { "en": "TLS/HTTPS and proxy errors", "fr": "Erreurs TLS/HTTPS et proxy" },
"tags": [
{
"label": { "en": "TLS errors", "fr": "erreurs TLS" },
"color": "#f0abfc",
"pattern": "(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)"
},
{
"label": { "en": "proxy errors", "fr": "erreurs proxy" },
"color": "#fdba74",
"pattern": "(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)"
}
]
}
]
},
{
"group": { "en": "System", "fr": "Système" },
"presets": [
{
"id": "sys_auth",
"name": { "en": "SSH and logins", "fr": "SSH et connexions" },
"tags": [
{
"label": { "en": "login failures", "fr": "échecs de connexion" },
"color": "#fca5a5",
"pattern": "(?:Failed (?:password|publickey|none)|Invalid user|authentication failures?|Connection closed by (?:invalid|authenticating) user|maximum authentication attempts exceeded|FAILED (?:LOGIN|SU)|incorrect password attempts?|NOT in sudoers)"
},
{
"label": { "en": "logins", "fr": "connexions" },
"color": "#86efac",
"pattern": "(?:Accepted (?:password|publickey|keyboard-interactive(?:/pam)?)|session opened for user|New session \\S+ of user)"
}
]
},
{
"id": "sys_sudo",
"name": { "en": "sudo commands", "fr": "Commandes sudo" },
"tags": [
{
"label": { "en": "sudo commands", "fr": "commandes sudo" },
"color": "#fde68a",
"caseSensitive": true,
"pattern": "\\bCOMMAND=\\S+"
}
]
},
{
"id": "sys_kernel",
"name": { "en": "Kernel: OOM, crashes, disks", "fr": "Noyau : OOM, plantages, disques" },
"tags": [
{
"label": { "en": "out of memory", "fr": "mémoire épuisée" },
"color": "#f87171",
"pattern": "(?:Out of memory|oom-kill(?:er)?|oom_reaper|Killed process \\d+|invoked oom-killer)"
},
{
"label": { "en": "kernel errors", "fr": "erreurs noyau" },
"color": "#fda4af",
"pattern": "(?:Kernel panic|\\bBUG: |\\bOops\\b|Call Trace|segfault at|general protection fault|I/O error|EXT4-fs error|Buffer I/O error|blocked for more than \\d+ seconds|Hardware Error|soft lockup|hard LOCKUP)"
}
]
},
{
"id": "sys_systemd",
"name": { "en": "systemd services", "fr": "Services systemd" },
"tags": [
{
"label": { "en": "failed services", "fr": "services en échec" },
"color": "#fca5a5",
"pattern": "(?:Failed to start|failed with result|Main process exited, code=(?:exited|killed|dumped)|entered failed state|Start request repeated too quickly|Dependency failed)"
},
{
"label": { "en": "service start/stop", "fr": "démarrage/arrêt de service" },
"color": "#bbf7d0",
"caseSensitive": true,
"pattern": "\\b(?:Started|Starting|Stopped|Stopping|Reloaded|Reloading|Reached target)\\b"
}
]
},
{
"id": "sys_firewall",
"name": { "en": "Firewall and fail2ban", "fr": "Pare-feu et fail2ban" },
"tags": [
{
"label": { "en": "firewall", "fr": "pare-feu" },
"color": "#fdba74",
"caseSensitive": true,
"pattern": "(?:\\[UFW (?:BLOCK|ALLOW|AUDIT|LIMIT BLOCK)\\]|\\b(?:DROP|REJECT)\\b|\\b(?:Ban|Unban|Found) \\d{1,3}(?:\\.\\d{1,3}){3}\\b)"
}
]
}
]
},
{
"group": "Applications",
"presets": [
{
"id": "app_docker",
"name": { "en": "Docker and containers", "fr": "Docker et conteneurs" },
"tags": [
{
"label": { "en": "container problems", "fr": "problèmes de conteneur" },
"color": "#fcd34d",
"pattern": "(?:\\bOOMKilled\\b|exited with code [1-9]\\d*|exit code: [1-9]\\d*|health_status: unhealthy|\\bunhealthy\\b|Back-off restarting|CrashLoopBackOff|container (?:die|kill|oom)\\b|restarting \\(\\d+\\))"
}
]
},
{
"id": "app_db",
"name": { "en": "Databases", "fr": "Bases de données" },
"tags": [
{
"label": { "en": "database errors", "fr": "erreurs base de données" },
"color": "#c4b5fd",
"pattern": "(?:\\bdeadlock(?: detected| found)?\\b|duplicate key|too many (?:connections|clients)|lock wait timeout|slow query|could not connect to server|server has gone away|out of shared memory|terminating connection|Access denied for user|password authentication failed)"
}
]
}
]
},
{
"group": { "en": "General", "fr": "Général" },
"presets": [
{
"id": "gen_levels",
"name": { "en": "Log levels", "fr": "Niveaux de log" },
"tags": [
{
"label": { "en": "fatal / critical", "fr": "fatal / critique" },
"color": "#ef4444",
"pattern": "\\b(?:fatal|crit(?:ical)?|panic|emerg(?:ency)?)\\b"
},
{
"label": "info / notice",
"color": "#bfdbfe",
"pattern": "\\b(?:info|notice)\\b"
},
{
"label": "debug / trace",
"color": "#e5e7eb",
"pattern": "\\b(?:debug|trace)\\b"
},
{
"label": "ok",
"color": "#86efac",
"regex": false,
"wholeWord": true,
"pattern": "ok"
}
]
},
{
"id": "gen_ip",
"name": { "en": "IPv4 addresses", "fr": "Adresses IPv4" },
"tags": [
{
"label": { "en": "IPv4 addresses", "fr": "adresses IPv4" },
"color": "#a5f3fc",
"pattern": "\\b(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b"
}
]
}
]
}
]
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestBuiltinPresets(t *testing.T) {
groups, err := parsePresets(builtinPresets)
if err != nil {
t.Fatal(err)
}
if len(groups) == 0 {
t.Fatal("no built-in presets")
}
}
func TestLoadPresetsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "presets.json")
if res := loadPresets(path); res.Source != "builtin" || res.Error != "" {
t.Fatalf("missing file: got %q, error %q", res.Source, res.Error)
}
os.WriteFile(path, []byte(`[{"group":"Mine","presets":[{"id":"a","name":{"en":"A","fr":"A fr"},
"tags":[{"label":"x","pattern":"foo|bar","color":"#112233"},{"pattern":"a.b","color":"#445566","regex":false}]}]}]`), 0o644)
res := loadPresets(path)
if res.Source != "file" || res.Error != "" || res.Groups[0].Presets[0].Name["fr"] != "A fr" || res.Groups[0].Group["en"] != "Mine" {
t.Fatalf("valid file: %+v", res)
}
for _, bad := range []string{
`not json`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"(","color":"#112233"}]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"red"}]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"#112233"}]},{"id":"a","name":"B","tags":[{"pattern":"y","color":"#112233"}]}]}]`,
} {
os.WriteFile(path, []byte(bad), 0o644)
if res := loadPresets(path); res.Source != "builtin" || res.Error == "" || len(res.Groups) == 0 {
t.Errorf("%s: got %q, error %q", bad, res.Source, res.Error)
}
}
}
+89 -14
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"container/list"
"context" "context"
"net" "net"
"strings" "strings"
@@ -15,17 +16,26 @@ type ReverseDNS struct {
posTTL time.Duration // cache duration of a found name posTTL time.Duration // cache duration of a found name
negTTL time.Duration // cache duration of "no name" negTTL time.Duration // cache duration of "no name"
lookups chan struct{} // limits the lookups running at once
waiters chan struct{} // limits the messages waiting for a lookup (Resolve)
mu sync.Mutex mu sync.Mutex
cache map[string]*rdnsEntry cache map[string]*list.Element // ip -> element of lru
lru *list.List // *rdnsEntry, most recently used first
} }
type rdnsEntry struct { type rdnsEntry struct {
ip string
name string name string
expires time.Time expires time.Time
done chan struct{} // closed once the lookup has finished done chan struct{} // closed once the lookup has finished
} }
const rdnsMaxEntries = 10000 const (
rdnsMaxEntries = 10000
rdnsMaxLookups = 64
rdnsMaxWaiters = 1024
)
// NewReverseDNS uses the system resolver, or `server` ("ip" or "ip:port") when set. // NewReverseDNS uses the system resolver, or `server` ("ip" or "ip:port") when set.
func NewReverseDNS(enabled bool, server string) *ReverseDNS { func NewReverseDNS(enabled bool, server string) *ReverseDNS {
@@ -47,7 +57,10 @@ func NewReverseDNS(enabled bool, server string) *ReverseDNS {
r: r, r: r,
posTTL: time.Hour, posTTL: time.Hour,
negTTL: 10 * time.Minute, negTTL: 10 * time.Minute,
cache: make(map[string]*rdnsEntry), lookups: make(chan struct{}, rdnsMaxLookups),
waiters: make(chan struct{}, rdnsMaxWaiters),
cache: make(map[string]*list.Element),
lru: list.New(),
} }
} }
@@ -60,6 +73,38 @@ func isClosed(ch chan struct{}) bool {
} }
} }
// entry returns the cache entry of ip, starting its lookup when it is missing
// or expired, or nil when too many lookups are already running (a flood of
// unknown addresses). The least recently used entry makes room for a new one.
func (d *ReverseDNS) entry(ip string) *rdnsEntry {
d.mu.Lock()
defer d.mu.Unlock()
if el := d.cache[ip]; el != nil {
e := el.Value.(*rdnsEntry)
if !isClosed(e.done) || time.Now().Before(e.expires) {
d.lru.MoveToFront(el)
return e
}
}
select {
case d.lookups <- struct{}{}:
default:
return nil
}
if el := d.cache[ip]; el != nil {
d.lru.Remove(el)
}
for d.lru.Len() >= rdnsMaxEntries {
old := d.lru.Back()
d.lru.Remove(old)
delete(d.cache, old.Value.(*rdnsEntry).ip)
}
e := &rdnsEntry{ip: ip, done: make(chan struct{})}
d.cache[ip] = d.lru.PushFront(e)
go d.resolve(ip, e)
return e
}
// Lookup returns the name of ip, or "" when ip is not an IP address, has no // Lookup returns the name of ip, or "" when ip is not an IP address, has no
// PTR record, or is not resolved within `wait`. A lookup that takes longer // PTR record, or is not resolved within `wait`. A lookup that takes longer
// keeps running in the background and fills the cache for later calls. // keeps running in the background and fills the cache for later calls.
@@ -67,18 +112,10 @@ func (d *ReverseDNS) Lookup(ip string, wait time.Duration) string {
if !d.enabled || net.ParseIP(ip) == nil { if !d.enabled || net.ParseIP(ip) == nil {
return "" return ""
} }
d.mu.Lock() e := d.entry(ip)
e := d.cache[ip] if e == nil {
if e == nil || (isClosed(e.done) && time.Now().After(e.expires)) { return ""
if len(d.cache) >= rdnsMaxEntries {
d.cache = make(map[string]*rdnsEntry)
} }
e = &rdnsEntry{done: make(chan struct{})}
d.cache[ip] = e
go d.resolve(ip, e)
}
d.mu.Unlock()
if !isClosed(e.done) { if !isClosed(e.done) {
timer := time.NewTimer(wait) timer := time.NewTimer(wait)
defer timer.Stop() defer timer.Stop()
@@ -91,6 +128,43 @@ func (d *ReverseDNS) Lookup(ip string, wait time.Duration) string {
return e.name return e.name
} }
// Resolve is Lookup without blocking the caller: fn gets the name (or "") at
// once when it is known, otherwise from a goroutine after at most `wait`. The
// syslog listeners use it so that a slow DNS server never delays the reading
// of the next messages.
func (d *ReverseDNS) Resolve(ip string, wait time.Duration, fn func(name string)) {
if !d.enabled || net.ParseIP(ip) == nil {
fn("")
return
}
e := d.entry(ip)
switch {
case e == nil:
fn("")
return
case isClosed(e.done):
fn(e.name)
return
}
select {
case d.waiters <- struct{}{}:
default:
fn("") // too many messages waiting already
return
}
go func() {
defer func() { <-d.waiters }()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-e.done:
fn(e.name)
case <-timer.C:
fn("")
}
}()
}
// LookupMany resolves several addresses in parallel; unresolved ones are absent. // LookupMany resolves several addresses in parallel; unresolved ones are absent.
func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]string { func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]string {
out := make(map[string]string) out := make(map[string]string)
@@ -112,6 +186,7 @@ func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]str
} }
func (d *ReverseDNS) resolve(ip string, e *rdnsEntry) { func (d *ReverseDNS) resolve(ip string, e *rdnsEntry) {
defer func() { <-d.lookups }()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
ttl := d.negTTL ttl := d.negTTL
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"net"
"testing"
"time"
)
// slowDNS never answers before the context ends.
func slowDNS() *ReverseDNS {
d := NewReverseDNS(true, "")
d.r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
<-ctx.Done()
return nil, errors.New("timeout")
}}
return d
}
func TestResolveDoesNotBlock(t *testing.T) {
d := slowDNS()
got := make(chan string, 1)
start := time.Now()
d.Resolve("192.0.2.1", 50*time.Millisecond, func(name string) { got <- name })
if time.Since(start) > 20*time.Millisecond {
t.Fatal("Resolve waited for the DNS server")
}
if name := <-got; name != "" {
t.Errorf("name %q, want none", name)
}
// Not an IP address: the callback runs at once.
called := false
d.Resolve("router", time.Second, func(name string) { called = name == "" })
if !called {
t.Error("callback not called synchronously for a host name")
}
}
func TestReverseDNSLimits(t *testing.T) {
d := slowDNS()
// Lookups beyond the limit are not started (and not cached).
for i := 0; i < rdnsMaxLookups+10; i++ {
d.Lookup(net.IPv4(10, 0, byte(i>>8), byte(i)).String(), 0)
}
if n := d.lru.Len(); n != rdnsMaxLookups {
t.Errorf("%d entries, want %d", n, rdnsMaxLookups)
}
}
func TestReverseDNSEvictsLeastRecentlyUsed(t *testing.T) {
d := slowDNS()
done := make(chan struct{})
close(done)
add := func(ip string) {
e := &rdnsEntry{ip: ip, name: ip + ".lan", expires: time.Now().Add(time.Hour), done: done}
d.cache[ip] = d.lru.PushFront(e)
}
for i := 0; i < rdnsMaxEntries; i++ {
add(net.IPv4(10, 1, byte(i>>8), byte(i)).String())
}
first := net.IPv4(10, 1, 0, 0).String()
if name := d.Lookup(first, 0); name != first+".lan" { // now the most recent
t.Fatalf("cached name %q", name)
}
d.entry("192.0.2.9")
if d.lru.Len() != rdnsMaxEntries {
t.Errorf("%d entries, want %d", d.lru.Len(), rdnsMaxEntries)
}
if d.cache[first] == nil {
t.Error("recently used entry evicted")
}
if d.cache[net.IPv4(10, 1, 0, 1).String()] != nil {
t.Error("least recently used entry kept")
}
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Spool keeps on disk the batches VictoriaLogs could not take, so that they
// are sent later instead of being lost. Each batch is one NDJSON file named
// <unix nanoseconds>-<lines>.ndjson; files are sent back oldest first.
type Spool struct {
dir string
max int64 // maximum total size in bytes
mu sync.Mutex
size int64 // bytes on disk
lines int64 // messages on disk
seq int64
}
// errSpoolFull is returned when a batch does not fit within SPOOL_MAX_MB.
var errSpoolFull = fmt.Errorf("disk buffer full")
// OpenSpool creates the directory if needed and counts the batches already
// there (left by a previous run).
func OpenSpool(dir string, max int64) (*Spool, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
s := &Spool{dir: dir, max: max}
files, err := s.files()
if err != nil {
return nil, err
}
for _, f := range files {
s.size += f.size
s.lines += f.lines
}
return s, nil
}
type spoolFile struct {
path string
size int64
lines int64
}
// files lists the batches on disk, oldest first. Unfinished writes (.tmp)
// are removed.
func (s *Spool) files() ([]spoolFile, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
var out []spoolFile
for _, e := range entries {
name := e.Name()
if strings.HasSuffix(name, ".tmp") {
_ = os.Remove(filepath.Join(s.dir, name))
continue
}
base, ok := strings.CutSuffix(name, ".ndjson")
if !ok {
continue
}
_, n, _ := strings.Cut(base, "-")
lines, _ := strconv.ParseInt(n, 10, 64)
info, err := e.Info()
if err != nil {
continue
}
out = append(out, spoolFile{path: filepath.Join(s.dir, name), size: info.Size(), lines: lines})
}
// The names start with a fixed-width timestamp: string order is time order.
sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
return out, nil
}
// Write saves one batch of `lines` messages.
func (s *Spool) Write(body []byte, lines int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.size+int64(len(body)) > s.max {
return errSpoolFull
}
s.seq++
name := fmt.Sprintf("%020d%04d-%d.ndjson", time.Now().UnixNano(), s.seq%10000, lines)
path := filepath.Join(s.dir, name)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, body, 0o644); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
s.size += int64(len(body))
s.lines += int64(lines)
return nil
}
// Oldest returns the oldest batch, or ok=false when the spool is empty.
func (s *Spool) Oldest() (f spoolFile, body []byte, ok bool, err error) {
files, err := s.files()
if err != nil || len(files) == 0 {
return f, nil, false, err
}
f = files[0]
body, err = os.ReadFile(f.path)
return f, body, err == nil, err
}
// Remove deletes a batch once VictoriaLogs has taken it.
func (s *Spool) Remove(f spoolFile) {
if err := os.Remove(f.path); err != nil && !os.IsNotExist(err) {
return
}
s.mu.Lock()
s.size -= f.size
s.lines -= f.lines
s.mu.Unlock()
}
// Pending returns the number of messages and bytes waiting on disk.
func (s *Spool) Pending() (lines, size int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.lines, s.size
}
+179 -37
View File
@@ -17,13 +17,18 @@ import (
) )
// Store sends messages to VictoriaLogs in batches and queries it with LogsQL. // Store sends messages to VictoriaLogs in batches and queries it with LogsQL.
// Batches VictoriaLogs cannot take go to the disk spool (when enabled) and
// are sent again, oldest first, once it answers.
type Store struct { type Store struct {
base string base string
client *http.Client client *http.Client
streamClient *http.Client streamClient *http.Client
in chan *Entry in chan *Entry
quit chan struct{} // closed on shutdown: unblocks waiting producers
batchSize int batchSize int
flushEvery time.Duration flushEvery time.Duration
spool *Spool // nil: no disk buffer
spooled chan struct{} // wakes the replay loop up after a write to the spool
received atomic.Int64 received atomic.Int64
ingested atomic.Int64 ingested atomic.Int64
@@ -31,7 +36,7 @@ type Store struct {
lastErr atomic.Value // string lastErr atomic.Value // string
} }
func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration) *Store { func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration, spool *Spool) *Store {
s := &Store{ s := &Store{
base: strings.TrimRight(base, "/"), base: strings.TrimRight(base, "/"),
client: &http.Client{Timeout: 60 * time.Second}, client: &http.Client{Timeout: 60 * time.Second},
@@ -39,21 +44,34 @@ func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration) *
// request context still cancels it when the browser goes away. // request context still cancels it when the browser goes away.
streamClient: &http.Client{}, streamClient: &http.Client{},
in: make(chan *Entry, queueSize), in: make(chan *Entry, queueSize),
quit: make(chan struct{}),
batchSize: batchSize, batchSize: batchSize,
flushEvery: flushEvery, flushEvery: flushEvery,
spool: spool,
spooled: make(chan struct{}, 1),
} }
s.lastErr.Store("") s.lastErr.Store("")
return s return s
} }
// Enqueue never blocks: when the queue is full, the message is counted as dropped. // Enqueue adds a message to the queue. When the queue is full, a message
// whose producer can wait (Entry.Wait: Docker, host logs) blocks until there
// is room; any other one (syslog) is counted as dropped.
func (s *Store) Enqueue(e *Entry) { func (s *Store) Enqueue(e *Entry) {
s.received.Add(1) s.received.Add(1)
select { select {
case s.in <- e: case s.in <- e:
return
default: default:
s.dropped.Add(1)
} }
if e.Wait {
select {
case s.in <- e:
return
case <-s.quit:
}
}
s.dropped.Add(1)
} }
// Run drains the queue into VictoriaLogs until the context is cancelled, // Run drains the queue into VictoriaLogs until the context is cancelled,
@@ -62,41 +80,41 @@ func (s *Store) Run(ctx context.Context) {
ticker := time.NewTicker(s.flushEvery) ticker := time.NewTicker(s.flushEvery)
defer ticker.Stop() defer ticker.Stop()
batch := make([]*Entry, 0, s.batchSize) batch := make([]*Entry, 0, s.batchSize)
if s.spool != nil {
go s.replay(ctx)
}
flush := func() { flush := func(ctx context.Context) {
if len(batch) == 0 { if len(batch) > 0 {
return s.store(ctx, batch)
} clear(batch)
if err := s.insert(batch); err != nil {
s.dropped.Add(int64(len(batch)))
s.lastErr.Store(err.Error())
log.Printf("victorialogs: %d messages dropped: %v", len(batch), err)
} else {
s.ingested.Add(int64(len(batch)))
s.lastErr.Store("")
}
batch = batch[:0] batch = batch[:0]
} }
}
for { for {
select { select {
case e := <-s.in: case e := <-s.in:
batch = append(batch, e) batch = append(batch, e)
if len(batch) >= s.batchSize { if len(batch) >= s.batchSize {
flush() flush(ctx)
} }
case <-ticker.C: case <-ticker.C:
flush() flush(ctx)
case <-ctx.Done(): case <-ctx.Done():
close(s.quit)
// The last batches get one short attempt, then go to the spool.
end, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for { for {
select { select {
case e := <-s.in: case e := <-s.in:
batch = append(batch, e) batch = append(batch, e)
if len(batch) >= s.batchSize { if len(batch) >= s.batchSize {
flush() flush(end)
} }
default: default:
flush() flush(end)
return return
} }
} }
@@ -104,38 +122,158 @@ func (s *Store) Run(ctx context.Context) {
} }
} }
func (s *Store) insert(batch []*Entry) error { // store sends one batch to VictoriaLogs, or to the spool when VictoriaLogs
// fails or older batches are still waiting there (to keep their order).
// Entry.Done is called once the batch is stored or spooled.
func (s *Store) store(ctx context.Context, batch []*Entry) {
body, err := encodeBatch(batch)
if err == nil {
err = s.save(ctx, body, len(batch))
}
if err != nil {
s.dropped.Add(int64(len(batch)))
s.lastErr.Store(err.Error())
log.Printf("victorialogs: %d messages dropped: %v", len(batch), err)
return
}
for _, e := range batch {
if e.Done != nil {
e.Done()
}
}
}
func (s *Store) save(ctx context.Context, body []byte, lines int) error {
if s.spool == nil {
// No disk buffer: retry a few times, then give up.
var err error
for attempt := 0; attempt < 5; attempt++ {
if attempt > 0 && !sleepCtx(ctx, time.Duration(1<<attempt)*500*time.Millisecond) { // 1s, 2s, 4s, 8s
break
}
if err = s.post(ctx, body); err == nil {
s.ingested.Add(int64(lines))
s.lastErr.Store("")
return nil
}
}
return err
}
var postErr error
if waiting, _ := s.spool.Pending(); waiting == 0 {
if postErr = s.post(ctx, body); postErr == nil {
s.ingested.Add(int64(lines))
s.lastErr.Store("")
return nil
}
s.lastErr.Store(postErr.Error())
}
err := s.spool.Write(body, lines)
if err == nil {
select {
case s.spooled <- struct{}{}:
default:
}
return nil
}
if postErr != nil {
return fmt.Errorf("%v; %v", postErr, err)
}
// Spool full while older batches wait: one direct attempt before dropping.
if postErr = s.post(ctx, body); postErr == nil {
s.ingested.Add(int64(lines))
return nil
}
return fmt.Errorf("%v; %v", err, postErr)
}
// replay sends the spooled batches back to VictoriaLogs, oldest first, with
// an increasing pause (up to 30 s) while it keeps failing.
func (s *Store) replay(ctx context.Context) {
if n, size := s.spool.Pending(); n > 0 {
log.Printf("spool: %d messages (%d bytes) waiting from a previous run", n, size)
}
backoff := time.Second
for {
f, body, ok, err := s.spool.Oldest()
if err != nil {
log.Printf("spool: %v", err)
}
if !ok {
select {
case <-ctx.Done():
return
case <-s.spooled:
case <-time.After(time.Minute):
}
continue
}
err = s.post(ctx, body)
switch {
case err == nil:
s.spool.Remove(f)
s.ingested.Add(f.lines)
s.lastErr.Store("")
backoff = time.Second
continue
case isRejected(err):
// VictoriaLogs refuses the data itself: sending it again would not help.
s.spool.Remove(f)
s.dropped.Add(f.lines)
log.Printf("spool: %d messages refused by victorialogs: %v", f.lines, err)
continue
}
s.lastErr.Store(err.Error())
if !sleepCtx(ctx, backoff) {
return
}
backoff = min(2*backoff, 30*time.Second)
}
}
func encodeBatch(batch []*Entry) ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer
enc := json.NewEncoder(&buf) enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) enc.SetEscapeHTML(false)
for _, e := range batch { for _, e := range batch {
if err := enc.Encode(e.Record()); err != nil { if err := enc.Encode(e.Record()); err != nil {
return err return nil, err
} }
} }
u := s.base + "/insert/jsonline?_stream_fields=host,app&_msg_field=_msg&_time_field=_time" return buf.Bytes(), nil
var err error
for attempt := 0; attempt < 5; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(1<<attempt) * 500 * time.Millisecond) // 1s, 2s, 4s, 8s
}
if err = s.post(u, buf.Bytes()); err == nil {
return nil
}
}
return err
} }
func (s *Store) post(u string, body []byte) error { // insertError is an error status of VictoriaLogs to an insert.
resp, err := s.client.Post(u, "application/stream+json", bytes.NewReader(body)) type insertError struct {
status int
msg string
}
func (e *insertError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.status, e.msg) }
// isRejected tells whether VictoriaLogs refused the data itself (4xx other
// than 429), as opposed to being unreachable or overloaded.
func isRejected(err error) bool {
var ie *insertError
return errors.As(err, &ie) && ie.status >= 400 && ie.status < 500 && ie.status != http.StatusTooManyRequests
}
func (s *Store) post(ctx context.Context, body []byte) error {
u := s.base + "/insert/jsonline?_stream_fields=host,app&_msg_field=_msg&_time_field=_time"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/stream+json")
resp, err := s.client.Do(req)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode/100 != 2 { if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) return &insertError{status: resp.StatusCode, msg: strings.TrimSpace(string(msg))}
} }
_, _ = io.Copy(io.Discard, resp.Body) _, _ = io.Copy(io.Discard, resp.Body)
return nil return nil
@@ -265,11 +403,15 @@ func queryStatus(err error) int {
} }
func (s *Store) Stats() map[string]any { func (s *Store) Stats() map[string]any {
return map[string]any{ st := map[string]any{
"received": s.received.Load(), "received": s.received.Load(),
"ingested": s.ingested.Load(), "ingested": s.ingested.Load(),
"dropped": s.dropped.Load(), "dropped": s.dropped.Load(),
"queue": len(s.in), "queue": len(s.in),
"lastError": s.lastErr.Load(), "lastError": s.lastErr.Load(),
} }
if s.spool != nil {
st["spooled"], st["spoolBytes"] = s.spool.Pending()
}
return st
} }
+216
View File
@@ -0,0 +1,216 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// insertVL plays VictoriaLogs' insert endpoint: it answers `status` (0 = the
// connection fails) and keeps the lines it accepted.
type insertVL struct {
mu sync.Mutex
status int
lines []string
}
func (v *insertVL) set(status int) {
v.mu.Lock()
v.status = status
v.mu.Unlock()
}
func (v *insertVL) got() []string {
v.mu.Lock()
defer v.mu.Unlock()
return append([]string(nil), v.lines...)
}
func (v *insertVL) RoundTrip(r *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(r.Body)
v.mu.Lock()
defer v.mu.Unlock()
if v.status == 0 {
return nil, errors.New("connection refused")
}
if v.status == http.StatusOK {
for _, l := range strings.Split(strings.TrimSpace(string(body)), "\n") {
v.lines = append(v.lines, l)
}
}
return &http.Response{StatusCode: v.status, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil
}
func testEntry(msg string, done *atomic.Int64) *Entry {
e := &Entry{Received: time.Now(), Time: time.Now(), Host: "h", App: "a", Message: msg}
if done != nil {
e.Done = func() { done.Add(1) }
}
return e
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
if cond() {
return
}
}
t.Fatalf("timed out waiting for %s", what)
}
func TestSpoolFiles(t *testing.T) {
dir := t.TempDir()
sp, err := OpenSpool(dir, 100)
if err != nil {
t.Fatal(err)
}
if err := sp.Write([]byte("a\nb\n"), 2); err != nil {
t.Fatal(err)
}
if err := sp.Write([]byte("c\n"), 1); err != nil {
t.Fatal(err)
}
if err := sp.Write(bytes.Repeat([]byte("x"), 100), 1); !errors.Is(err, errSpoolFull) {
t.Fatalf("write over the limit: %v, want errSpoolFull", err)
}
_ = os.WriteFile(filepath.Join(dir, "broken.ndjson.tmp"), []byte("z"), 0o644)
// A new run finds the batches left on disk and drops unfinished writes.
sp, err = OpenSpool(dir, 100)
if err != nil {
t.Fatal(err)
}
if n, size := sp.Pending(); n != 3 || size != 6 {
t.Fatalf("pending %d lines %d bytes, want 3 and 6", n, size)
}
f, body, ok, err := sp.Oldest()
if !ok || err != nil || string(body) != "a\nb\n" || f.lines != 2 {
t.Fatalf("oldest: %q %+v %v %v", body, f, ok, err)
}
sp.Remove(f)
if _, body, _, _ := sp.Oldest(); string(body) != "c\n" {
t.Fatalf("next oldest %q", body)
}
if _, err := os.Stat(filepath.Join(dir, "broken.ndjson.tmp")); !os.IsNotExist(err) {
t.Error("unfinished write left in the spool")
}
}
func TestStoreSpoolsWhileVictoriaLogsIsDown(t *testing.T) {
sp, err := OpenSpool(t.TempDir(), 1<<20)
if err != nil {
t.Fatal(err)
}
vl := &insertVL{status: 0}
s := NewStore("http://vl", 2, 100, 20*time.Millisecond, sp)
s.client.Transport = vl
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.Run(ctx)
var done atomic.Int64
for _, m := range []string{"one", "two", "three"} {
s.Enqueue(testEntry(m, &done))
}
// VictoriaLogs is down: the messages are kept on disk, and acknowledged.
waitFor(t, "3 spooled messages", func() bool { n, _ := sp.Pending(); return n == 3 })
if done.Load() != 3 || s.dropped.Load() != 0 {
t.Fatalf("done %d dropped %d, want 3 and 0", done.Load(), s.dropped.Load())
}
// While batches wait on disk, new ones queue behind them.
vl.set(http.StatusServiceUnavailable)
s.Enqueue(testEntry("four", &done))
waitFor(t, "4 spooled messages", func() bool { n, _ := sp.Pending(); return n == 4 })
vl.set(http.StatusOK)
waitFor(t, "the spool to drain", func() bool { n, _ := sp.Pending(); return n == 0 })
got := strings.Join(vl.got(), "\n")
for i, m := range []string{"one", "two", "three", "four"} {
if !strings.Contains(got, `"_msg":"`+m+`"`) {
t.Errorf("message %d %q not sent: %s", i, m, got)
}
}
if strings.Index(got, `"one"`) > strings.Index(got, `"four"`) {
t.Error("spooled batches sent out of order")
}
if s.ingested.Load() != 4 || s.lastErr.Load() != "" {
t.Errorf("ingested %d lastErr %q", s.ingested.Load(), s.lastErr.Load())
}
// Once the spool is empty, batches go straight to VictoriaLogs again.
s.Enqueue(testEntry("five", &done))
waitFor(t, "a direct insert", func() bool { return s.ingested.Load() == 5 })
}
func TestStoreDropsRefusedSpooledBatch(t *testing.T) {
sp, _ := OpenSpool(t.TempDir(), 1<<20)
_ = sp.Write([]byte("{bad json\n"), 1)
vl := &insertVL{status: http.StatusBadRequest}
s := NewStore("http://vl", 10, 10, time.Second, sp)
s.client.Transport = vl
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.Run(ctx)
waitFor(t, "the refused batch to be dropped", func() bool { return s.dropped.Load() == 1 })
if n, _ := sp.Pending(); n != 0 {
t.Errorf("%d messages left in the spool", n)
}
}
func TestStoreWithoutSpoolDrops(t *testing.T) {
vl := &insertVL{status: 0}
s := NewStore("http://vl", 10, 10, time.Hour, nil)
s.client.Transport = vl
ctx, cancel := context.WithCancel(context.Background())
cancel() // shutdown: one attempt, no retry pauses
var done atomic.Int64
s.store(ctx, []*Entry{testEntry("x", &done)})
if done.Load() != 0 || s.dropped.Load() != 1 {
t.Errorf("done %d dropped %d, want 0 and 1", done.Load(), s.dropped.Load())
}
}
func TestEnqueueWait(t *testing.T) {
s := NewStore("http://vl", 10, 1, time.Hour, nil)
s.Enqueue(testEntry("fills the queue", nil))
s.Enqueue(testEntry("syslog: dropped", nil))
if s.dropped.Load() != 1 {
t.Fatalf("dropped %d, want 1", s.dropped.Load())
}
// A producer that can wait blocks until there is room…
queued := make(chan struct{})
go func() {
e := testEntry("docker: waits", nil)
e.Wait = true
s.Enqueue(e)
close(queued)
}()
select {
case <-queued:
t.Fatal("did not wait for room in the queue")
case <-time.After(50 * time.Millisecond):
}
<-s.in
<-queued
if s.dropped.Load() != 1 {
t.Errorf("dropped %d, want 1", s.dropped.Load())
}
// …or until shutdown.
close(s.quit)
e := testEntry("docker: shutdown", nil)
e.Wait = true
s.Enqueue(e)
if s.dropped.Load() != 2 {
t.Errorf("dropped %d after shutdown, want 2", s.dropped.Load())
}
}
+38 -2
View File
@@ -38,6 +38,12 @@ type Entry struct {
SourceType string // "syslog" or "docker" SourceType string // "syslog" or "docker"
Extra map[string]string // additional fields (docker: container, image, …) Extra map[string]string // additional fields (docker: container, image, …)
// Not stored. Wait: the producer can be slowed down when the queue is full
// (Docker, host logs) instead of losing the message. Done: called once the
// message is stored in VictoriaLogs or in the disk spool.
Wait bool
Done func()
} }
// Record returns the entry in the shape stored in VictoriaLogs and returned by the API. // Record returns the entry in the shape stored in VictoriaLogs and returned by the API.
@@ -241,7 +247,15 @@ func serveUDP(ctx context.Context, pc net.PacketConn, sink func(*Entry)) {
} }
} }
// TCP limits: connections open at once, and how long a connection may stay
// silent before it is closed (senders reconnect on their own).
var (
tcpMaxConns = 512
tcpIdle = 30 * time.Minute
)
func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) { func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
slots := make(chan struct{}, tcpMaxConns)
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
if err != nil { if err != nil {
@@ -252,8 +266,30 @@ func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
continue continue
} }
go handleTCP(ctx, conn, sink) select {
case slots <- struct{}{}:
default:
log.Printf("syslog tcp: %d connections already open, refusing %s", tcpMaxConns, conn.RemoteAddr())
conn.Close()
continue
} }
go func() {
defer func() { <-slots }()
handleTCP(ctx, conn, sink)
}()
}
}
// idleConn pushes the read deadline back before each read, so only a
// connection that stays silent for tcpIdle is closed.
type idleConn struct {
net.Conn
idle time.Duration
}
func (c idleConn) Read(p []byte) (int, error) {
_ = c.Conn.SetReadDeadline(time.Now().Add(c.idle))
return c.Conn.Read(p)
} }
const maxFrame = 1 << 20 const maxFrame = 1 << 20
@@ -266,7 +302,7 @@ func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) {
defer stop() defer stop()
src := hostOf(conn.RemoteAddr()) src := hostOf(conn.RemoteAddr())
r := bufio.NewReaderSize(conn, 64*1024) r := bufio.NewReaderSize(idleConn{conn, tcpIdle}, 64*1024)
for { for {
c, err := r.ReadByte() c, err := r.ReadByte()
if err != nil { if err != nil {
+44 -4
View File
@@ -16,6 +16,7 @@ import (
// Tag highlights a keyword in displayed messages. // Tag highlights a keyword in displayed messages.
type Tag struct { type Tag struct {
ID string `json:"id"` ID string `json:"id"`
Code string `json:"code"` // two digits, shown on matching log lines
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
Label string `json:"label,omitempty"` // shown instead of the pattern (presets) Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
Color string `json:"color"` Color string `json:"color"`
@@ -27,12 +28,46 @@ type Tag struct {
func defaultTags() []Tag { func defaultTags() []Tag {
return []Tag{ return []Tag{
{ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true}, {ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
{ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true}, {ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
{ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
} }
} }
var codeRe = regexp.MustCompile(`^[0-9]{2}$`)
// freeCode returns the lowest code from 01 to 99 not used by tags, or "" when
// all are taken. A code stays with its tag until the tag is deleted.
func freeCode(tags []Tag) string {
used := map[string]bool{}
for _, t := range tags {
used[t.Code] = true
}
for n := 1; n <= 99; n++ {
if c := fmt.Sprintf("%02d", n); !used[c] {
return c
}
}
return ""
}
// assignCodes gives a code to the tags that have none (files written before
// codes existed) or share one with an earlier tag.
func assignCodes(tags []Tag) bool {
changed := false
seen := map[string]bool{}
for i := range tags {
if codeRe.MatchString(tags[i].Code) && !seen[tags[i].Code] {
seen[tags[i].Code] = true
continue
}
tags[i].Code = ""
tags[i].Code = freeCode(tags)
seen[tags[i].Code] = true
changed = true
}
return changed
}
// Colors of the default tags in earlier versions: still unchanged, they are // Colors of the default tags in earlier versions: still unchanged, they are
// switched to the new pastel defaults when the file is loaded. // switched to the new pastel defaults when the file is loaded.
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"} var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
@@ -68,6 +103,7 @@ func (e *codedError) Error() string {
var ( var (
errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"} errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"}
errTooManyTags = &codedError{code: "too_many_tags", msg: "too many tags (99 at most)"}
colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
) )
@@ -108,7 +144,7 @@ func LoadTagStore(path string) (*TagStore, error) {
if err := json.Unmarshal(b, &s.tags); err != nil { 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)
} }
if migrateDefaultColors(s.tags) { if c1, c2 := migrateDefaultColors(s.tags), assignCodes(s.tags); c1 || c2 {
if err := s.save(); err != nil { if err := s.save(); err != nil {
return nil, err return nil, err
} }
@@ -145,6 +181,9 @@ func (s *TagStore) Create(t Tag) (Tag, error) {
t.ID = newID() t.ID = newID()
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if t.Code = freeCode(s.tags); t.Code == "" {
return t, errTooManyTags
}
s.tags = append(s.tags, t) s.tags = append(s.tags, t)
return t, s.save() return t, s.save()
} }
@@ -158,6 +197,7 @@ func (s *TagStore) Update(id string, t Tag) (Tag, error) {
defer s.mu.Unlock() defer s.mu.Unlock()
for i := range s.tags { for i := range s.tags {
if s.tags[i].ID == id { if s.tags[i].ID == id {
t.Code = s.tags[i].Code // assigned by the server, never changed
s.tags[i] = t s.tags[i] = t
return t, s.save() return t, s.save()
} }
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestTagCodes(t *testing.T) {
path := filepath.Join(t.TempDir(), "tags.json")
// File written before codes existed, with a duplicate code.
old := `[{"id":"a","pattern":"x","color":"#000000"},{"id":"b","code":"07","pattern":"y","color":"#000000"},{"id":"c","code":"07","pattern":"z","color":"#000000"}]`
if err := os.WriteFile(path, []byte(old), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
got := []string{}
for _, tg := range s.List() {
got = append(got, tg.Code)
}
if want := []string{"01", "07", "02"}; !slices.Equal(got, want) {
t.Fatalf("migrated codes = %v, want %v", got, want)
}
n, err := s.Create(Tag{Pattern: "w", Color: "#000000"})
if err != nil || n.Code != "03" {
t.Fatalf("Create code = %q, %v; want 03", n.Code, err)
}
// The client cannot change a code.
u, err := s.Update("b", Tag{Code: "42", Pattern: "y2", Color: "#000000"})
if err != nil || u.Code != "07" {
t.Fatalf("Update code = %q, %v; want 07", u.Code, err)
}
// A deleted tag frees its code; the others keep theirs.
if err := s.Delete("a"); err != nil {
t.Fatal(err)
}
if n, _ := s.Create(Tag{Pattern: "v", Color: "#000000"}); n.Code != "01" {
t.Fatalf("code after delete = %q, want 01", n.Code)
}
// Codes are saved in the file.
s2, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
if got := s2.List()[0].Code; got != "07" {
t.Fatalf("reloaded code = %q, want 07", got)
}
}
+374 -75
View File
@@ -33,6 +33,7 @@ const I18N = {
skipped: (n) => `${n} messages not shown (rate too high)`, skipped: (n) => `${n} messages not shown (rate too high)`,
stats: (s) => `received ${s.received} · stored ${s.ingested} · dropped ${s.dropped} · queue ${s.queue}`, stats: (s) => `received ${s.received} · stored ${s.ingested} · dropped ${s.dropped} · queue ${s.queue}`,
storageErr: 'storage: ', storageErr: 'storage: ',
spooled: (n) => `${n} waiting on disk`,
unreachable: 'server unreachable', unreachable: 'server unreachable',
fTime: 'timestamp', fMsg: 'message', fPid: 'pid', fSd: 'structured data', fTime: 'timestamp', fMsg: 'message', fPid: 'pid', fSd: 'structured data',
filterHost: 'Filter on this host', filterApp: 'Filter on this app', filterHost: 'Filter on this host', filterApp: 'Filter on this app',
@@ -47,18 +48,19 @@ const I18N = {
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.', colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
newTag: 'new', newTag: 'new',
confirmDelete: (p) => `Delete tag "${p}"?`, confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error, ok)?', confirmReset: 'Replace all tags with the defaults (warning, error)?',
presetAria: 'Add a preset', presetPick: '+ Preset…', presetAria: 'Add a preset', presetPick: '+ Preset…',
preset_http_status: 'HTTP status codes', preset_http_methods: 'HTTP methods', tagFilter: 'Filter tags (keyword, label or code)',
preset_http_probes: 'Probes and attacks', preset_http_bots: 'Bots and scripts', tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total === 1 ? '' : 's'}` : `${n.shown} / ${n.total} tags`),
preset_http_errors: 'TLS/HTTPS and proxy errors', noTagMatch: 'No tag matches the filter.',
pl_probes: 'probes / attacks', pl_bots: 'bots / scripts', pl_tls: 'TLS errors', pl_proxy: 'proxy errors',
presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'), presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'),
presetsFileErr: (f) => `Invalid presets file ${f}, built-in presets used: `,
tagsLoadErr: 'Tags: ', tagsLoadErr: 'Tags: ',
err_pattern_required: 'The keyword is required', err_pattern_required: 'The keyword is required',
err_invalid_color: 'Invalid color (expected #rrggbb)', err_invalid_color: 'Invalid color (expected #rrggbb)',
err_invalid_regex: 'Invalid regular expression', err_invalid_regex: 'Invalid regular expression',
err_tag_not_found: 'Tag not found', err_tag_not_found: 'Tag not found',
err_too_many_tags: 'Too many tags (99 at most)',
err_live_logsql: 'Live view is not available in LogsQL mode', err_live_logsql: 'Live view is not available in LogsQL mode',
dateTime: 'Date & time', dateTime: 'Date & time',
tzLabel: 'Time zone', tzLabel: 'Time zone',
@@ -119,10 +121,21 @@ const I18N = {
themeHelp: 'System follows the light/dark preference of your computer or phone.', themeHelp: 'System follows the light/dark preference of your computer or phone.',
logDisplay: 'Log display', fontSize: 'Font size', logDisplay: 'Log display', fontSize: 'Font size',
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large', sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
fontLabel: 'Font', fontSystem: 'System monospace (no download)', density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
fontHelp: 'Free fonts (SIL Open Font License) loaded by your browser from Bunny Fonts, a privacy-friendly European font service. Without internet access, the system font is used.', fontLabel: 'Font', fontSystem: 'System monospace (no download)', fontBuiltin: 'built in, narrow',
fontHelp: 'Free fonts. The built-in ones (Inconsolata Condensed, the narrowest, Iosevka and Ubuntu Mono) are served by LogStream itself and work offline; they are narrow, so more text fits on each line. The others are loaded by your browser from Bunny Fonts, a privacy-friendly European font service; without internet access, the system font is used.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'], previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Danger zone', dangerZone: 'Danger zone',
dbTitle: 'Database', dbRefresh: 'Refresh', dbLoading: 'Loading…',
dbUnits: ['B', 'KB', 'MB', 'GB', 'TB'],
dbRows: 'Stored lines', dbDisk: 'Size on disk', dbIndex: (x) => `including ${x} of index`,
dbRaw: 'Raw size', dbRatio: (x) => `compressed ×${x}`,
dbPeriod: 'Period', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} day${n > 1 ? 's' : ''}`,
dbRetention: (r) => `retention ${r}`,
dbRecent: 'Last 24 h', dbLastHour: (n) => `${n} in the last hour`,
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} host${p.hn > 1 ? 's' : ''} · ${p.a} app${p.an > 1 ? 's' : ''}`,
dbFree: 'Free disk space', dbEmpty: 'The database is empty.',
dbQueryErr: 'Some figures could not be computed: ',
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.', purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
purgeBtn: 'Delete all logs…', purgeBtn: 'Delete all logs…',
purgePrompt: 'This permanently deletes ALL stored logs.\n\nType PURGE to confirm:', purgePrompt: 'This permanently deletes ALL stored logs.\n\nType PURGE to confirm:',
@@ -132,23 +145,34 @@ const I18N = {
purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).', purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)', err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)',
err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)', err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)',
err_read_only: 'Read-only account: changes are reserved to administrators',
err_cross_site: 'Request refused: it comes from another site',
authOff: 'No authentication: anyone who can reach this page can read the logs and change the settings. Set AUTH_USER / AUTH_PASS or AUTH_MODE=oidc.',
readOnlyNote: 'Read-only account: these settings can only be changed by an administrator.',
err_purge_confirm: 'Type PURGE to confirm', err_purge_confirm: 'Type PURGE to confirm',
liveZoomed: 'Live view is not available on a zoomed range', liveZoomed: 'Live view is only available in Stream mode',
connZoom: 'live paused (zoom)', connZoom: 'live paused (time range)',
viewAria: 'Display mode', viewStream: 'Stream', viewPeriod: 'Time range',
viewStreamTitle: 'Stream: the latest logs over a sliding duration, updated live',
viewPeriodTitle: 'Time range: the logs between a start and an end date',
perFrom: 'Start', perTo: 'End', perPrev: 'Previous time range', perNext: 'Next time range',
perOut: 'Zoom out (twice as long)',
histoTitle: 'Timeline', histoTitle: 'Timeline',
hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color', hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color',
hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None', hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None',
hRender: 'Display', hBars: 'Bars', hArea: 'Area', hRender: 'Display', hBars: 'Bars', hArea: 'Area',
hStep: 'Division', hAuto: 'Automatic', hRefresh: 'Refresh', hOff: 'Off', hRefreshAuto: 'At each new interval', hStep: 'Division', hAuto: 'Automatic', hRefresh: 'Refresh', hOff: 'Off', hRefreshAuto: 'At each new interval',
histoHelp: 'Click a bar to zoom on its interval, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.', histoHelp: 'Click a bar to show its interval in Time range mode, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.',
hLeg_err: 'error and above', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug', hLeg_err: 'error and above', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×', hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)', hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)',
hDivision: (s) => `interval ${s}`, hCapped: 'enlarged', hDivision: (s) => `interval ${s}`, hCapped: 'enlarged',
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.', hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Reset zoom', unitDay: 'd', hUnzoom: '× Back to stream', unitDay: 'd',
toTop: 'Back to top', toTop: 'Back to top',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colMsg: 'Message', colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
codesTitle: 'Codes of the color tags found in the message',
tagCodeTitle: 'Code shown on the log lines this tag matches',
colGrip: 'Drag to resize, double-click for the automatic width', colGrip: 'Drag to resize, double-click for the automatic width',
resetCols: 'Reset column widths', colsReset: 'Column widths reset', resetCols: 'Reset column widths', colsReset: 'Column widths reset',
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).', colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
@@ -183,6 +207,7 @@ const I18N = {
skipped: (n) => `${n} messages non affichés (débit trop élevé)`, 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}`, stats: (s) => `reçus ${s.received} · stockés ${s.ingested} · perdus ${s.dropped} · file ${s.queue}`,
storageErr: 'stockage : ', storageErr: 'stockage : ',
spooled: (n) => `${n} en attente sur disque`,
unreachable: 'serveur injoignable', unreachable: 'serveur injoignable',
fTime: 'horodatage', fMsg: 'message', fPid: 'pid', fSd: 'données structurées', fTime: 'horodatage', fMsg: 'message', fPid: 'pid', fSd: 'données structurées',
filterHost: 'Filtrer sur cet hôte', filterApp: 'Filtrer sur cette appli', filterHost: 'Filtrer sur cet hôte', filterApp: 'Filtrer sur cette appli',
@@ -197,18 +222,19 @@ const I18N = {
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.', colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
newTag: 'nouveau', newTag: 'nouveau',
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`, confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?', confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?',
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…', presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
preset_http_status: 'Codes HTTP', preset_http_methods: 'Méthodes HTTP', tagFilter: 'Filtrer les tags (mot-clé, libellé ou code)',
preset_http_probes: 'Sondes et attaques', preset_http_bots: 'Robots et scripts', tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total > 1 ? 's' : ''}` : `${n.shown} / ${n.total} tags`),
preset_http_errors: 'Erreurs TLS/HTTPS et proxy', noTagMatch: 'Aucun tag ne correspond au filtre.',
pl_probes: 'sondes / attaques', pl_bots: 'robots / scripts', pl_tls: 'erreurs TLS', pl_proxy: 'erreurs proxy',
presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'), presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'),
presetsFileErr: (f) => `Fichier de préréglages ${f} invalide, préréglages intégrés utilisés : `,
tagsLoadErr: 'Tags : ', tagsLoadErr: 'Tags : ',
err_pattern_required: 'Le mot-clé est obligatoire', err_pattern_required: 'Le mot-clé est obligatoire',
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)', err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
err_invalid_regex: 'Expression régulière invalide', err_invalid_regex: 'Expression régulière invalide',
err_tag_not_found: 'Tag introuvable', err_tag_not_found: 'Tag introuvable',
err_too_many_tags: 'Trop de tags (99 au maximum)',
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL', err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
dateTime: 'Date et heure', dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire', tzLabel: 'Fuseau horaire',
@@ -269,10 +295,21 @@ const I18N = {
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.', themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.',
logDisplay: 'Affichage des logs', fontSize: 'Taille du texte', logDisplay: 'Affichage des logs', fontSize: 'Taille du texte',
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande', sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
fontHelp: 'Polices libres (licence SIL Open Font) chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée. Sans accès à internet, la police du système est utilisée.', fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', fontBuiltin: 'intégrée, étroite',
fontHelp: 'Polices libres. Les polices intégrées (Inconsolata Condensed, la plus étroite, Iosevka et Ubuntu Mono) sont servies par LogStream lui-même et fonctionnent hors ligne ; elles sont étroites, donc chaque ligne affiche plus de texte. Les autres sont chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée ; sans accès à internet, la police du système est utilisée.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'], previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Zone de danger', dangerZone: 'Zone de danger',
dbTitle: 'Base de données', dbRefresh: 'Actualiser', dbLoading: 'Chargement…',
dbUnits: ['o', 'Ko', 'Mo', 'Go', 'To'],
dbRows: 'Lignes stockées', dbDisk: 'Taille sur disque', dbIndex: (x) => `dont ${x} d'index`,
dbRaw: 'Taille brute', dbRatio: (x) => `compression ×${x}`,
dbPeriod: 'Période', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} jour${n > 1 ? 's' : ''}`,
dbRetention: (r) => `rétention ${r}`,
dbRecent: 'Dernières 24 h', dbLastHour: (n) => `${n} sur la dernière heure`,
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} hôte${p.hn > 1 ? 's' : ''} · ${p.a} application${p.an > 1 ? 's' : ''}`,
dbFree: 'Espace disque libre', dbEmpty: 'La base est vide.',
dbQueryErr: 'Certains chiffres n\'ont pas pu être calculés : ',
purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.', purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.',
purgeBtn: 'Supprimer tous les logs…', purgeBtn: 'Supprimer tous les logs…',
purgePrompt: 'Cette action supprime définitivement TOUS les logs stockés.\n\nTapez PURGE pour confirmer :', purgePrompt: 'Cette action supprime définitivement TOUS les logs stockés.\n\nTapez PURGE pour confirmer :',
@@ -282,23 +319,34 @@ const I18N = {
purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).', purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)', err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)',
err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)', err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)',
err_read_only: 'Compte en lecture seule : les modifications sont réservées aux administrateurs',
err_cross_site: 'Requête refusée : elle vient d\'un autre site',
authOff: 'Aucune authentification : toute personne qui atteint cette page peut lire les logs et changer les réglages. Définissez AUTH_USER / AUTH_PASS ou AUTH_MODE=oidc.',
readOnlyNote: 'Compte en lecture seule : seul un administrateur peut modifier ces réglages.',
err_purge_confirm: 'Tapez PURGE pour confirmer', err_purge_confirm: 'Tapez PURGE pour confirmer',
liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée', liveZoomed: 'Le direct n\'est disponible qu\'en mode Flux',
connZoom: 'direct en pause (zoom)', connZoom: 'direct en pause (période)',
viewAria: 'Mode d\'affichage', viewStream: 'Flux', viewPeriod: 'Période',
viewStreamTitle: 'Flux : les derniers logs sur une durée glissante, mis à jour en direct',
viewPeriodTitle: 'Période : les logs entre une date de début et une date de fin',
perFrom: 'Début', perTo: 'Fin', perPrev: 'Période précédente', perNext: 'Période suivante',
perOut: 'Élargir (deux fois plus long)',
histoTitle: 'Frise', histoTitle: 'Frise',
hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur', hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur',
hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune', hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune',
hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire', hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire',
hStep: 'Division', hAuto: 'Automatique', hRefresh: 'Rafraîchissement', hOff: 'Désactivé', hRefreshAuto: 'À chaque nouvel intervalle', hStep: 'Division', hAuto: 'Automatique', hRefresh: 'Rafraîchissement', hOff: 'Désactivé', hRefreshAuto: 'À chaque nouvel intervalle',
histoHelp: 'Cliquez sur une barre pour zoomer sur son intervalle, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.', histoHelp: 'Cliquez sur une barre pour afficher son intervalle en mode Période, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.',
hLeg_err: 'error et plus', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug', hLeg_err: 'error et plus', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calme', hLeg_burst: 'rafale > 3×', hLeg_anom: 'anomalie > 10×', hLeg_calm: 'calme', hLeg_burst: 'rafale > 3×', hLeg_anom: 'anomalie > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× la médiane`, hUnshown: 'sans détail (débit élevé)', hTotal: 'Total', hRatio: (x) => `${x}× la médiane`, hUnshown: 'sans détail (débit élevé)',
hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi', hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi',
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.', hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Annuler le zoom', unitDay: 'j', hUnzoom: '× Revenir au flux', unitDay: 'j',
toTop: 'Revenir en haut', toTop: 'Revenir en haut',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colMsg: 'Message', colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
codesTitle: 'Codes des tags de couleur trouvés dans le message',
tagCodeTitle: 'Code affiché sur les lignes de log où ce tag est trouvé',
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique', colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées', resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).', colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
@@ -449,12 +497,13 @@ const state = {
rows: [], // displayed records, newest first rows: [], // displayed records, newest first
pending: [], // live messages received while scrolled down pending: [], // live messages received while scrolled down
tags: [], tags: [],
presets: [], // preset groups from /api/presets
matchers: [], // compiled tags + search terms matchers: [], // compiled tags + search terms
mode: store.get('mode', 'simple'), mode: store.get('mode', 'simple'),
live: store.get('live', '1') === '1', live: store.get('live', '1') === '1',
es: null, es: null,
reqId: 0, reqId: 0,
zoom: null, // {from, to} in ms when the timeline is zoomed (range "custom") zoom: null, // {from, to} in ms in Time range mode (range "custom"), null in Stream mode
tookMs: null, tookMs: null,
conn: ['', 'connPaused'], conn: ['', 'connPaused'],
stats: null, stats: null,
@@ -483,12 +532,14 @@ function setLang(next) {
setConn(...state.conn); setConn(...state.conn);
renderList(); renderList();
updateCount(state.tookMs); updateCount(state.tookMs);
setZoomOption(); renderPeriod();
renderHisto(); renderHisto();
renderStats(); renderStats();
renderTagList(); renderTagList();
renderPresets();
renderTimeSettings(); renderTimeSettings();
renderPurge(); renderPurge();
renderDbStats();
renderInterface(); renderInterface();
renderSyslog(); renderSyslog();
renderHost(); renderHost();
@@ -531,16 +582,19 @@ $('#themeSwitch').addEventListener('click', (ev) => {
/* ================= Log font and size ================= */ /* ================= Log font and size ================= */
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net). // Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net), except the
// built-in ones (web/fonts, declared in style.css), which also work offline.
const LOG_FONTS = [ const LOG_FONTS = [
{ id: 'system' }, { id: 'system' },
{ id: 'inconsolata-condensed', family: 'Inconsolata Condensed', builtin: true },
{ id: 'iosevka', family: 'Iosevka', builtin: true },
{ id: 'ubuntu-mono', family: 'Ubuntu Mono', builtin: true },
{ id: 'jetbrains-mono', family: 'JetBrains Mono' }, { id: 'jetbrains-mono', family: 'JetBrains Mono' },
{ id: 'fira-code', family: 'Fira Code' }, { id: 'fira-code', family: 'Fira Code' },
{ id: 'source-code-pro', family: 'Source Code Pro' }, { id: 'source-code-pro', family: 'Source Code Pro' },
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono' }, { id: 'ibm-plex-mono', family: 'IBM Plex Mono' },
{ id: 'cascadia-code', family: 'Cascadia Code' }, { id: 'cascadia-code', family: 'Cascadia Code' },
{ id: 'roboto-mono', family: 'Roboto Mono' }, { id: 'roboto-mono', family: 'Roboto Mono' },
{ id: 'ubuntu-mono', family: 'Ubuntu Mono' },
{ id: 'inconsolata', family: 'Inconsolata' }, { id: 'inconsolata', family: 'Inconsolata' },
{ id: 'red-hat-mono', family: 'Red Hat Mono' }, { id: 'red-hat-mono', family: 'Red Hat Mono' },
{ id: 'noto-sans-mono', family: 'Noto Sans Mono' }, { id: 'noto-sans-mono', family: 'Noto Sans Mono' },
@@ -548,7 +602,8 @@ const LOG_FONTS = [
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' }, { id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
]; ];
const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' }; const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' };
const ui = { font: 'system', size: 'medium' }; const LOG_DENSITIES = ['normal', 'compact'];
const ui = { font: 'system', size: 'medium', density: 'normal' };
function applyLogFont(id) { function applyLogFont(id) {
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0]; const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
@@ -558,6 +613,10 @@ function applyLogFont(id) {
root.removeProperty('--log-font'); root.removeProperty('--log-font');
return; return;
} }
if (f.builtin) {
root.setProperty('--log-font', `'${f.family}', var(--mono)`);
return;
}
const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`; const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`;
let link = document.getElementById('logFontCss'); let link = document.getElementById('logFontCss');
if (!link) { if (!link) {
@@ -573,13 +632,22 @@ function applyLogSize(id) {
document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]); document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]);
} }
function applyLogDensity(id) {
ui.density = LOG_DENSITIES.includes(id) ? id : 'normal';
document.documentElement.dataset.density = ui.density;
}
function renderInterface() { function renderInterface() {
renderThemeSwitch(); renderThemeSwitch();
for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) { for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) {
b.setAttribute('aria-checked', String(b.dataset.size === ui.size)); b.setAttribute('aria-checked', String(b.dataset.size === ui.size));
} }
for (const b of document.querySelectorAll('#densitySwitch [data-density]')) {
b.setAttribute('aria-checked', String(b.dataset.density === ui.density));
}
const sel = $('#fontSelect'); const sel = $('#fontSelect');
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(f.family || t('fontSystem'))}</option>`).join(''); const fontName = (f) => (!f.family ? t('fontSystem') : f.builtin ? `${f.family} (${t('fontBuiltin')})` : f.family);
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(fontName(f))}</option>`).join('');
sel.value = ui.font; sel.value = ui.font;
renderFontPreview(); renderFontPreview();
renderHistoSettings(); renderHistoSettings();
@@ -603,6 +671,13 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
store.set('logSize', ui.size); store.set('logSize', ui.size);
renderInterface(); renderInterface();
}); });
$('#densitySwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-density]');
if (!b) return;
applyLogDensity(b.dataset.density);
store.set('logDensity', ui.density);
renderInterface();
});
$('#fontSelect').addEventListener('change', (ev) => { $('#fontSelect').addEventListener('change', (ev) => {
applyLogFont(ev.target.value); applyLogFont(ev.target.value);
store.set('logFont', ui.font); store.set('logFont', ui.font);
@@ -696,7 +771,8 @@ function compileMatchers() {
// A regex may name a group "hl" to color only that part of the match. // A regex may name a group "hl" to color only that part of the match.
const hl = tag.regex && /\(\?P?<hl>/.test(tag.pattern); const hl = tag.regex && /\(\?P?<hl>/.test(tag.pattern);
if (hl) src = src.replace(/\(\?P<hl>/g, '(?<hl>'); if (hl) src = src.replace(/\(\?P<hl>/g, '(?<hl>');
out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` }); out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">`,
code: tag.code, name: tag.label || tag.pattern });
} catch (e) { } catch (e) {
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message); console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
} }
@@ -738,6 +814,30 @@ function highlight(text) {
return out + esc(text.slice(pos)); return out + esc(text.slice(pos));
} }
// Badges with the codes of the tags found in a message, in list order. The
// column has room for 3: beyond that, 2 badges and "+N" (all in the tooltip).
function codesHTML(text) {
text = String(text ?? '');
const found = [];
for (const m of state.matchers) {
if (!m.code || !text) continue;
m.re.lastIndex = 0;
let x;
while ((x = m.re.exec(text)) !== null) {
if (x[0] === '') { m.re.lastIndex++; continue; }
const g = x.indices?.groups?.hl;
if (!x.indices?.groups || g) { found.push(m); break; }
}
}
if (!found.length) return '';
const shown = found.length > 3 ? found.slice(0, 2) : found;
const title = found.map((m) => `${m.code} ${m.name}`).join('\n');
return `<span title="${esc(title)}">`
+ shown.map((m) => `<b>${esc(m.code)}</b>`).join('')
+ (found.length > 3 ? `<b class="more">+${found.length - 2}</b>` : '')
+ '</span>';
}
/* ================= List rendering ================= */ /* ================= List rendering ================= */
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' }; const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
@@ -763,6 +863,7 @@ function rowHTML(r, isNew) {
+ (r.app + (r.app
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>` ? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
: '<span class="app"></span>') : '<span class="app"></span>')
+ `<span class="codes">${codesHTML(r._msg)}</span>`
+ `<div class="msg">${highlight(r._msg)}</div>` + `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>'; + '</article>';
} }
@@ -829,7 +930,9 @@ function renderList() {
function rehighlight() { function rehighlight() {
for (const row of list.children) { for (const row of list.children) {
const r = recOf.get(row); const r = recOf.get(row);
if (r) row.querySelector('.msg').innerHTML = highlight(r._msg); if (!r) continue;
row.querySelector('.msg').innerHTML = highlight(r._msg);
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
} }
} }
@@ -1342,33 +1445,115 @@ function showHistoSel() {
sel.style.width = ((b - a + 1) / d.count) * 100 + '%'; sel.style.width = ((b - a + 1) / d.count) * 100 + '%';
} }
function setZoomOption() { /* ---- Display mode: Stream or Time range ---- */
const sel = $('#range');
let opt = sel.querySelector('option[value="custom"]'); // datetime-local fields hold a wall time in the chosen time zone.
if (!state.zoom) { function toWall(ms, sec) {
if (opt) opt.remove(); const p = zoneParts(new Date(ms));
return; return `${p.Y}-${p.M}-${p.D}T${p.h}:${p.m}${sec ? ':' + p.s : ''}`;
}
function zoneOffsetMs(ms) {
const m = /^([+-])(\d\d):(\d\d)$/.exec(zoneParts(new Date(ms)).off);
return m ? (m[1] === '-' ? -1 : 1) * (m[2] * 36e5 + m[3] * 6e4) : 0;
}
function fromWall(v) {
const m = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d))?/.exec(v || '');
if (!m) return NaN;
const wall = Date.UTC(m[1], m[2] - 1, m[3], m[4], m[5], m[6] || 0);
const guess = wall - zoneOffsetMs(wall);
return wall - zoneOffsetMs(guess); // second pass: right offset around DST changes
}
const serverNow = () => Date.now() + (histo.data ? histo.data.offset : 0);
function renderPeriod() {
const on = !!state.zoom;
for (const b of $('#viewMode').querySelectorAll('[data-view]')) {
b.setAttribute('aria-checked', String((b.dataset.view === 'period') === on));
} }
if (!opt) { opt = new Option('', 'custom'); sel.add(opt); } $('#range').hidden = on;
opt.textContent = fmtSpan(state.zoom.from, state.zoom.to, state.zoom.to - state.zoom.from < 3e5); $('#period').hidden = !on;
sel.value = 'custom'; if (!on) return;
const { from, to } = state.zoom;
const sec = from % 6e4 !== 0 || to % 6e4 !== 0;
for (const [el, v] of [[$('#perFrom'), from], [$('#perTo'), to]]) {
el.step = sec ? 1 : 60;
el.classList.remove('bad');
if (document.activeElement !== el) el.value = toWall(v, sec);
}
$('#perNext').disabled = to >= serverNow();
} }
function zoomTo(from, to) { function zoomTo(from, to) {
state.zoom = { from: Math.round(from), to: Math.round(to) }; state.zoom = { from: Math.round(from), to: Math.round(to) };
setZoomOption(); store.set('period', JSON.stringify(state.zoom));
renderPeriod();
hideHistoTip(); hideHistoTip();
refresh(); refresh();
} }
function unzoom() { function unzoom() {
state.zoom = null; state.zoom = null;
setZoomOption(); store.set('period', '');
renderPeriod();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h'; if (!$('#range').value) $('#range').value = '1h';
refresh(); refresh();
} }
// Entering Time range mode starts from what the stream was showing.
function enterPeriod() {
if (state.zoom) return;
const d = histo.data;
if (d) return zoomTo(d.start, d.start + d.count * d.step);
const to = Math.ceil(serverNow() / 6e4) * 6e4;
zoomTo(to - (RANGE_MS[$('#range').value] || 36e5), to);
}
$('#viewMode').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-view]');
if (!b) return;
if (b.dataset.view === 'period') enterPeriod();
else if (state.zoom) unzoom();
});
const onPeriodInput = debounce(() => {
if (!state.zoom) return;
const from = fromWall($('#perFrom').value);
const to = fromWall($('#perTo').value);
const ok = !isNaN(from) && !isNaN(to) && to > from;
$('#perFrom').classList.toggle('bad', isNaN(from) || (!isNaN(to) && !ok));
$('#perTo').classList.toggle('bad', isNaN(to) || (!isNaN(from) && !ok));
if (ok && (from !== state.zoom.from || to !== state.zoom.to)) zoomTo(from, to);
}, 500);
for (const id of ['perFrom', 'perTo']) {
$('#' + id).addEventListener('input', onPeriodInput);
$('#' + id).addEventListener('keydown', (ev) => { if (ev.key === 'Enter') ev.target.blur(); });
$('#' + id).addEventListener('blur', () => { if (!$('#' + id).classList.contains('bad')) renderPeriod(); });
}
// ◀ ▶ move by the width of the range, − doubles it around its middle.
function shiftPeriod(dir) {
const { from, to } = state.zoom;
const w = to - from;
zoomTo(from + dir * w, to + dir * w);
}
$('#perPrev').addEventListener('click', () => shiftPeriod(-1));
$('#perNext').addEventListener('click', () => shiftPeriod(1));
$('#perOut').addEventListener('click', () => {
const { from, to } = state.zoom;
const w = to - from;
let a = from - w / 2;
let b = to + w / 2;
const now = serverNow();
const end = Math.max(to, now); // widen into the past rather than the future
if (b > end) { a -= b - end; b = end; }
const r = w >= 12e4 ? 6e4 : 1e3; // whole minutes, or seconds for short ranges
zoomTo(Math.round(a / r) * r, Math.round(b / r) * r);
});
{ {
const el = $('#histo'); const el = $('#histo');
el.innerHTML = '<div class="h-plot"><svg class="h-svg" preserveAspectRatio="none" aria-hidden="true"></svg>' el.innerHTML = '<div class="h-plot"><svg class="h-svg" preserveAspectRatio="none" aria-hidden="true"></svg>'
@@ -1490,10 +1675,6 @@ async function refresh() {
} }
const onFilterChange = (ev) => { const onFilterChange = (ev) => {
if (ev && ev.target.id === 'range' && state.zoom && ev.target.value !== 'custom') {
state.zoom = null; // another range chosen: leave the zoom
setZoomOption();
}
if (!state.zoom) store.set('range', $('#range').value); if (!state.zoom) store.set('range', $('#range').value);
store.set('severity', $('#severity').value); store.set('severity', $('#severity').value);
refresh(); refresh();
@@ -1608,6 +1789,7 @@ function renderStats() {
const n = { received: fmtNum(s.received), ingested: fmtNum(s.ingested), dropped: '%DROPPED%', queue: fmtNum(s.queue) }; 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); const dropped = s.dropped ? `<span class="bad">${fmtNum(s.dropped)}</span>` : fmtNum(0);
let html = esc(t('stats', n)).replace('%DROPPED%', dropped); let html = esc(t('stats', n)).replace('%DROPPED%', dropped);
if (s.spooled) html += ` · <span class="warn">${esc(t('spooled', fmtNum(s.spooled)))}</span>`;
if (s.lastError) html += ` · <span class="bad">${esc(t('storageErr') + s.lastError)}</span>`; if (s.lastError) html += ` · <span class="bad">${esc(t('storageErr') + s.lastError)}</span>`;
el.innerHTML = html; el.innerHTML = html;
} }
@@ -1656,7 +1838,7 @@ function onTimePrefsChange() {
store.set('timefmt', timePrefs.fmt); store.set('timefmt', timePrefs.fmt);
updateTimePreview(); updateTimePreview();
renderList(); renderList();
setZoomOption(); renderPeriod();
refreshHisto(); // intervals are aligned on the local time refreshHisto(); // intervals are aligned on the local time
} }
$('#tzSelect').addEventListener('change', onTimePrefsChange); $('#tzSelect').addEventListener('change', onTimePrefsChange);
@@ -1937,6 +2119,69 @@ setInterval(() => {
} }
}, 4000); }, 4000);
/* ================= Database statistics ================= */
// 1536 -> "1,5 Ko" / "1.5 KB" (binary multiples, the usual reading for disk sizes).
function fmtBytes(n) {
const units = t('dbUnits');
let i = 0;
n = Number(n || 0);
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
const digits = i === 0 || n >= 100 ? 0 : 1;
return `${n.toLocaleString(t('locale'), { maximumFractionDigits: digits })} ${units[i]}`;
}
const db = { stats: null, error: null, loading: false };
function renderDbStats() {
const el = $('#dbStats');
const s = db.stats;
if (!s) {
el.innerHTML = `<div class="db-msg${db.error ? ' bad' : ''}">${esc(db.error || t('dbLoading'))}</div>`;
return;
}
const row = (k, v, sub) => `<dt>${esc(k)}</dt><dd><b>${esc(v)}</b>${sub ? ` <span class="muted">${esc(sub)}</span>` : ''}</dd>`;
const day = (x) => fmtFull(x).replace(/[.,]\d{3}(?=\D*$)/, '');
let html = row(t('dbRows'), fmtNum(s.rows));
html += row(t('dbDisk'), fmtBytes(s.diskBytes), s.indexBytes ? t('dbIndex', fmtBytes(s.indexBytes)) : '');
if (s.rawBytes) {
const ratio = s.diskBytes ? (s.rawBytes / s.diskBytes).toLocaleString(t('locale'), { maximumFractionDigits: 1 }) : '';
html += row(t('dbRaw'), fmtBytes(s.rawBytes), ratio ? t('dbRatio', ratio) : '');
}
if (s.oldest && s.newest) {
const sub = [s.days ? t('dbDays', s.days) : '', s.retention ? t('dbRetention', s.retention) : ''].filter(Boolean).join(' · ');
html += row(t('dbPeriod'), t('dbFromTo', { from: day(s.oldest), to: day(s.newest) }), sub);
} else if (s.retention) {
html += row(t('dbPeriod'), '—', t('dbRetention', s.retention));
}
if (s.rows) {
html += row(t('dbRecent'), fmtNum(s.last24h), t('dbLastHour', fmtNum(s.last1h)));
html += row(t('dbSources'), t('dbHostsApps', { h: fmtNum(s.hosts), hn: s.hosts, a: fmtNum(s.apps), an: s.apps }));
}
if (s.freeBytes) html += row(t('dbFree'), fmtBytes(s.freeBytes));
if (!s.rows) html += `<div class="db-msg">${esc(t('dbEmpty'))}</div>`;
if (s.queryError) html += `<div class="db-msg bad">${esc(t('dbQueryErr') + s.queryError)}</div>`;
el.innerHTML = html;
}
async function loadDbStats(refresh = false) {
if (db.loading) return;
db.loading = true;
$('#dbRefresh').disabled = true;
try {
db.stats = await api('/api/dbstats' + (refresh ? '?refresh=1' : ''));
db.error = null;
} catch (e) {
db.stats = null;
db.error = e.message;
} finally {
db.loading = false;
$('#dbRefresh').disabled = false;
}
renderDbStats();
}
$('#dbRefresh').addEventListener('click', () => loadDbStats(true));
/* ================= Purge ================= */ /* ================= Purge ================= */
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null }; const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
@@ -1966,6 +2211,7 @@ async function loadPurgeStatus() {
refresh(); refresh();
loadFacets(); loadFacets();
loadStats(); loadStats();
loadDbStats(true);
} }
purge.running = s.running || 0; purge.running = s.running || 0;
} catch { /* shown on the next attempt */ } } catch { /* shown on the next attempt */ }
@@ -1995,27 +2241,24 @@ $('#purgeBtn').addEventListener('click', async () => {
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316']; const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
// Ready-made tags for HTTP/HTTPS access logs. The patterns are valid in both // Ready-made tags, from the presets file (or the built-in presets.json):
// JavaScript and Go (RE2) and cover nginx/Apache (common, combined), Traefik // see docs/presets.md. A text is a string or an object per language.
// (CLF, JSON), Caddy (JSON) and HAProxy (httplog). For status codes and const pickText = (x) => (typeof x === 'string' ? x : (x?.[lang] ?? x?.en ?? Object.values(x || {})[0] ?? ''));
// methods only the "hl" group is colored, not the context around it.
const HTTP_STATUS_CTX = '(?:" |"(?:status|DownstreamStatus|OriginStatus|status_code)": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )'; async function loadPresets() {
const httpStatus = (d, color) => ({ label: `HTTP ${d}xx`, pattern: `${HTTP_STATUS_CTX}(?<hl>${d}\\d\\d)\\b`, color }); try {
const httpMethod = (m, color) => ({ label: m.replace(/\|/g, '/'), pattern: `"(?<hl>${m})[ "]`, color, caseSensitive: true }); const res = await api('/api/presets');
const PRESETS = { state.presets = res.groups || [];
http_status: () => [httpStatus(2, '#86efac'), httpStatus(3, '#93c5fd'), httpStatus(4, '#fdba74'), httpStatus(5, '#f87171')], if (res.error) toast(t('presetsFileErr', res.file) + res.error);
http_methods: () => [httpMethod('GET|HEAD|OPTIONS', '#cbd5e1'), httpMethod('POST|PUT|PATCH', '#c4b5fd'), httpMethod('DELETE', '#f9a8d4')], } catch (e) { toast(e.message); }
http_probes: () => [{ label: t('pl_probes'), color: '#fda4af', renderPresets();
pattern: '(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|<script|union(?:\\s|%20|\\+)+select)' }], }
http_bots: () => [{ label: t('pl_bots'), color: '#fde68a',
pattern: '\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b' }], function renderPresets() {
http_errors: () => [ $('#presetTags').innerHTML = `<option value="">${esc(t('presetPick'))}</option>`
{ label: t('pl_tls'), color: '#f0abfc', + state.presets.map((g) => `<optgroup label="${esc(pickText(g.group))}">`
pattern: '(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)' }, + g.presets.map((p) => `<option value="${esc(p.id)}">${esc(pickText(p.name))}</option>`).join('') + '</optgroup>').join('');
{ label: t('pl_proxy'), color: '#fdba74', }
pattern: '(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)' },
],
};
async function loadTags() { async function loadTags() {
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); } try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
@@ -2026,6 +2269,7 @@ function tagRowHTML(tag) {
const opt = (field, label, title) => const opt = (field, label, title) =>
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`; `<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)}"> return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
<span class="tag-code" title="${esc(t('tagCodeTitle'))}">${esc(tag.code || '··')}</span>
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}"> <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'))}"> <input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
<span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span> <span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span>
@@ -2044,8 +2288,32 @@ function tagRowHTML(tag) {
function renderTagList() { function renderTagList() {
$('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || `<p class="muted small">${esc(t('noTags'))}</p>`; $('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || `<p class="muted small">${esc(t('noTags'))}</p>`;
filterTagList();
} }
// Settings > Filters: shows only the tags whose keyword, label or code contains the filter.
function filterTagList() {
const q = $('#tagFilter').value.trim().toLowerCase();
let shown = 0;
for (const row of $('#tagList').querySelectorAll('.tag-row')) {
const tag = state.tags.find((x) => x.id === row.dataset.id);
const hit = !q || !tag || [tag.pattern, tag.label, tag.code].some((v) => String(v || '').toLowerCase().includes(q));
row.hidden = !hit;
if (hit) shown++;
}
let none = $('#tagList').querySelector('.tag-none');
if (q && !shown && state.tags.length) {
if (!none) {
none = document.createElement('p');
none.className = 'muted small tag-none';
$('#tagList').append(none);
}
none.textContent = t('noTagMatch');
} else if (none) none.remove();
$('#tagCount').textContent = state.tags.length ? t('tagCount', { shown, total: state.tags.length }) : '';
}
$('#tagFilter').addEventListener('input', filterTagList);
const saveTimers = {}; const saveTimers = {};
function scheduleSave(tag, rowEl) { function scheduleSave(tag, rowEl) {
clearTimeout(saveTimers[tag.id]); clearTimeout(saveTimers[tag.id]);
@@ -2096,6 +2364,7 @@ $('#addTag').addEventListener('click', async () => {
try { try {
const tag = await api('/api/tags', { method: 'POST', body: { pattern: t('newTag'), color, wholeWord: true, enabled: true } }); const tag = await api('/api/tags', { method: 'POST', body: { pattern: t('newTag'), color, wholeWord: true, enabled: true } });
state.tags.push(tag); state.tags.push(tag);
$('#tagFilter').value = '';
renderTagList(); renderTagList();
applyTags(); applyTags();
const input = $('#tagList').lastElementChild.querySelector('input[type="text"]'); const input = $('#tagList').lastElementChild.querySelector('input[type="text"]');
@@ -2106,14 +2375,15 @@ $('#addTag').addEventListener('click', async () => {
// Adds a preset group, skipping tags whose pattern is already in the list. // Adds a preset group, skipping tags whose pattern is already in the list.
$('#presetTags').addEventListener('change', async (ev) => { $('#presetTags').addEventListener('change', async (ev) => {
const make = PRESETS[ev.target.value]; const preset = state.presets.flatMap((g) => g.presets).find((p) => p.id === ev.target.value);
ev.target.value = ''; ev.target.value = '';
if (!make) return; if (!preset) return;
let added = 0; let added = 0;
try { try {
for (const p of make()) { for (const p of preset.tags) {
if (state.tags.some((x) => x.pattern === p.pattern)) continue; if (state.tags.some((x) => x.pattern === p.pattern)) continue;
state.tags.push(await api('/api/tags', { method: 'POST', body: { regex: true, enabled: true, ...p } })); const tag = { ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true };
state.tags.push(await api('/api/tags', { method: 'POST', body: tag }));
added++; added++;
} }
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
@@ -2134,11 +2404,14 @@ $('#resetTags').addEventListener('click', async () => {
$('#settingsBtn').addEventListener('click', () => { $('#settingsBtn').addEventListener('click', () => {
renderTimeSettings(); renderTimeSettings();
renderTagList(); renderTagList();
loadPresets();
renderInterface(); renderInterface();
loadPurgeStatus(); loadPurgeStatus();
loadSyslog(); loadSyslog();
loadHost(); loadHost();
loadDocker(); loadDocker();
renderDbStats();
loadDbStats();
showSettingsTab(store.get('settingsTab', 'locale')); showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal(); $('#settingsDlg').showModal();
}); });
@@ -2155,14 +2428,40 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
} }
applyLogFont(store.get('logFont', 'system')); applyLogFont(store.get('logFont', 'system'));
applyLogSize(store.get('logSize', 'medium')); applyLogSize(store.get('logSize', 'medium'));
applyLogDensity(store.get('logDensity', 'normal'));
$('#authWarnClose').addEventListener('click', () => {
$('#authWarn').hidden = true;
store.set('authWarnHidden', '1');
});
applyLang(); applyLang();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h'; if (!$('#range').value) $('#range').value = '1h';
$('#severity').value = store.get('severity', ''); $('#severity').value = store.get('severity', '');
try { // Time range mode is kept across reloads
const z = JSON.parse(store.get('period', '') || 'null');
if (z && z.to > z.from) state.zoom = { from: z.from, to: z.to };
} catch { /* stream mode */ }
renderPeriod();
// With a login (local or OIDC), show who is logged in and the log out button. // Without a login, warn that the UI is open to everyone. With a login (local or OIDC),
// show who is logged in and the log out button, and lock the admin settings of a
// read-only account.
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => { fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
if (!me || !me.user) return; if (!me) return;
if (me.mode === 'none' && store.get('authWarnHidden') !== '1') $('#authWarn').hidden = false;
if (me.role === 'viewer') {
document.body.classList.add('read-only');
for (const p of document.querySelectorAll('[data-panel="filters"], [data-panel="sources"], [data-panel="data"]')) {
for (const s of p.querySelectorAll('.set-section')) s.inert = true;
const note = document.createElement('p');
note.className = 'ro-note';
note.dataset.i18n = 'readOnlyNote';
note.textContent = t('readOnlyNote');
p.prepend(note);
}
}
if (!me.user) return;
const btn = $('#logoutBtn'); const btn = $('#logoutBtn');
btn.hidden = false; btn.hidden = false;
btn.dataset.user = me.user; btn.dataset.user = me.user;
+93
View File
@@ -0,0 +1,93 @@
Copyright 2006 The Inconsolata Project Authors (https://github.com/cyrealtype/Inconsolata)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+110
View File
@@ -0,0 +1,110 @@
Copyright (c) 2015-2023, Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
--------------------------
SIL Open Font License v1.1
====================================================
Preamble
----------
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
Definitions
-------------
`"Font Software"` refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
`"Reserved Font Name"` refers to any names specified as such after the
copyright statement(s).
`"Original Version"` refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
`"Modified Version"` refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
`"Author"` refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
Permission & Conditions
------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5. The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
Termination
-----------
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+96
View File
@@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56 -15
View File
@@ -50,6 +50,33 @@
</header> </header>
<section class="filters"> <section class="filters">
<div id="viewMode" class="seg view-mode" role="radiogroup" data-i18n-aria="viewAria">
<button type="button" role="radio" data-view="stream" data-i18n-title="viewStreamTitle">
<!-- Lucide "radio" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
<span data-i18n="viewStream">Stream</span>
</button>
<button type="button" role="radio" data-view="period" data-i18n-title="viewPeriodTitle">
<!-- Lucide "calendar-range" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M3 10h18M8 2v4M17 14h-6M13 18H7M7 14h.01M17 18h.01"/></svg>
<span data-i18n="viewPeriod">Time range</span>
</button>
</div>
<div id="period" class="period" hidden>
<button id="perPrev" class="btn tool" type="button" data-i18n-title="perPrev" data-i18n-aria="perPrev">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<input id="perFrom" type="datetime-local" data-i18n-title="perFrom" data-i18n-aria="perFrom">
<span class="muted" aria-hidden="true">→</span>
<input id="perTo" type="datetime-local" data-i18n-title="perTo" data-i18n-aria="perTo">
<button id="perNext" class="btn tool" type="button" data-i18n-title="perNext" data-i18n-aria="perNext">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
</button>
<button id="perOut" class="btn tool" type="button" data-i18n-title="perOut" data-i18n-aria="perOut">
<!-- Lucide "zoom-out" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3M8 11h6"/></svg>
</button>
</div>
<select id="range" data-i18n-aria="rangeAria"> <select id="range" data-i18n-aria="rangeAria">
<option value="5m" data-i18n="r5m">5 min</option> <option value="5m" data-i18n="r5m">5 min</option>
<option value="15m" data-i18n="r15m">15 min</option> <option value="15m" data-i18n="r15m">15 min</option>
@@ -97,6 +124,12 @@
<section id="histo" class="histo" data-i18n-aria="histoAria" hidden></section> <section id="histo" class="histo" data-i18n-aria="histoAria" hidden></section>
<div id="authWarn" class="auth-warn" role="status" hidden>
<span data-i18n="authOff"></span>
<button id="authWarnClose" 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>
</div>
<div id="error" class="error-banner" hidden></div> <div id="error" class="error-banner" hidden></div>
<button id="newPill" class="pill" type="button" hidden></button> <button id="newPill" class="pill" type="button" hidden></button>
@@ -107,6 +140,7 @@
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Filters</span></span>
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span> <span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
</div> </div>
<main id="list" class="list"></main> <main id="list" class="list"></main>
@@ -182,25 +216,22 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="set-panel" role="tabpanel" data-panel="filters" hidden> <div class="set-panel wide" role="tabpanel" data-panel="filters" hidden>
<section class="set-section"> <section class="set-section tags-section">
<div class="sec-head">
<h3 data-i18n="tagsTitle">Color tags</h3> <h3 data-i18n="tagsTitle">Color tags</h3>
<span id="tagCount" class="muted small"></span>
</div>
<p class="muted small" data-i18n="tagsHelp"></p> <p class="muted small" data-i18n="tagsHelp"></p>
<div id="tagList" class="tag-list"></div> <div class="tag-toolbar">
<footer>
<span class="tag-add">
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button> <button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria"> <select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
<option value="" data-i18n="presetPick">+ Preset…</option> <option value="" data-i18n="presetPick">+ Preset…</option>
<option value="http_status" data-i18n="preset_http_status">HTTP status codes</option>
<option value="http_methods" data-i18n="preset_http_methods">HTTP methods</option>
<option value="http_probes" data-i18n="preset_http_probes">Probes and attacks</option>
<option value="http_bots" data-i18n="preset_http_bots">Bots and scripts</option>
<option value="http_errors" data-i18n="preset_http_errors">TLS/HTTPS and proxy errors</option>
</select> </select>
</span> <input id="tagFilter" type="search" class="dk-filter" autocomplete="off" spellcheck="false" data-i18n-ph="tagFilter" data-i18n-aria="tagFilter">
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button> <button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
</footer> </div>
<div id="tagList" class="tag-list"></div>
</section> </section>
</div> </div>
@@ -234,7 +265,7 @@
<p class="muted small" data-i18n="hostHelp"></p> <p class="muted small" data-i18n="hostHelp"></p>
</section> </section>
<section class="set-section"> <section class="set-section span">
<h3 data-i18n="dockerTitle">Docker containers</h3> <h3 data-i18n="dockerTitle">Docker containers</h3>
<p id="dockerStatus" class="docker-status"></p> <p id="dockerStatus" class="docker-status"></p>
<div id="dockerBody" hidden> <div id="dockerBody" hidden>
@@ -271,7 +302,7 @@
</div> </div>
<p class="muted small hint" data-i18n="themeHelp"></p> <p class="muted small hint" data-i18n="themeHelp"></p>
</section> </section>
<section class="set-section"> <section class="set-section span">
<h3 data-i18n="logDisplay">Log display</h3> <h3 data-i18n="logDisplay">Log display</h3>
<div class="field-grid"> <div class="field-grid">
<span class="lbl" data-i18n="fontSize">Font size</span> <span class="lbl" data-i18n="fontSize">Font size</span>
@@ -281,6 +312,11 @@
<button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button> <button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button>
<button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button> <button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button>
</div> </div>
<span class="lbl" data-i18n="density">Density</span>
<div id="densitySwitch" class="seg" role="radiogroup" data-i18n-aria="density">
<button type="button" role="radio" data-density="normal" data-i18n="densityNormal">Normal</button>
<button type="button" role="radio" data-density="compact" data-i18n="densityCompact">Compact</button>
</div>
<label for="fontSelect" data-i18n="fontLabel">Font</label> <label for="fontSelect" data-i18n="fontLabel">Font</label>
<select id="fontSelect" class="field"></select> <select id="fontSelect" class="field"></select>
</div> </div>
@@ -327,9 +363,14 @@
</div> </div>
<!-- Data --> <!-- Data -->
<div class="set-panel" role="tabpanel" data-panel="data" hidden> <div class="set-panel wide" role="tabpanel" data-panel="data" hidden>
<section class="set-section danger"> <section class="set-section danger">
<h3 data-i18n="dangerZone">Danger zone</h3> <h3 data-i18n="dangerZone">Danger zone</h3>
<div class="db-head">
<span class="lbl" data-i18n="dbTitle">Database</span>
<button id="dbRefresh" class="link-btn" type="button" data-i18n="dbRefresh">Refresh</button>
</div>
<dl id="dbStats" class="db-stats"></dl>
<p class="muted small" data-i18n="purgeHelp"></p> <p class="muted small" data-i18n="purgeHelp"></p>
<div class="purge-row"> <div class="purge-row">
<button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button> <button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button>
+142 -34
View File
@@ -1,3 +1,13 @@
/* Narrow monospace fonts served by LogStream itself, so they also work offline (licences in
web/fonts). Latin subset only; other scripts fall back to --mono. Inconsolata Condensed is
Inconsolata's variable font pinned at width 75 (0.4em per character, others 0.5em). */
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
/* ---------- Theme: everything goes through these variables ---------- */ /* ---------- Theme: everything goes through these variables ---------- */
:root { :root {
--bg: #f6f7f9; --bg: #f6f7f9;
@@ -38,6 +48,8 @@
--tag-warning-text: #111827; --tag-warning-text: #111827;
--log-size: 12.5px; /* Settings > Interface > Font size */ --log-size: 12.5px; /* Settings > Interface > Font size */
--codes-w: 4.9rem; /* tag codes column: room for 3 badges */
--code-bg: #858c97; /* tag code badges */
/* --log-font is set by Settings > Interface > Font (defaults to --mono) */ /* --log-font is set by Settings > Interface > Font (defaults to --mono) */
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -228,6 +240,19 @@ body.busy .progress::after {
font-size: 13px; max-width: 220px; font-size: 13px; max-width: 220px;
} }
.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); } .filters select.set { border-color: var(--accent); background-color: var(--accent-soft); }
/* Display mode: Stream or Time range, then the start → end fields of the range */
.view-mode { padding: 2px; gap: 2px; border-radius: 9px; }
.view-mode button { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; }
.view-mode svg { width: 15px; height: 15px; }
.period { display: inline-flex; align-items: center; gap: 6px; }
.period .btn.tool { width: 32px; padding: 0; justify-content: center; }
.period input {
height: 32px; padding: 0 8px;
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
font-size: 13px; font-variant-numeric: tabular-nums; color-scheme: light dark;
}
.period input:focus { outline: 0; box-shadow: 0 0 0 3px var(--accent-soft); }
.period input.bad { border-color: var(--sev-err); background: color-mix(in srgb, var(--sev-err) 10%, transparent); }
.spacer { flex: 1; } .spacer { flex: 1; }
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; } #count { font-size: 12.5px; font-variant-numeric: tabular-nums; }
@@ -296,13 +321,13 @@ body.busy .progress::after {
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } .list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.list:empty { display: none; } .list:empty { display: none; }
/* Columns: received, message time, severity, host, app, message. Widths come from /* Columns: received, message time, severity, host, app, tag codes, message. Widths come from
--col-* (set on #table when a column has been resized, see app.js), shared by --col-* (set on #table when a column has been resized, see app.js), shared by
the header and every row through subgrid so that the columns line up. */ the header and every row through subgrid so that the columns line up. */
.table { .table {
display: grid; display: grid;
grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem) grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) minmax(0, 1fr); var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) var(--codes-w) minmax(0, 1fr);
column-gap: 12px; column-gap: 12px;
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */ overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */
@@ -331,7 +356,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.row { .row {
display: grid; display: grid;
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr; grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) var(--codes-w) 1fr;
gap: 0 12px; align-items: baseline; gap: 0 12px; align-items: baseline;
padding: 5px 14px 5px 11px; padding: 5px 14px 5px 11px;
border-left: 3px solid transparent; border-left: 3px solid transparent;
@@ -341,6 +366,10 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
cursor: pointer; cursor: pointer;
} }
.row:last-child { border-bottom: 0; } .row:last-child { border-bottom: 0; }
/* Settings > Interface > Density: compact fits about 50% more lines on screen */
:root[data-density="compact"] .row { padding-top: 1px; padding-bottom: 1px; line-height: 1.25; }
:root[data-density="compact"] .row .sev { padding-top: 0; padding-bottom: 0; line-height: 1.3; }
:root[data-density="compact"] .row .codes b { padding-top: 0; padding-bottom: 0; line-height: 1.2; }
.row:hover { background: var(--row-hover); } .row:hover { background: var(--row-hover); }
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.row.new { animation: flash 1.2s ease-out; } .row.new { animation: flash 1.2s ease-out; }
@@ -372,6 +401,17 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); } .row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); } .row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
.row .host[data-act], .row .app[data-act] { cursor: pointer; } .row .host[data-act], .row .app[data-act] { cursor: pointer; }
/* Codes of the tags found in the message: grey badges, fixed size */
.row .codes { white-space: nowrap; overflow: hidden; }
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
.row .codes b, .tag-code {
min-width: 2.2ch; padding: 1px 4px; border-radius: 5px; text-align: center;
/* same font as the severity badges (the log font) */
font-family: var(--log-font, var(--mono)); font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
line-height: 1.35; font-variant-numeric: tabular-nums;
background: var(--code-bg); color: #0b0f17;
}
.row .codes b.more { background: none; box-shadow: inset 0 0 0 1px var(--code-bg); color: var(--muted); }
.row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
mark.tag { mark.tag {
@@ -406,6 +446,22 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap;
} }
.auth-warn {
display: flex; align-items: center; gap: 10px;
margin: 6px 20px 0; padding: 6px 8px 6px 14px;
border: 1px solid color-mix(in srgb, var(--sev-warning) 45%, transparent);
background: color-mix(in srgb, var(--sev-warning) 12%, transparent);
color: var(--text); border-radius: var(--radius); font-size: 12.5px;
}
.auth-warn[hidden] { display: none; }
.auth-warn span { flex: 1; }
.auth-warn .icon-btn { width: 26px; height: 26px; flex: none; }
.ro-note {
margin: 0 0 12px; padding: 8px 12px; border-radius: var(--radius); font-size: 12.5px;
background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text);
}
.read-only .set-panel .set-section[inert] { opacity: .55; }
.pill { .pill {
position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30; position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30;
border: 0; border-radius: 999px; padding: 7px 16px; border: 0; border-radius: 999px; padding: 7px 16px;
@@ -425,6 +481,7 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
.conn.ok::before { color: var(--sev-info); } .conn.ok::before { color: var(--sev-info); }
.conn.ko::before { color: var(--sev-err); } .conn.ko::before { color: var(--sev-err); }
#stats .bad { color: var(--sev-err); } #stats .bad { color: var(--sev-err); }
#stats .warn { color: var(--sev-warning); }
/* "Back to top", at the right end of the status bar: never over a log row */ /* "Back to top", at the right end of the status bar: never over a log row */
.to-top { width: 24px; height: 24px; margin: -5px -6px -5px auto; flex: none; } /* no taller bar */ .to-top { width: 24px; height: 24px; margin: -5px -6px -5px auto; flex: none; } /* no taller bar */
.to-top svg { width: 16px; height: 16px; } .to-top svg { width: 16px; height: 16px; }
@@ -438,12 +495,12 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
/* ---------- Settings dialog ---------- */ /* ---------- Settings dialog ---------- */
dialog.settings { dialog.settings {
width: min(900px, calc(100vw - 32px)); width: min(1280px, calc(100vw - 32px));
height: min(660px, calc(100vh - 64px)); max-height: none; height: calc(100vh - 48px); max-height: 960px;
} }
dialog.settings .dlg { display: flex; flex-direction: column; height: 100%; padding: 0; } dialog.settings .dlg { display: flex; flex-direction: column; height: 100%; padding: 0; }
dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px solid var(--border); } dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px solid var(--border); }
.set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px minmax(0, 1fr); } .set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 176px minmax(0, 1fr); }
.set-nav { .set-nav {
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
padding: 12px 10px; border-right: 1px solid var(--border); padding: 12px 10px; border-right: 1px solid var(--border);
@@ -458,8 +515,19 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
.set-nav button:hover { background: var(--panel-2); color: var(--text); } .set-nav button:hover { background: var(--panel-2); color: var(--text); }
.set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); } .set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); }
.set-nav svg { width: 18px; height: 18px; flex: none; } .set-nav svg { width: 18px; height: 18px; flex: none; }
.set-panels { overflow-y: auto; padding: 2px 24px 22px; } .set-panels { overflow-y: auto; padding: 16px 20px 20px; }
.set-panel > .set-section:first-child { margin-top: 16px; } /* Sections are cards laid out in columns when there is room; .span ones take the full width. */
.set-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 14px; align-items: start; grid-auto-flow: row dense; }
.set-panel[hidden] { display: none; }
.set-panel.wide { grid-template-columns: minmax(0, 1fr); }
.set-panel > .set-section, .set-panel > .set-section + .set-section {
margin: 0; padding: 14px 16px; min-width: 0;
border: 1px solid var(--border); border-radius: 12px;
background: color-mix(in srgb, var(--panel-2) 35%, var(--panel));
}
.set-panel > .set-section.span { grid-column: 1 / -1; }
.set-panel > .ro-note { grid-column: 1 / -1; margin: 0; }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.hint { margin-top: 8px !important; } .hint { margin-top: 8px !important; }
.lbl { font-size: 13px; font-weight: 550; } .lbl { font-size: 13px; font-weight: 550; }
.set-panel .preview-list { margin: 14px 0 10px; pointer-events: none; } .set-panel .preview-list { margin: 14px 0 10px; pointer-events: none; }
@@ -475,6 +543,7 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
.preview-list .row .host { grid-area: host; } .preview-list .row .host { grid-area: host; }
.preview-list .row .app { grid-area: app; display: block; } .preview-list .row .app { grid-area: app; display: block; }
.preview-list .row .msg { grid-area: msg; } .preview-list .row .msg { grid-area: msg; }
.preview-list .row .codes { display: none; }
dialog { dialog {
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px); width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
@@ -503,6 +572,17 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
background: var(--panel-2); font-family: var(--mono); font-size: 12.5px; background: var(--panel-2); font-family: var(--mono); font-size: 12.5px;
} }
.set-section.danger h3 { color: var(--sev-err); } .set-section.danger h3 { color: var(--sev-err); }
.db-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin: 4px 0 6px; }
.db-stats {
display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 14px;
margin: 0 0 14px; padding: 10px 12px; border-radius: 8px; background: var(--panel-2); font-size: 12.5px;
}
.db-stats dt { color: var(--muted); }
.db-stats dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.db-stats dd b { font-weight: 600; font-variant-numeric: tabular-nums; }
.db-msg { grid-column: 1 / -1; color: var(--muted); }
.db-msg.bad { color: var(--sev-err); }
.link-btn:disabled { opacity: .5; cursor: default; text-decoration: none; }
.purge-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; } .purge-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
.btn.danger { color: var(--sev-err); border-color: color-mix(in srgb, var(--sev-err) 45%, var(--border)); } .btn.danger { color: var(--sev-err); border-color: color-mix(in srgb, var(--sev-err) 45%, var(--border)); }
.btn.danger:hover:not(:disabled) { background: var(--sev-err); border-color: var(--sev-err); color: #fff; } .btn.danger:hover:not(:disabled) { background: var(--sev-err); border-color: var(--sev-err); color: #fff; }
@@ -577,9 +657,19 @@ input.switch:checked::after { transform: translateX(14px); }
input.switch:disabled { cursor: not-allowed; } input.switch:disabled { cursor: not-allowed; }
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; } /* Settings > Filters: toolbar kept in view while the list scrolls, dense rows. */
.tag-add { display: flex; flex-wrap: wrap; gap: 8px; } .tags-section { padding-top: 12px !important; }
.tag-add select.field { width: auto; } .tag-toolbar {
position: sticky; top: -16px; z-index: 2;
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
margin: 10px -16px 0; padding: 8px 16px;
background: color-mix(in srgb, var(--panel-2) 35%, var(--panel));
border-bottom: 1px solid var(--border);
}
.tag-toolbar .btn { height: 30px; }
.tag-toolbar select.field { width: auto; height: 30px; }
.tag-toolbar .dk-filter { flex: 1 1 220px; height: 30px; }
.tag-toolbar #resetTags { margin-left: auto; }
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; } .seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
.seg button { .seg button {
@@ -589,33 +679,38 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.seg button:hover { color: var(--text); } .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); } .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; } /* One wide column: long regular expressions stay readable. */
.tag-list { display: flex; flex-direction: column; gap: 3px; margin-top: 10px; }
.tag-row { .tag-row {
display: grid; align-items: center; gap: 8px 10px; display: grid; align-items: center; gap: 4px 6px;
grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px; grid-template-columns: 2.4em 30px minmax(7rem, 1fr) 10rem auto 26px;
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px; padding: 3px 4px 3px 6px; border: 1px solid var(--border); border-radius: 8px;
background: var(--panel-2); background: var(--panel);
} }
.tag-row[hidden] { display: none; }
.tag-row.off { opacity: .55; } .tag-row.off { opacity: .55; }
.tag-row .tag-code { font-size: 11px; padding: 2px 4px; cursor: default; text-align: center; }
.tag-row input[type="color"] { .tag-row input[type="color"] {
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px; width: 30px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: 6px;
background: none; cursor: pointer; background: none; cursor: pointer;
} }
.tag-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 3px; } .tag-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
.tag-row input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 5px; } .tag-row input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 5px; }
.tag-row input[type="text"] { .tag-row input[type="text"] {
height: 32px; padding: 0 10px; min-width: 0; height: 26px; padding: 0 8px; min-width: 0;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; background: var(--panel);
font-family: var(--mono); font-size: 13px; outline: 0; font-family: var(--mono); font-size: 12.5px; outline: 0;
} }
.tag-row input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .tag-row input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.tag-row .preview { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; } .tag-row .preview { font-family: var(--mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
.tag-row .opts { display: flex; gap: 4px; } .tag-row .opts { display: flex; gap: 3px; }
.opt { .opt {
display: inline-flex; align-items: center; height: 28px; padding: 0 8px; display: inline-flex; align-items: center; height: 24px; padding: 0 6px;
border: 1px solid var(--border); border-radius: 7px; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; background: var(--panel);
font-size: 11.5px; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none; font-size: 11px; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none;
} }
.tag-row .icon-btn { width: 26px; height: 26px; border-radius: 6px; }
.tag-row .icon-btn svg { width: 15px; height: 15px; }
.opt input { display: none; } .opt input { display: none; }
.opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } .opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; } .tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; }
@@ -629,18 +724,25 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.live .lbl { display: none; } .live .lbl { display: none; }
.filters { padding: 10px 16px 6px; } .filters { padding: 10px 16px 6px; }
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; } .filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; }
.view-mode { flex: 1 1 100%; }
.view-mode button { flex: 1; justify-content: center; }
/* Phones: the two dates on one line, the ◀ ▶ − buttons below */
.period { flex: 1 1 100%; flex-wrap: wrap; }
.period > span { display: none; }
.period input { flex: 1 1 calc(50% - 3px); min-width: 0; }
.period .btn.tool { order: 1; flex: 1; }
.spacer { display: none; } .spacer { display: none; }
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.histo { padding: 0 16px 6px; } .histo { padding: 0 16px 6px; }
.h-leg { display: none; } .h-leg { display: none; }
.list, .error-banner, .table { margin-left: 16px; margin-right: 16px; } .list, .error-banner, .auth-warn, .table { margin-left: 16px; margin-right: 16px; }
/* Phones: no columns, two lines per log (layout below); the widths do not apply */ /* Phones: no columns, two lines per log (layout below); the widths do not apply */
.table { display: block; } .table { display: block; }
.table .list { display: block; margin: 0; } .table .list { display: block; margin: 0; }
.list-head { display: none; } .list-head { display: none; }
.row, .table .row { .row, .table .row {
grid-template-columns: auto auto 1fr; grid-template-columns: auto auto 1fr auto;
grid-template-areas: "rcv sev host" "msg msg msg"; grid-template-areas: "rcv sev host codes" "msg msg msg msg";
gap: 3px 8px; padding: 8px 12px 8px 10px; gap: 3px 8px; padding: 8px 12px 8px 10px;
} }
.row time.rcv { grid-area: rcv; } .row time.rcv { grid-area: rcv; }
@@ -648,6 +750,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.row .sev { grid-area: sev; } .row .sev { grid-area: sev; }
.row .host { grid-area: host; justify-self: end; max-width: 100%; } .row .host { grid-area: host; justify-self: end; max-width: 100%; }
.row .app { display: none; } .row .app { display: none; }
.row .codes { grid-area: codes; }
.row .msg { grid-area: msg; } .row .msg { grid-area: msg; }
.details { grid-column: 1 / -1; } .details { grid-column: 1 / -1; }
.details dl { grid-template-columns: 1fr; } .details dl { grid-template-columns: 1fr; }
@@ -664,15 +767,20 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
font-size: 11.5px; text-align: center; font-size: 11.5px; text-align: center;
} }
.set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } .set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.set-panels { padding: 0 16px 18px; } .set-panels { padding: 12px 12px 18px; }
#sizeSwitch { display: flex; } .set-panel { grid-template-columns: minmax(0, 1fr); gap: 10px; }
#sizeSwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; } .set-panel > .set-section, .set-panel > .set-section + .set-section { padding: 12px; }
.tag-toolbar { position: static; margin: 10px -12px 0; padding: 8px 12px; }
.tag-toolbar #resetTags { margin-left: 0; }
#sizeSwitch, #densitySwitch { display: flex; }
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
:root[data-density="compact"] .row { padding-top: 3px; padding-bottom: 3px; }
.field-grid select { margin-bottom: 6px; } .field-grid select { margin-bottom: 6px; }
.status { padding: 6px 16px; } .status { padding: 6px 16px; }
.tag-row { grid-template-columns: 38px 1fr 36px; } .tag-row { grid-template-columns: auto 30px 1fr 26px; }
.tag-row .preview { display: none; } .tag-row .preview { display: none; }
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; } .tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
.tag-row [data-del] { grid-column: 3; grid-row: 1; } .tag-row [data-del] { grid-column: 4; grid-row: 1; }
} }
/* ---------- Login page (AUTH_MODE=local) ---------- */ /* ---------- Login page (AUTH_MODE=local) ---------- */