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
24 changed files with 1997 additions and 210 deletions

No files matched your search

+8 -1
View File
@@ -9,6 +9,9 @@ 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=
@@ -28,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)
+53 -15
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 à
@@ -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.
@@ -257,11 +270,15 @@ couleur, est mémorisé par navigateur.
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. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed. 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
@@ -270,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.
@@ -280,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` :
@@ -320,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),
@@ -342,15 +374,20 @@ 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)) | | `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 |
@@ -361,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 :
+49 -12
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.
@@ -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.
@@ -233,10 +247,13 @@ remembered per browser.
[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 internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as
typed. The densest setting is Tiny + Compact + Inconsolata Condensed. typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
- **Data**: "Delete all logs" permanently erases every stored log (you must type - **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
@@ -244,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.
@@ -254,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
@@ -291,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
@@ -311,15 +341,20 @@ 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)) | | `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 |
@@ -330,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
+13
View File
@@ -23,6 +23,7 @@ type API struct {
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) {
@@ -32,6 +33,7 @@ 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)
@@ -314,6 +316,17 @@ 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())
} }
+53 -17
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,
@@ -157,8 +164,9 @@ 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)
}
}
+5 -1
View File
@@ -17,6 +17,8 @@ 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) PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md)
OIDC_ISSUER: ${OIDC_ISSUER:-} OIDC_ISSUER: ${OIDC_ISSUER:-}
@@ -24,10 +26,12 @@ services:
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
+11 -6
View File
@@ -61,8 +61,8 @@ type DockerManager struct {
cfg dockerConfig cfg dockerConfig
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) },
}) })
} }
+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,
}) })
} }
+45 -9
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.
e.HostIP, e.Host = e.Host, name rdns.Resolve(e.Host, 300*time.Millisecond, func(name string) {
} if name != "" {
store.Enqueue(e) e.HostIP, e.Host = e.Host, name
hub.Publish(e) }
store.Enqueue(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)
@@ -161,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 },
} }
+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
}
+187 -45
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
batchSize int quit chan struct{} // closed on shutdown: unblocks waiting producers
flushEvery time.Duration batchSize int
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,29 +36,42 @@ 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},
// No global timeout: a large export can take longer than a minute. The // No global timeout: a large export can take longer than a minute. The
// 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),
batchSize: batchSize, quit: make(chan struct{}),
flushEvery: flushEvery, batchSize: batchSize,
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,20 +80,16 @@ 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)
batch = batch[:0]
} }
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]
} }
for { for {
@@ -83,20 +97,24 @@ func (s *Store) Run(ctx context.Context) {
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,10 +266,32 @@ 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
// handleTCP supports both RFC 6587 framings: octet counting // handleTCP supports both RFC 6587 framings: octet counting
@@ -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 {
+270 -28
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',
@@ -49,6 +50,9 @@ const I18N = {
confirmDelete: (p) => `Delete tag "${p}"?`, confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error)?', confirmReset: 'Replace all tags with the defaults (warning, error)?',
presetAria: 'Add a preset', presetPick: '+ Preset…', presetAria: 'Add a preset', presetPick: '+ Preset…',
tagFilter: 'Filter tags (keyword, label or code)',
tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total === 1 ? '' : 's'}` : `${n.shown} / ${n.total} tags`),
noTagMatch: 'No tag matches the filter.',
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: `, presetsFileErr: (f) => `Invalid presets file ${f}, built-in presets used: `,
tagsLoadErr: 'Tags: ', tagsLoadErr: 'Tags: ',
@@ -122,6 +126,16 @@ const I18N = {
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.', 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:',
@@ -131,21 +145,30 @@ 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', colCodes: 'Filters', 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', codesTitle: 'Codes of the color tags found in the message',
@@ -184,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',
@@ -200,6 +224,9 @@ const I18N = {
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) ?', 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…',
tagFilter: 'Filtrer les tags (mot-clé, libellé ou code)',
tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total > 1 ? 's' : ''}` : `${n.shown} / ${n.total} tags`),
noTagMatch: 'Aucun tag ne correspond au filtre.',
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 : `, presetsFileErr: (f) => `Fichier de préréglages ${f} invalide, préréglages intégrés utilisés : `,
tagsLoadErr: 'Tags : ', tagsLoadErr: 'Tags : ',
@@ -273,6 +300,16 @@ const I18N = {
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.', 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,21 +319,30 @@ 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', colCodes: 'Filtres', 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', codesTitle: 'Codes des tags de couleur trouvés dans le message',
@@ -457,7 +503,7 @@ const state = {
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,
@@ -486,13 +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(); renderPresets();
renderTimeSettings(); renderTimeSettings();
renderPurge(); renderPurge();
renderDbStats();
renderInterface(); renderInterface();
renderSyslog(); renderSyslog();
renderHost(); renderHost();
@@ -1398,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>'
@@ -1546,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();
@@ -1664,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;
} }
@@ -1712,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);
@@ -1993,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 };
@@ -2022,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 */ }
@@ -2098,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]);
@@ -2150,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"]');
@@ -2195,6 +2410,8 @@ $('#settingsBtn').addEventListener('click', () => {
loadSyslog(); loadSyslog();
loadHost(); loadHost();
loadDocker(); loadDocker();
renderDbStats();
loadDbStats();
showSettingsTab(store.get('settingsTab', 'locale')); showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal(); $('#settingsDlg').showModal();
}); });
@@ -2212,14 +2429,39 @@ $('#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')); 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;
+55 -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>
@@ -183,20 +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">
<h3 data-i18n="tagsTitle">Color tags</h3> <div class="sec-head">
<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> <button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
<span class="tag-add"> <select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button> <option value="" data-i18n="presetPick">+ Preset…</option>
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria"> </select>
<option value="" data-i18n="presetPick">+ Preset…</option> <input id="tagFilter" type="search" class="dk-filter" autocomplete="off" spellcheck="false" data-i18n-ph="tagFilter" data-i18n-aria="tagFilter">
</select>
</span>
<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>
@@ -230,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>
@@ -267,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>
@@ -328,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>
+104 -27
View File
@@ -240,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; }
@@ -433,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;
@@ -452,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; }
@@ -465,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);
@@ -485,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; }
@@ -531,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; }
@@ -605,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 {
@@ -617,34 +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: auto 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: 12px; padding: 3px 6px; cursor: default; } .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; }
@@ -658,11 +724,18 @@ 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; }
@@ -694,13 +767,17 @@ 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; }
.set-panel { grid-template-columns: minmax(0, 1fr); gap: 10px; }
.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, #densitySwitch { display: flex; }
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; } #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; } :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: auto 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: 4; grid-row: 1; } .tag-row [data-del] { grid-column: 4; grid-row: 1; }