Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c25b1e4e2 | ||
|
|
688a7dc2e6 | ||
|
|
4225a2c870 | ||
|
|
1adb25e403 | ||
|
|
7c26d0128a | ||
|
|
64c21b1f70 | ||
|
|
236c936a9d | ||
|
|
8228bc140f | ||
|
|
9ea1371696 | ||
|
|
974c20e45c | ||
|
|
c664f1eaaf | ||
|
|
1a21656546 | ||
|
|
be58284916 | ||
|
|
7b139e8931 | ||
|
|
19ed16ac12 | ||
|
|
7aebb1120f | ||
|
|
ab38a54d54 |
No files matched your search
+12
-3
@@ -4,11 +4,21 @@ HTTP_PORT=8080
|
|||||||
TZ=Europe/Paris
|
TZ=Europe/Paris
|
||||||
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
||||||
RETENTION=30d
|
RETENTION=30d
|
||||||
# Web UI authentication: local (HTTP Basic below, or none) or oidc (OpenID Connect provider)
|
# Web UI authentication: local (login page with the account below, or none) or oidc (OpenID Connect provider)
|
||||||
AUTH_MODE=local
|
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: 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
|
||||||
|
LOGIN_LOGO=
|
||||||
|
|
||||||
|
# Ready-made color tags offered in Settings > Filters (see docs/presets.md).
|
||||||
|
# Empty: /data/presets.json if present, else the built-in list. To use your own file,
|
||||||
|
# mount it in docker-compose.yml, e.g. ./presets.json:/config/presets.json:ro
|
||||||
|
PRESETS_FILE=
|
||||||
|
# Session lifetime, both modes (e.g. 8h, 24h)
|
||||||
|
SESSION_TTL=12h
|
||||||
# oidc mode: issuer URL exactly as the provider announces it
|
# oidc mode: issuer URL exactly as the provider announces it
|
||||||
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
|
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
|
||||||
OIDC_ISSUER=
|
OIDC_ISSUER=
|
||||||
@@ -16,9 +26,8 @@ OIDC_CLIENT_ID=
|
|||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended)
|
# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended)
|
||||||
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
|
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
|
||||||
# Requested scopes (openid is always added) and session lifetime (e.g. 8h, 24h)
|
# Requested scopes (openid is always added)
|
||||||
OIDC_SCOPES=openid profile email
|
OIDC_SCOPES=openid profile email
|
||||||
OIDC_SESSION_TTL=12h
|
|
||||||
# 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
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
FROM golang:1.27.1-alpine3.24 AS build
|
FROM golang:1.27.1-alpine3.24 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod ./
|
COPY go.mod ./
|
||||||
COPY *.go ./
|
COPY *.go presets.json ./
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream .
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream .
|
||||||
|
|
||||||
|
|||||||
+56
-14
@@ -78,8 +78,8 @@ par exemple `error AND host:web-01`, `app:~"ssh|nginx"` ou `* | stats by (host)
|
|||||||
Le direct est désactivé dans ce mode.
|
Le direct est désactivé dans ce mode.
|
||||||
|
|
||||||
Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur),
|
Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur),
|
||||||
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application
|
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application,
|
||||||
et le message. Un clic sur un hôte ou une application filtre dessus.
|
les codes des tags trouvés et le message. Un clic sur un hôte ou une application filtre dessus.
|
||||||
|
|
||||||
Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont
|
Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont
|
||||||
l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même
|
l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même
|
||||||
@@ -225,20 +225,38 @@ couleur, est mémorisé par navigateur.
|
|||||||
automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, respect
|
automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, respect
|
||||||
de la casse, expression régulière, actif. Les tags sont stockés sur le serveur dans
|
de la casse, expression régulière, actif. Les tags sont stockés sur le serveur dans
|
||||||
`/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs.
|
`/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs.
|
||||||
Tags par défaut (pastel) : `warning` (orange), `error` (rouge), `ok` (vert). Les tags par
|
Tags par défaut (pastel) : `warning` (orange) et `error` (rouge) ; `ok` (vert) est dans le
|
||||||
|
préréglage *Niveaux de log*. Les tags par
|
||||||
défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux
|
défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux
|
||||||
couleurs pastel.
|
couleurs pastel.
|
||||||
|
Le menu *+ Préréglage…* ajoute des tags tout faits : logs d'accès HTTP/HTTPS (codes de statut,
|
||||||
|
méthodes, sondes, robots, erreurs TLS et proxy), logs système (SSH, sudo, noyau, systemd,
|
||||||
|
pare-feu), applications (Docker, bases de données) et motifs généraux (niveaux de log,
|
||||||
|
adresses IPv4). Les tags déjà présents ne sont pas ajoutés en double, et les tags ajoutés se
|
||||||
|
modifient comme les autres. La liste vient d'un fichier texte modifiable (`PRESETS_FILE`) :
|
||||||
|
voir [docs/presets.fr.md](docs/presets.fr.md) pour le détail de chaque préréglage et le
|
||||||
|
format du fichier. Dans une expression
|
||||||
|
régulière, un groupe nommé `hl` (`(?<hl>…)`) ne colore que cette partie de la correspondance :
|
||||||
|
les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour.
|
||||||
|
Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste
|
||||||
|
attaché au tag jusqu'à sa suppression (les tags créés par les versions précédentes en reçoivent
|
||||||
|
un aussi). La colonne *Filtres* de la liste affiche, en badges gris, les codes des tags actifs
|
||||||
|
trouvés dans chaque message ; elle a la place pour 3, au-delà elle en affiche 2 et `+N`, et
|
||||||
|
l'infobulle les liste tous.
|
||||||
- **Interface**
|
- **Interface**
|
||||||
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
|
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
|
||||||
bouton soleil/lune de l'en-tête bascule entre clair et sombre.
|
bouton soleil/lune de l'en-tête bascule entre clair et sombre.
|
||||||
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande) et police :
|
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande), densité
|
||||||
la police monospace du système, ou l'une des 12 polices libres conçues pour le texte dense
|
(normale, ou compacte pour afficher environ 50 % de lignes en plus à l'écran) et police :
|
||||||
|
la police monospace du système, l'une des 3 polices étroites intégrées, servies par
|
||||||
|
LogStream lui-même et utilisables hors ligne (Inconsolata Condensed, la plus étroite,
|
||||||
|
Iosevka et Ubuntu Mono), ou l'une des 11 polices libres conçues pour le texte dense
|
||||||
(JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono,
|
(JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono,
|
||||||
Ubuntu Mono, Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). Elles sont
|
Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). Ces 11 polices sont
|
||||||
chargées par le navigateur depuis [Bunny Fonts](https://fonts.bunny.net), un service
|
chargées par le navigateur depuis [Bunny Fonts](https://fonts.bunny.net), un service
|
||||||
européen de polices respectueux de la vie privée ; sans accès à internet, la police du
|
européen de polices respectueux de la vie privée ; sans accès à internet, la police du
|
||||||
système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels
|
système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels
|
||||||
quels.
|
quels. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed.
|
||||||
- **Données** : « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
|
- **Données** : « 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`) ; mettez
|
||||||
@@ -250,11 +268,31 @@ couleur, est mémorisé par navigateur.
|
|||||||
|
|
||||||
`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) :
|
`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) :
|
||||||
|
|
||||||
- **`local`** (par défaut) : authentification HTTP Basic avec `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à).
|
||||||
- **`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.
|
||||||
|
|
||||||
|
En mode `local`, la page de connexion suit le thème et la langue de l'interface. La session dure
|
||||||
|
`SESSION_TTL` (12 h par défaut), survit aux redémarrages (sa clé de signature est dans
|
||||||
|
`/data/session.key`) et se termine quand `AUTH_USER` ou `AUTH_PASS` change ; le bouton de
|
||||||
|
déconnexion (en haut à droite) y met fin. Les échecs de connexion sont écrits dans les logs avec
|
||||||
|
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`).
|
||||||
|
|
||||||
|
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
|
||||||
|
son chemin dans `LOGIN_LOGO` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml, service logstream
|
||||||
|
volumes:
|
||||||
|
- ./logo.png:/config/logo.png:ro
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
LOGIN_LOGO=/config/logo.png
|
||||||
|
```
|
||||||
|
|
||||||
Pour utiliser OIDC :
|
Pour utiliser OIDC :
|
||||||
|
|
||||||
1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez
|
1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez
|
||||||
@@ -271,7 +309,7 @@ Pour utiliser OIDC :
|
|||||||
laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…).
|
laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…).
|
||||||
|
|
||||||
Ouvrir l'interface renvoie vers la page de connexion du fournisseur, puis revient sur logstream.
|
Ouvrir l'interface renvoie vers la page de connexion du fournisseur, puis revient sur logstream.
|
||||||
La session dure `OIDC_SESSION_TTL` (12 h par défaut) et survit aux redémarrages (sa clé de
|
La session dure `SESSION_TTL` (12 h par défaut) et survit aux redémarrages (sa clé de
|
||||||
signature est dans `/data/session.key`) ; à son expiration, la page repasse par la connexion. Le
|
signature est dans `/data/session.key`) ; à son expiration, la page repasse par la connexion. Le
|
||||||
bouton de déconnexion (en haut à droite) termine la session logstream, puis ouvre la page de
|
bouton de déconnexion (en haut à droite) termine la session logstream, puis ouvre la page de
|
||||||
déconnexion du fournisseur s'il en a une.
|
déconnexion du fournisseur s'il en a une.
|
||||||
@@ -302,17 +340,19 @@ résolutions.
|
|||||||
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
|
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
|
||||||
| `HTTP_PORT` | `8080` | port de l'interface web |
|
| `HTTP_PORT` | `8080` | port de l'interface web |
|
||||||
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
||||||
| `AUTH_MODE` | `local` | `local` (HTTP Basic) ou `oidc`, voir [Authentification](#authentification) |
|
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
|
||||||
| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface (mode `local`) |
|
| `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification |
|
||||||
|
| `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) |
|
||||||
| `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_SESSION_TTL` | `12h` | durée de la session |
|
|
||||||
| `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` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres |
|
||||||
| `EXPORT_MAX` | `100000` | nombre maximal de lignes dans un export CSV |
|
| `EXPORT_MAX` | `100000` | nombre maximal de lignes dans un export CSV |
|
||||||
|
| `PRESETS_FILE` | `/data/presets.json` | fichier des préréglages de tags ; liste intégrée s'il est absent (voir [docs/presets.fr.md](docs/presets.fr.md)) |
|
||||||
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux |
|
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux |
|
||||||
| `DOCKER_HOST` | `tcp://docker-proxy:2375` dans compose | adresse de l'API Docker (`unix:///var/run/docker.sock` hors compose) |
|
| `DOCKER_HOST` | `tcp://docker-proxy:2375` dans compose | adresse de l'API Docker (`unix:///var/run/docker.sock` hors compose) |
|
||||||
| `DOCKER_BACKFILL` | `1h` | historique lu pour un conteneur vu pour la première fois |
|
| `DOCKER_BACKFILL` | `1h` | historique lu pour un conteneur vu pour la première fois |
|
||||||
@@ -366,7 +406,8 @@ Pour mettre à jour l'une d'elles :
|
|||||||
| Fichier | Contenu |
|
| Fichier | Contenu |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.go` | configuration, démarrage |
|
| `main.go` | configuration, démarrage |
|
||||||
| `auth.go` | authentification : HTTP Basic ou OpenID Connect (découverte, PKCE, contrôle de l'ID token, cookie de session) |
|
| `auth.go` | authentification : OpenID Connect (découverte, PKCE, contrôle de l'ID token) et cookie de session signé |
|
||||||
|
| `auth_local.go` | mode `local` : page de connexion (`web/login.html`), cookie de session, `LOGIN_LOGO` |
|
||||||
| `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 |
|
| `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 |
|
||||||
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
|
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
|
||||||
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
|
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
|
||||||
@@ -378,8 +419,9 @@ Pour mettre à jour l'une d'elles :
|
|||||||
| `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources |
|
| `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources |
|
||||||
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
|
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
|
||||||
| `tags.go` | stockage des tags de couleur |
|
| `tags.go` | stockage des tags de couleur |
|
||||||
|
| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée |
|
||||||
| `api.go` | routes HTTP `/api/*` |
|
| `api.go` | routes HTTP `/api/*` |
|
||||||
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`) |
|
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion |
|
||||||
|
|
||||||
## Remarque
|
## Remarque
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ message, host and app. Words are combined with AND.
|
|||||||
The live view is disabled in this mode.
|
The live view is disabled in this mode.
|
||||||
|
|
||||||
Each row shows, from left to right: the **reception time** (server clock), the timestamp
|
Each row shows, from left to right: the **reception time** (server clock), the timestamp
|
||||||
found in the message itself (`msg_time`), severity, host, app and message. Click a host or an
|
found in the message itself (`msg_time`), severity, host, app, codes of the tags found and message. Click a host or an
|
||||||
app to filter on it.
|
app to filter on it.
|
||||||
|
|
||||||
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
|
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
|
||||||
@@ -205,17 +205,34 @@ remembered per browser.
|
|||||||
switches to black or white to stay readable) and options: whole word, match case,
|
switches to black or white to stay readable) and options: whole word, match case,
|
||||||
regular expression, active. Tags are stored on the server in `/data/tags.json`
|
regular expression, active. Tags are stored on the server in `/data/tags.json`
|
||||||
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
|
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
|
||||||
`warning` (orange), `error` (red), `ok` (green). Default tags still using the colors of
|
`warning` (orange) and `error` (red); `ok` (green) is in the *Log levels* preset. Default
|
||||||
earlier versions are switched to the pastel ones automatically.
|
tags still using the colors of earlier versions are switched to the pastel ones
|
||||||
|
automatically.
|
||||||
|
The *+ Preset…* menu adds ready-made tags: HTTP/HTTPS access logs (status codes, methods,
|
||||||
|
probes, bots, TLS and proxy errors), system logs (SSH, sudo, kernel, systemd, firewall),
|
||||||
|
applications (Docker, databases) and general patterns (log levels, IPv4 addresses). Tags
|
||||||
|
already in the list are skipped, and the added tags can be edited like any other. The list
|
||||||
|
comes from a text file you can edit (`PRESETS_FILE`): see [docs/presets.md](docs/presets.md)
|
||||||
|
for each preset and the file format. In a regular expression, a group named `hl`
|
||||||
|
(`(?<hl>…)`) colors only that part of the match: the presets use it to color the status code
|
||||||
|
or the method, not the text around it.
|
||||||
|
Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag
|
||||||
|
until the tag is deleted (codes are also given to tags created by earlier versions). The
|
||||||
|
*Filters* column of the log list shows, as grey badges, the codes of the active tags found in
|
||||||
|
each message; it has room for 3, beyond that it shows 2 and `+N`, and the tooltip lists them
|
||||||
|
all.
|
||||||
- **Interface**
|
- **Interface**
|
||||||
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
|
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
|
||||||
button in the header switches between light and dark.
|
button in the header switches between light and dark.
|
||||||
- *Log display*: font size (tiny, small, medium, large) and font: the system monospace
|
- *Log display*: font size (tiny, small, medium, large), density (normal, or compact to
|
||||||
font, or one of 12 free fonts made for dense text (JetBrains Mono, Fira Code, Source
|
fit about 50% more lines on screen) and font: the system monospace font, one of 3 narrow
|
||||||
Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat
|
built-in fonts served by LogStream itself, which work offline (Inconsolata Condensed, the
|
||||||
Mono, Noto Sans Mono, Victor Mono, DM Mono). They are loaded by the browser from
|
narrowest, Iosevka and Ubuntu Mono), or one of 11 free fonts made for dense text (JetBrains
|
||||||
|
Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Inconsolata,
|
||||||
|
Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). These 11 are loaded by the browser from
|
||||||
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
|
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
|
||||||
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as typed.
|
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as
|
||||||
|
typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
|
||||||
- **Data**: "Delete all logs" permanently erases every stored log (you must type
|
- **Data**: "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`); set `ALLOW_PURGE=false` to disable the feature.
|
||||||
@@ -226,11 +243,29 @@ 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): HTTP Basic authentication with `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).
|
||||||
- **`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.
|
||||||
|
|
||||||
|
In `local` mode the login page follows the theme and language of the UI. The session lasts
|
||||||
|
`SESSION_TTL` (12 h by default), survives restarts (its signing key is in `/data/session.key`) and
|
||||||
|
ends when `AUTH_USER` or `AUTH_PASS` changes; the log out button (top right) ends it. Failed logins
|
||||||
|
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`).
|
||||||
|
|
||||||
|
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml, logstream service
|
||||||
|
volumes:
|
||||||
|
- ./logo.png:/config/logo.png:ro
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
LOGIN_LOGO=/config/logo.png
|
||||||
|
```
|
||||||
|
|
||||||
To use OIDC:
|
To use OIDC:
|
||||||
|
|
||||||
1. In the provider, create a **confidential** client (with a secret) for logstream and register
|
1. In the provider, create a **confidential** client (with a secret) for logstream and register
|
||||||
@@ -247,7 +282,7 @@ To use OIDC:
|
|||||||
provider could not be read (wrong issuer, unreachable…).
|
provider could not be read (wrong issuer, unreachable…).
|
||||||
|
|
||||||
Opening the UI sends you to the provider's login page, then back to logstream. The session
|
Opening the UI sends you to the provider's login page, then back to logstream. The session
|
||||||
lasts `OIDC_SESSION_TTL` (12 h by default) and survives restarts (its signing key is in
|
lasts `SESSION_TTL` (12 h by default) and survives restarts (its signing key is in
|
||||||
`/data/session.key`); when it ends, the page goes through the login again. The log out button
|
`/data/session.key`); when it ends, the page goes through the login again. The log out button
|
||||||
(top right) ends the logstream session, then opens the provider's log out page if it has one.
|
(top right) ends the logstream session, then opens the provider's log out page if it has one.
|
||||||
|
|
||||||
@@ -274,17 +309,19 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
|
|||||||
| `SYSLOG_PORT` | `514` | syslog port published on the host |
|
| `SYSLOG_PORT` | `514` | syslog port published on the host |
|
||||||
| `HTTP_PORT` | `8080` | web UI port |
|
| `HTTP_PORT` | `8080` | web UI port |
|
||||||
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
||||||
| `AUTH_MODE` | `local` | `local` (HTTP Basic) or `oidc`, see [Authentication](#authentication) |
|
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
|
||||||
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI (`local` mode) |
|
| `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
|
||||||
|
| `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) |
|
||||||
| `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_SESSION_TTL` | `12h` | session lifetime |
|
|
||||||
| `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` | `true` | allow "Delete all logs" in Settings |
|
||||||
| `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export |
|
| `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export |
|
||||||
|
| `PRESETS_FILE` | `/data/presets.json` | color tag presets file; the built-in list when missing (see [docs/presets.md](docs/presets.md)) |
|
||||||
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
|
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
|
||||||
| `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) |
|
| `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) |
|
||||||
| `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time |
|
| `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time |
|
||||||
@@ -335,7 +372,8 @@ To update one of them:
|
|||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.go` | configuration, startup |
|
| `main.go` | configuration, startup |
|
||||||
| `auth.go` | authentication: HTTP Basic or OpenID Connect (discovery, PKCE, ID token checks, session cookie) |
|
| `auth.go` | authentication: OpenID Connect (discovery, PKCE, ID token checks) and the signed session cookie |
|
||||||
|
| `auth_local.go` | `local` mode: login page (`web/login.html`), session cookie, `LOGIN_LOGO` |
|
||||||
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
|
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
|
||||||
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
|
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
|
||||||
| `query.go` | turns UI filters into LogsQL; live-view filter |
|
| `query.go` | turns UI filters into LogsQL; live-view filter |
|
||||||
@@ -347,8 +385,9 @@ To update one of them:
|
|||||||
| `syslogserver.go` | syslog listeners opened and closed from Settings > Sources |
|
| `syslogserver.go` | syslog listeners opened and closed from Settings > Sources |
|
||||||
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
|
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
|
||||||
| `tags.go` | color tag storage |
|
| `tags.go` | color tag storage |
|
||||||
|
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
|
||||||
| `api.go` | `/api/*` HTTP routes |
|
| `api.go` | `/api/*` HTTP routes |
|
||||||
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) |
|
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type API struct {
|
|||||||
store *Store
|
store *Store
|
||||||
hub *Hub
|
hub *Hub
|
||||||
tags *TagStore
|
tags *TagStore
|
||||||
|
presets string // presets file, built-in presets when missing
|
||||||
rdns *ReverseDNS
|
rdns *ReverseDNS
|
||||||
allowPurge bool
|
allowPurge bool
|
||||||
exportMax int
|
exportMax int
|
||||||
@@ -34,6 +35,7 @@ func (a *API) Routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /api/tags", a.listTags)
|
mux.HandleFunc("GET /api/tags", a.listTags)
|
||||||
mux.HandleFunc("POST /api/tags", a.createTag)
|
mux.HandleFunc("POST /api/tags", a.createTag)
|
||||||
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
|
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
|
||||||
|
mux.HandleFunc("GET /api/presets", a.listPresets)
|
||||||
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
|
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
|
||||||
mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag)
|
mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag)
|
||||||
mux.HandleFunc("GET /api/purge", a.purgeStatus)
|
mux.HandleFunc("GET /api/purge", a.purgeStatus)
|
||||||
@@ -316,6 +318,10 @@ func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, a.tags.List())
|
writeJSON(w, http.StatusOK, a.tags.List())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) listPresets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, loadPresets(a.presets))
|
||||||
|
}
|
||||||
|
|
||||||
func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) {
|
func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) {
|
||||||
var t Tag
|
var t Tag
|
||||||
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t)
|
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t)
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Web UI authentication. AUTH_MODE=local (default) keeps the optional HTTP Basic
|
// Web UI authentication. AUTH_MODE=local (default) shows a login page when AUTH_USER /
|
||||||
// authentication (AUTH_USER / AUTH_PASS); AUTH_MODE=oidc delegates the login to an
|
// AUTH_PASS are set (auth_local.go); AUTH_MODE=oidc delegates the login to an
|
||||||
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
|
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
|
||||||
// flow and PKCE. Only the standard library is used.
|
// flow and PKCE. Only the standard library is used.
|
||||||
|
|
||||||
@@ -49,13 +49,23 @@ type authConfig struct {
|
|||||||
scopes string
|
scopes string
|
||||||
sessionTTL time.Duration
|
sessionTTL time.Duration
|
||||||
dataDir string
|
dataDir string
|
||||||
|
loginLogo string // local mode: PNG shown on the login page
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAuth returns the middleware that protects the UI and the API (except /healthz).
|
// newAuth returns the middleware that protects the UI and the API (except /healthz).
|
||||||
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
||||||
|
if c.sessionTTL <= 0 {
|
||||||
|
c.sessionTTL = 12 * time.Hour
|
||||||
|
}
|
||||||
switch strings.ToLower(c.mode) {
|
switch strings.ToLower(c.mode) {
|
||||||
case "", "local":
|
case "", "local":
|
||||||
return basicAuth(c.user, c.pass, next), nil
|
if c.user == "" {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
l := newLocal(c)
|
||||||
|
l.next = next
|
||||||
|
return l, nil
|
||||||
case "oidc":
|
case "oidc":
|
||||||
o, err := newOIDC(c)
|
o, err := newOIDC(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,28 +78,6 @@ func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
|||||||
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode)
|
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// basicAuth protects the UI when AUTH_USER is set (except /healthz).
|
|
||||||
func basicAuth(user, pass string, next http.Handler) http.Handler {
|
|
||||||
if user == "" {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/healthz" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u, p, ok := r.BasicAuth()
|
|
||||||
if !ok ||
|
|
||||||
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
|
|
||||||
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="logstream"`)
|
|
||||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type oidcMeta struct {
|
type oidcMeta struct {
|
||||||
Issuer string `json:"issuer"`
|
Issuer string `json:"issuer"`
|
||||||
AuthEndpoint string `json:"authorization_endpoint"`
|
AuthEndpoint string `json:"authorization_endpoint"`
|
||||||
@@ -136,9 +124,6 @@ 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.sessionTTL <= 0 {
|
|
||||||
c.sessionTTL = 12 * time.Hour
|
|
||||||
}
|
|
||||||
return &OIDC{
|
return &OIDC{
|
||||||
cfg: c,
|
cfg: c,
|
||||||
callback: ru.Path,
|
callback: ru.Path,
|
||||||
@@ -166,7 +151,7 @@ func sessionKey(dir string) []byte {
|
|||||||
log.Fatalf("session key: %v", err)
|
log.Fatalf("session key: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, k, 0o600); err != nil {
|
if err := os.WriteFile(path, k, 0o600); err != nil {
|
||||||
log.Printf("oidc: cannot save %s (%v): sessions end when logstream restarts", path, err)
|
log.Printf("auth: cannot save %s (%v): sessions end when logstream restarts", path, err)
|
||||||
}
|
}
|
||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
@@ -176,6 +161,14 @@ type session struct {
|
|||||||
Exp int64 `json:"e"`
|
Exp int64 `json:"e"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeAuthRequired answers API calls without a session; the UI turns it into a reload
|
||||||
|
// (and so into a new login).
|
||||||
|
func writeAuthRequired(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
type loginState struct {
|
type loginState struct {
|
||||||
Nonce string `json:"n"`
|
Nonce string `json:"n"`
|
||||||
Verifier string `json:"v"`
|
Verifier string `json:"v"`
|
||||||
@@ -196,7 +189,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var s session
|
var s session
|
||||||
if c, err := r.Cookie(sessionCookie); err == nil && o.verifyCookie(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})
|
||||||
return
|
return
|
||||||
@@ -210,9 +203,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
o.startLogin(w, r)
|
o.startLogin(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeAuthRequired(w)
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -229,7 +220,7 @@ func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: loginCookie + state,
|
Name: loginCookie + state,
|
||||||
Value: o.signCookie(loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
|
Value: signCookie(o.key, loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(loginTTL.Seconds()),
|
MaxAge: int(loginTTL.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -260,8 +251,8 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
state := q.Get("state")
|
state := q.Get("state")
|
||||||
var ls loginState
|
var ls loginState
|
||||||
c, err := r.Cookie(loginCookie + state)
|
c, err := r.Cookie(loginCookie + state)
|
||||||
if state == "" || err != nil || !o.verifyCookie(c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
if state == "" || err != nil || !verifyCookie(o.key, c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
||||||
http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest)
|
http.Error(w, "login expired or started in another browser: open LogStream again", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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})
|
||||||
@@ -269,13 +260,13 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
user, err := o.exchange(r, q.Get("code"), ls)
|
user, 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", user)
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
Name: sessionCookie,
|
||||||
Value: o.signCookie(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()}),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -564,15 +555,15 @@ func (o *OIDC) getJSON(u string, v any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
|
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
|
||||||
func (o *OIDC) signCookie(v any) string {
|
func signCookie(key []byte, v any) string {
|
||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
p := base64.RawURLEncoding.EncodeToString(b)
|
p := base64.RawURLEncoding.EncodeToString(b)
|
||||||
m := hmac.New(sha256.New, o.key)
|
m := hmac.New(sha256.New, key)
|
||||||
m.Write([]byte(p))
|
m.Write([]byte(p))
|
||||||
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OIDC) verifyCookie(s string, v any) bool {
|
func verifyCookie(key []byte, s string, v any) bool {
|
||||||
p, sig, ok := strings.Cut(s, ".")
|
p, sig, ok := strings.Cut(s, ".")
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -581,7 +572,7 @@ func (o *OIDC) verifyCookie(s string, v any) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
m := hmac.New(sha256.New, o.key)
|
m := hmac.New(sha256.New, key)
|
||||||
m.Write([]byte(p))
|
m.Write([]byte(p))
|
||||||
if !hmac.Equal(got, m.Sum(nil)) {
|
if !hmac.Equal(got, m.Sum(nil)) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
|
||||||
|
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
|
||||||
|
// accepted so scripts calling the API keep working, but the browser popup is gone.
|
||||||
|
|
||||||
|
const loginPage = "/login.html"
|
||||||
|
|
||||||
|
var loginFailDelay = time.Second // slows down password guessing
|
||||||
|
|
||||||
|
type Local struct {
|
||||||
|
user, pass string
|
||||||
|
ttl time.Duration
|
||||||
|
logo string // LOGIN_LOGO, served at /auth/logo
|
||||||
|
key []byte
|
||||||
|
next http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLocal(c authConfig) *Local {
|
||||||
|
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
||||||
|
m := hmac.New(sha256.New, sessionKey(c.dataDir))
|
||||||
|
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
|
||||||
|
if c.loginLogo != "" {
|
||||||
|
if _, err := os.Stat(c.loginLogo); err != nil {
|
||||||
|
log.Printf("auth: LOGIN_LOGO: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/healthz", "/style.css":
|
||||||
|
l.next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
case "/auth/logo":
|
||||||
|
l.serveLogo(w, r)
|
||||||
|
return
|
||||||
|
case "/auth/login":
|
||||||
|
l.handleLogin(w, r)
|
||||||
|
return
|
||||||
|
case "/auth/logout":
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
|
||||||
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok := l.sessionUser(r)
|
||||||
|
if !ok {
|
||||||
|
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
|
||||||
|
user, ok = u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == loginPage:
|
||||||
|
if ok {
|
||||||
|
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
l.next.ServeHTTP(w, r)
|
||||||
|
case ok && r.URL.Path == "/auth/me":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
|
||||||
|
case ok:
|
||||||
|
l.next.ServeHTTP(w, r)
|
||||||
|
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
||||||
|
target := loginPage
|
||||||
|
if ret := r.URL.RequestURI(); ret != "/" {
|
||||||
|
target += "?" + url.Values{"r": {ret}}.Encode()
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
|
default:
|
||||||
|
writeAuthRequired(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
|
||||||
|
ret := safeReturn(r.PostFormValue("r"))
|
||||||
|
if !l.check(user, pass) {
|
||||||
|
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
||||||
|
time.Sleep(loginFailDelay)
|
||||||
|
q := url.Values{"e": {"1"}}
|
||||||
|
if ret != "/" {
|
||||||
|
q.Set("r", ret)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("auth: %s logged in from %s", user, clientIP(r))
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(l.ttl.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isHTTPS(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, ret, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) sessionUser(r *http.Request) (string, bool) {
|
||||||
|
var s session
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return s.User, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) check(user, pass string) bool {
|
||||||
|
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
|
||||||
|
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
|
||||||
|
return u&p == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
|
||||||
|
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if l.logo == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
http.ServeFile(w, r, l.logo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeReturn keeps the page to open after login inside logstream.
|
||||||
|
func safeReturn(ret string) string {
|
||||||
|
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
|
||||||
|
func isHTTPS(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newLocalApp puts the local login in front of a handler that echoes "app" and returns a
|
||||||
|
// browser (client with cookies) that does not follow redirects.
|
||||||
|
func newLocalApp(t *testing.T, c authConfig) (string, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
loginFailDelay = 0
|
||||||
|
c.mode, c.dataDir = "local", t.TempDir()
|
||||||
|
h, err := newAuth(c, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) }))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
return "http://app.test", &http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
Transport: hosts{"app.test": h},
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(t *testing.T, c *http.Client, app, user, pass, ret string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
res, err := c.PostForm(app+"/auth/login", url.Values{"user": {user}, "pass": {pass}, "r": {ret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res.Body.Close()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLoginFlow(t *testing.T) {
|
||||||
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
||||||
|
|
||||||
|
res, _ := c.Get(app + "/api/logs?q=x")
|
||||||
|
if res.StatusCode != http.StatusUnauthorized || res.Header.Get("WWW-Authenticate") != "" {
|
||||||
|
t.Fatalf("API without session: %d %q", res.StatusCode, res.Header.Get("WWW-Authenticate"))
|
||||||
|
}
|
||||||
|
res, _ = c.Get(app + "/?q=disk")
|
||||||
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusFound || loc != "/login.html?r=%2F%3Fq%3Ddisk" {
|
||||||
|
t.Fatalf("page without session: %d %q", res.StatusCode, loc)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/login.html", "/style.css", "/healthz"} {
|
||||||
|
if code, body := get(t, c, app+p); code != http.StatusOK || body != "app "+p {
|
||||||
|
t.Errorf("%s without session: %d %q", p, code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res = login(t, c, app, "admin", "wrong", "/?q=disk")
|
||||||
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || !strings.Contains(loc, "e=1") {
|
||||||
|
t.Fatalf("wrong password: %d %q", res.StatusCode, loc)
|
||||||
|
}
|
||||||
|
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
|
||||||
|
t.Fatal("session created by a wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
res = login(t, c, app, "admin", "pw", "//evil.example/")
|
||||||
|
if loc := res.Header.Get("Location"); loc != "/" {
|
||||||
|
t.Fatalf("open redirect: %q", loc)
|
||||||
|
}
|
||||||
|
res = login(t, c, app, "admin", "pw", "/?q=disk")
|
||||||
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || loc != "/?q=disk" {
|
||||||
|
t.Fatalf("login: %d %q", res.StatusCode, loc)
|
||||||
|
}
|
||||||
|
if code, body := get(t, c, app+"/api/logs"); code != http.StatusOK || body != "app /api/logs" {
|
||||||
|
t.Fatalf("API with session: %d %q", code, body)
|
||||||
|
}
|
||||||
|
if code, body := get(t, c, app+"/auth/me"); code != http.StatusOK || !strings.Contains(body, `"user":"admin"`) || !strings.Contains(body, `"mode":"local"`) {
|
||||||
|
t.Fatalf("/auth/me: %d %s", code, body)
|
||||||
|
}
|
||||||
|
if res, _ := c.Get(app + "/login.html"); res.StatusCode != http.StatusFound {
|
||||||
|
t.Errorf("login page while logged in: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, _ := c.Get(app + "/auth/logout"); res.Header.Get("Location") != "/login.html" {
|
||||||
|
t.Fatalf("logout: %q", res.Header.Get("Location"))
|
||||||
|
}
|
||||||
|
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
|
||||||
|
t.Fatal("session still valid after logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalBasicAuthForScripts(t *testing.T) {
|
||||||
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, app+"/api/logs", nil)
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
if res, _ := c.Do(req); res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("basic auth: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth("admin", "nope")
|
||||||
|
if res, _ := c.Do(req); res.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong basic auth: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalPasswordChangeEndsSessions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
loginFailDelay = 0
|
||||||
|
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
h1, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "old", dataDir: dir}, echo)
|
||||||
|
h2, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "new", dataDir: dir}, echo)
|
||||||
|
cookie := signCookie(h1.(*Local).key, session{User: "admin", Exp: 9999999999})
|
||||||
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
|
||||||
|
if _, ok := h1.(*Local).sessionUser(r); !ok {
|
||||||
|
t.Fatal("session refused with the same password")
|
||||||
|
}
|
||||||
|
if _, ok := h2.(*Local).sessionUser(r); ok {
|
||||||
|
t.Fatal("session kept after a password change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLogo(t *testing.T) {
|
||||||
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
||||||
|
if code, _ := get(t, c, app+"/auth/logo"); code != http.StatusNotFound {
|
||||||
|
t.Errorf("no LOGIN_LOGO: %d", code)
|
||||||
|
}
|
||||||
|
png := filepath.Join(t.TempDir(), "logo.png")
|
||||||
|
_ = os.WriteFile(png, []byte("\x89PNG\r\n\x1a\nfake"), 0o644)
|
||||||
|
app, c = newLocalApp(t, authConfig{user: "admin", pass: "pw", loginLogo: png})
|
||||||
|
res, _ := c.Get(app + "/auth/logo")
|
||||||
|
if res.StatusCode != http.StatusOK || res.Header.Get("Content-Type") != "image/png" {
|
||||||
|
t.Errorf("LOGIN_LOGO: %d %q", res.StatusCode, res.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalWithoutUserIsOpen(t *testing.T) {
|
||||||
|
if h, _ := newAuth(authConfig{mode: "local"}, http.NotFoundHandler()); h == nil || isLocal(h) {
|
||||||
|
t.Error("local mode without AUTH_USER should not protect anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocal(h http.Handler) bool { _, ok := h.(*Local); return ok }
|
||||||
+8
-2
@@ -14,15 +14,17 @@ services:
|
|||||||
VLOGS_URL: http://victorialogs:9428
|
VLOGS_URL: http://victorialogs:9428
|
||||||
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
AUTH_MODE: ${AUTH_MODE:-local} # local (Basic Auth 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:-}
|
||||||
|
LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes)
|
||||||
|
PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md)
|
||||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
||||||
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||||
OIDC_SESSION_TTL: ${OIDC_SESSION_TTL:-12h}
|
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:-true}
|
||||||
@@ -38,6 +40,10 @@ services:
|
|||||||
# logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources)
|
# logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources)
|
||||||
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
|
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
|
||||||
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
|
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
|
||||||
|
# logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env
|
||||||
|
# - ./logo.png:/config/logo.png:ro
|
||||||
|
# prereglages de tags personnalises, avec PRESETS_FILE=/config/presets.json dans .env
|
||||||
|
# - ./presets.json:/config/presets.json:ro
|
||||||
labels:
|
labels:
|
||||||
logstream.exclude: "true" # pas de collect des logs logstream
|
logstream.exclude: "true" # pas de collect des logs logstream
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
[English](presets.md) · **Français**
|
||||||
|
|
||||||
|
# Préréglages de tags de couleur
|
||||||
|
|
||||||
|
Dans **Paramètres › Filtres**, le menu **+ Préréglage…** ajoute d'un clic un groupe de tags de
|
||||||
|
couleur tout faits. Les tags ajoutés sont des tags ordinaires : vous pouvez changer leur couleur,
|
||||||
|
leur motif ou leurs options, ou les supprimer. Un tag dont le motif est déjà dans la liste n'est
|
||||||
|
pas ajouté en double.
|
||||||
|
|
||||||
|
La liste vient d'un fichier texte, [`presets.json`](../presets.json), intégré à LogStream. Vous
|
||||||
|
pouvez le remplacer par votre propre fichier (voir [Utiliser votre propre fichier](#utiliser-votre-propre-fichier)).
|
||||||
|
|
||||||
|
## Préréglages intégrés
|
||||||
|
|
||||||
|
### HTTP/HTTPS
|
||||||
|
|
||||||
|
Ces préréglages lisent les logs d'accès de nginx et Apache (formats common et combined), Traefik
|
||||||
|
(CLF et JSON), Caddy (JSON) et HAProxy (`option httplog`).
|
||||||
|
|
||||||
|
| Préréglage | Tags | Ce qui est coloré |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Codes HTTP | `HTTP 2xx` vert, `HTTP 3xx` bleu, `HTTP 4xx` orange, `HTTP 5xx` rouge | seulement le code de statut, par exemple `404` dans `"GET /x HTTP/1.1" 404 153`, `"status":404` ou `"DownstreamStatus":404`. Les autres nombres de la ligne (taille, chemin) ne sont pas touchés. |
|
||||||
|
| Méthodes HTTP | `GET/HEAD/OPTIONS` gris, `POST/PUT/PATCH` violet, `DELETE` rose | seulement la méthode dans `"GET /chemin` ou `"method":"GET"` (en majuscules uniquement) |
|
||||||
|
| Sondes et attaques | `sondes / attaques` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
|
||||||
|
| Robots et scripts | `robots / scripts` | les mots finissant par `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
|
||||||
|
| Erreurs TLS/HTTPS et proxy | `erreurs TLS`, `erreurs proxy` | échecs de handshake TLS, certificats expirés ou refusés, `x509:` ; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
|
||||||
|
|
||||||
|
### Système
|
||||||
|
|
||||||
|
| Préréglage | Tags | Ce qui est coloré |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH et connexions | `échecs de connexion` rouge, `connexions` vert | sshd/PAM : `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`… ; `Accepted publickey`, `session opened for user`, `New session … of user` |
|
||||||
|
| Commandes sudo | `commandes sudo` | la commande lancée, par exemple `COMMAND=/usr/bin/apt` |
|
||||||
|
| Noyau : OOM, plantages, disques | `mémoire épuisée`, `erreurs noyau` | `Out of memory`, `oom-killer`, `Killed process 4242` ; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
|
||||||
|
| Services systemd | `services en échec` rouge, `démarrage/arrêt de service` vert | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly` ; `Started`, `Stopping`, `Reloaded`, `Reached target` |
|
||||||
|
| Pare-feu et fail2ban | `pare-feu` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
|
||||||
|
|
||||||
|
### Applications
|
||||||
|
|
||||||
|
| Préréglage | Tags | Ce qui est coloré |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Docker et conteneurs | `problèmes de conteneur` | `exited with code 137` (codes non nuls seulement), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
|
||||||
|
| Bases de données | `erreurs base de données` | PostgreSQL et MySQL/MariaDB : `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
|
||||||
|
|
||||||
|
### Général
|
||||||
|
|
||||||
|
| Préréglage | Tags | Ce qui est coloré |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Niveaux de log | `fatal / critique` rouge, `info / notice` bleu, `debug / trace` gris, `ok` vert | ces mots en mots entiers, quelle que soit la casse (les tags par défaut `warning` et `error` couvrent le reste) |
|
||||||
|
| Adresses IPv4 | `adresses IPv4` | `192.168.1.20`, `203.0.113.9`… Les numéros de version à quatre parties comme `1.2.3.4` sont aussi colorés. |
|
||||||
|
|
||||||
|
Quand des tags se chevauchent, celui placé le plus haut dans la liste l'emporte : les
|
||||||
|
préréglages ajoutés après les tags par défaut ne les masquent donc jamais.
|
||||||
|
|
||||||
|
## Utiliser votre propre fichier
|
||||||
|
|
||||||
|
LogStream lit le fichier indiqué par `PRESETS_FILE`, `/data/presets.json` par défaut (dans le
|
||||||
|
volume `logstream-data`). S'il n'existe pas, la liste intégrée est utilisée. Le fichier est relu
|
||||||
|
à chaque ouverture des Paramètres : pas besoin de redémarrer après une modification.
|
||||||
|
|
||||||
|
Avec docker-compose, le plus simple est de garder le fichier à côté de `docker-compose.yml` :
|
||||||
|
|
||||||
|
1. Copiez [`presets.json`](../presets.json) depuis ce dépôt et modifiez-le.
|
||||||
|
2. Dans `docker-compose.yml`, décommentez la ligne `- ./presets.json:/config/presets.json:ro`.
|
||||||
|
3. Dans `.env`, mettez `PRESETS_FILE=/config/presets.json`, puis lancez `docker compose up -d`.
|
||||||
|
|
||||||
|
Si le fichier est invalide (erreur JSON, expression régulière ou couleur incorrecte, id en
|
||||||
|
double), les Paramètres affichent l'erreur et la liste intégrée est utilisée jusqu'à correction.
|
||||||
|
|
||||||
|
## Format du fichier
|
||||||
|
|
||||||
|
Le fichier est une liste JSON de groupes. Chaque groupe a un nom et une liste de préréglages ;
|
||||||
|
chaque préréglage a un `id`, un nom et ses tags.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"group": { "en": "My apps", "fr": "Mes applis" },
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"id": "monappli",
|
||||||
|
"name": "Mon appli",
|
||||||
|
"tags": [
|
||||||
|
{ "label": "paiement refusé", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
|
||||||
|
{ "label": "commande", "color": "#86efac", "pattern": "order #\\d+" },
|
||||||
|
{ "label": "lent", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Obligatoire | Signification |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `group` | oui | nom du groupe dans le menu |
|
||||||
|
| `id` | oui | identifiant unique du préréglage |
|
||||||
|
| `name` | oui | nom du préréglage dans le menu |
|
||||||
|
| `tags[].pattern` | oui | ce qu'il faut colorer : une expression régulière, ou du texte simple avec `"regex": false` |
|
||||||
|
| `tags[].color` | oui | couleur de fond, `#rrggbb` |
|
||||||
|
| `tags[].label` | non | nom affiché dans la liste des tags à la place du motif |
|
||||||
|
| `tags[].regex` | non | `true` par défaut |
|
||||||
|
| `tags[].wholeWord` | non | mots entiers seulement, `false` par défaut |
|
||||||
|
| `tags[].caseSensitive` | non | respecter la casse, `false` par défaut |
|
||||||
|
|
||||||
|
Les noms et libellés sont soit un seul texte pour toutes les langues (`"Mon appli"`), soit un
|
||||||
|
texte par langue (`{ "en": "My app", "fr": "Mon appli" }`) ; une langue absente se rabat sur
|
||||||
|
l'anglais.
|
||||||
|
|
||||||
|
Les expressions régulières doivent fonctionner à la fois dans le navigateur (JavaScript) et en
|
||||||
|
Go, qui les vérifie : évitez les assertions arrière `(?<=…)`, avant `(?=…)` et les références
|
||||||
|
arrière `\1`. En JSON, chaque barre oblique inverse s'écrit deux fois : `\d` devient `"\\d"`. Un
|
||||||
|
groupe nommé `hl`, `(?<hl>…)`, ne colore que cette partie de la correspondance, comme le font
|
||||||
|
les préréglages HTTP avec `(?<hl>5\\d\\d)`.
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
**English** · [Français](presets.fr.md)
|
||||||
|
|
||||||
|
# Color tag presets
|
||||||
|
|
||||||
|
In **Settings › Filters**, the **+ Preset…** menu adds a group of ready-made color tags in one
|
||||||
|
click. Added tags are ordinary tags: you can change their color, pattern or options, or delete
|
||||||
|
them. A tag whose pattern is already in the list is not added twice.
|
||||||
|
|
||||||
|
The list comes from a text file, [`presets.json`](../presets.json), built into LogStream. You
|
||||||
|
can replace it with your own file (see [Using your own file](#using-your-own-file)).
|
||||||
|
|
||||||
|
## Built-in presets
|
||||||
|
|
||||||
|
### HTTP/HTTPS
|
||||||
|
|
||||||
|
These presets read access logs from nginx and Apache (common and combined formats), Traefik
|
||||||
|
(CLF and JSON), Caddy (JSON) and HAProxy (`option httplog`).
|
||||||
|
|
||||||
|
| Preset | Tags | What gets colored |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| HTTP status codes | `HTTP 2xx` green, `HTTP 3xx` blue, `HTTP 4xx` orange, `HTTP 5xx` red | only the status code, e.g. `404` in `"GET /x HTTP/1.1" 404 153`, `"status":404` or `"DownstreamStatus":404`. Other numbers on the line (size, path) are left alone. |
|
||||||
|
| HTTP methods | `GET/HEAD/OPTIONS` grey, `POST/PUT/PATCH` purple, `DELETE` pink | only the method in `"GET /path` or `"method":"GET"` (upper case only) |
|
||||||
|
| Probes and attacks | `probes / attacks` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
|
||||||
|
| Bots and scripts | `bots / scripts` | words ending in `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
|
||||||
|
| TLS/HTTPS and proxy errors | `TLS errors`, `proxy errors` | TLS handshake failures, expired or rejected certificates, `x509:`; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
| Preset | Tags | What gets colored |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH and logins | `login failures` red, `logins` green | sshd/PAM: `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`…; `Accepted publickey`, `session opened for user`, `New session … of user` |
|
||||||
|
| sudo commands | `sudo commands` | the command run, e.g. `COMMAND=/usr/bin/apt` |
|
||||||
|
| Kernel: OOM, crashes, disks | `out of memory`, `kernel errors` | `Out of memory`, `oom-killer`, `Killed process 4242`; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
|
||||||
|
| systemd services | `failed services` red, `service start/stop` green | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly`; `Started`, `Stopping`, `Reloaded`, `Reached target` |
|
||||||
|
| Firewall and fail2ban | `firewall` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
|
||||||
|
|
||||||
|
### Applications
|
||||||
|
|
||||||
|
| Preset | Tags | What gets colored |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Docker and containers | `container problems` | `exited with code 137` (non-zero codes only), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
|
||||||
|
| Databases | `database errors` | PostgreSQL and MySQL/MariaDB: `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
| Preset | Tags | What gets colored |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Log levels | `fatal / critical` red, `info / notice` blue, `debug / trace` grey, `ok` green | these words as whole words, any case (the default `warning` and `error` tags cover the rest) |
|
||||||
|
| IPv4 addresses | `IPv4 addresses` | `192.168.1.20`, `203.0.113.9`… Four-part version numbers such as `1.2.3.4` are colored too. |
|
||||||
|
|
||||||
|
When tags overlap, the one highest in the tag list wins, so presets added after the default
|
||||||
|
tags never hide them.
|
||||||
|
|
||||||
|
## Using your own file
|
||||||
|
|
||||||
|
LogStream reads the file named by `PRESETS_FILE`, `/data/presets.json` by default (in the
|
||||||
|
`logstream-data` volume). When the file does not exist, the built-in list is used. The file is
|
||||||
|
read again each time Settings is opened: no restart is needed after an edit.
|
||||||
|
|
||||||
|
With docker-compose, the simplest is to keep the file next to `docker-compose.yml`:
|
||||||
|
|
||||||
|
1. Copy [`presets.json`](../presets.json) from this repository and edit it.
|
||||||
|
2. In `docker-compose.yml`, uncomment the line `- ./presets.json:/config/presets.json:ro`.
|
||||||
|
3. In `.env`, set `PRESETS_FILE=/config/presets.json`, then run `docker compose up -d`.
|
||||||
|
|
||||||
|
If the file is invalid (JSON error, bad regular expression or color, duplicate id), Settings
|
||||||
|
shows the error and the built-in list is used until the file is fixed.
|
||||||
|
|
||||||
|
## File format
|
||||||
|
|
||||||
|
The file is a JSON list of groups. Each group has a name and a list of presets; each preset has
|
||||||
|
an `id`, a name and its tags.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"group": { "en": "My apps", "fr": "Mes applis" },
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"id": "myapp",
|
||||||
|
"name": "My app",
|
||||||
|
"tags": [
|
||||||
|
{ "label": "payment failed", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
|
||||||
|
{ "label": "order", "color": "#86efac", "pattern": "order #\\d+" },
|
||||||
|
{ "label": "slow", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `group` | yes | name of the group in the menu |
|
||||||
|
| `id` | yes | unique identifier of the preset |
|
||||||
|
| `name` | yes | name of the preset in the menu |
|
||||||
|
| `tags[].pattern` | yes | what to color: a regular expression, or plain text with `"regex": false` |
|
||||||
|
| `tags[].color` | yes | background color, `#rrggbb` |
|
||||||
|
| `tags[].label` | no | name shown in the tag list instead of the pattern |
|
||||||
|
| `tags[].regex` | no | `true` by default |
|
||||||
|
| `tags[].wholeWord` | no | only match whole words, `false` by default |
|
||||||
|
| `tags[].caseSensitive` | no | match case, `false` by default |
|
||||||
|
|
||||||
|
Names and labels are either one text for every language (`"My app"`) or one text per language
|
||||||
|
(`{ "en": "My app", "fr": "Mon appli" }`); a missing language falls back to English.
|
||||||
|
|
||||||
|
Regular expressions must work both in the browser (JavaScript) and in Go, which checks them:
|
||||||
|
avoid look-behind `(?<=…)`, look-ahead `(?=…)` and back-references `\1`. In JSON, every
|
||||||
|
backslash is written twice: `\d` becomes `"\\d"`. A group named `hl`, `(?<hl>…)`, colors only
|
||||||
|
that part of the match, as the HTTP presets do with `(?<hl>5\\d\\d)`.
|
||||||
@@ -89,7 +89,9 @@ func main() {
|
|||||||
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||||
scopes: os.Getenv("OIDC_SCOPES"),
|
scopes: os.Getenv("OIDC_SCOPES"),
|
||||||
sessionTTL: getenvDuration("OIDC_SESSION_TTL", 12*time.Hour),
|
// SESSION_TTL applies to both modes; OIDC_SESSION_TTL is its former name.
|
||||||
|
sessionTTL: getenvDuration("SESSION_TTL", getenvDuration("OIDC_SESSION_TTL", 12*time.Hour)),
|
||||||
|
loginLogo: os.Getenv("LOGIN_LOGO"),
|
||||||
},
|
},
|
||||||
rdns: getenvBool("RDNS", true),
|
rdns: getenvBool("RDNS", true),
|
||||||
dnsServer: os.Getenv("DNS_SERVER"),
|
dnsServer: os.Getenv("DNS_SERVER"),
|
||||||
@@ -141,7 +143,8 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api := &API{store: store, hub: hub, tags: tags, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv}
|
presets := getenv("PRESETS_FILE", filepath.Join(cfg.dataDir, "presets.json"))
|
||||||
|
api := &API{store: store, hub: hub, tags: tags, presets: presets, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv}
|
||||||
if cfg.dockerLogs {
|
if cfg.dockerLogs {
|
||||||
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
|
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Built-in tag presets, used when no presets file is found (PRESETS_FILE,
|
||||||
|
// /data/presets.json by default). See docs/presets.md.
|
||||||
|
//
|
||||||
|
//go:embed presets.json
|
||||||
|
var builtinPresets []byte
|
||||||
|
|
||||||
|
// i18nText is a text per language ({"en": "...", "fr": "..."}) or one plain
|
||||||
|
// string for every language.
|
||||||
|
type i18nText map[string]string
|
||||||
|
|
||||||
|
func (t *i18nText) UnmarshalJSON(b []byte) error {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(b, &s) == nil {
|
||||||
|
*t = i18nText{"en": s}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return errors.New("expected a string or an object of strings per language")
|
||||||
|
}
|
||||||
|
*t = m
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresetTag struct {
|
||||||
|
Label i18nText `json:"label,omitempty"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Regex *bool `json:"regex,omitempty"` // regular expression unless false
|
||||||
|
WholeWord bool `json:"wholeWord,omitempty"`
|
||||||
|
CaseSensitive bool `json:"caseSensitive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Preset struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name i18nText `json:"name"`
|
||||||
|
Tags []PresetTag `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresetGroup struct {
|
||||||
|
Group i18nText `json:"group"`
|
||||||
|
Presets []Preset `json:"presets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePresets decodes and checks a presets file with the same rules as the
|
||||||
|
// tags, so that every preset can be added as is.
|
||||||
|
func parsePresets(b []byte) ([]PresetGroup, error) {
|
||||||
|
var groups []PresetGroup
|
||||||
|
if err := json.Unmarshal(b, &groups); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, p := range g.Presets {
|
||||||
|
if p.ID == "" || seen[p.ID] {
|
||||||
|
return nil, fmt.Errorf("preset %q: missing or duplicate id", p.ID)
|
||||||
|
}
|
||||||
|
seen[p.ID] = true
|
||||||
|
if len(p.Tags) == 0 {
|
||||||
|
return nil, fmt.Errorf("preset %q: no tags", p.ID)
|
||||||
|
}
|
||||||
|
for i, pt := range p.Tags {
|
||||||
|
t := Tag{Pattern: pt.Pattern, Color: pt.Color, Regex: pt.Regex == nil || *pt.Regex}
|
||||||
|
if err := t.validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("preset %q, tag %d: %w", p.ID, i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetsResponse is what GET /api/presets returns.
|
||||||
|
type presetsResponse struct {
|
||||||
|
Source string `json:"source"` // "file" or "builtin"
|
||||||
|
File string `json:"file"`
|
||||||
|
Error string `json:"error,omitempty"` // the file is invalid: built-in presets are used
|
||||||
|
Groups []PresetGroup `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPresets reads the presets file on every call, so that edits apply
|
||||||
|
// without a restart, and falls back to the built-in presets.
|
||||||
|
func loadPresets(path string) presetsResponse {
|
||||||
|
res := presetsResponse{Source: "builtin", File: path}
|
||||||
|
if path != "" {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if res.Groups, err = parsePresets(b); err == nil {
|
||||||
|
res.Source = "file"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.Error = err.Error()
|
||||||
|
case !errors.Is(err, os.ErrNotExist):
|
||||||
|
res.Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Groups, _ = parsePresets(builtinPresets) // checked by the tests
|
||||||
|
return res
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"group": "HTTP/HTTPS",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"id": "http_status",
|
||||||
|
"name": { "en": "HTTP status codes", "fr": "Codes HTTP" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": "HTTP 2xx",
|
||||||
|
"color": "#86efac",
|
||||||
|
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>2\\d\\d)\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "HTTP 3xx",
|
||||||
|
"color": "#93c5fd",
|
||||||
|
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>3\\d\\d)\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "HTTP 4xx",
|
||||||
|
"color": "#fdba74",
|
||||||
|
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>4\\d\\d)\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "HTTP 5xx",
|
||||||
|
"color": "#f87171",
|
||||||
|
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>5\\d\\d)\\b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "http_methods",
|
||||||
|
"name": { "en": "HTTP methods", "fr": "Méthodes HTTP" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": "GET/HEAD/OPTIONS",
|
||||||
|
"color": "#cbd5e1",
|
||||||
|
"caseSensitive": true,
|
||||||
|
"pattern": "\"(?<hl>GET|HEAD|OPTIONS)[ \"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "POST/PUT/PATCH",
|
||||||
|
"color": "#c4b5fd",
|
||||||
|
"caseSensitive": true,
|
||||||
|
"pattern": "\"(?<hl>POST|PUT|PATCH)[ \"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "DELETE",
|
||||||
|
"color": "#f9a8d4",
|
||||||
|
"caseSensitive": true,
|
||||||
|
"pattern": "\"(?<hl>DELETE)[ \"]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "http_probes",
|
||||||
|
"name": { "en": "Probes and attacks", "fr": "Sondes et attaques" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "probes / attacks", "fr": "sondes / attaques" },
|
||||||
|
"color": "#fda4af",
|
||||||
|
"pattern": "(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|<script|union(?:\\s|%20|\\+)+select)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "http_bots",
|
||||||
|
"name": { "en": "Bots and scripts", "fr": "Robots et scripts" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "bots / scripts", "fr": "robots / scripts" },
|
||||||
|
"color": "#fde68a",
|
||||||
|
"pattern": "\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "http_errors",
|
||||||
|
"name": { "en": "TLS/HTTPS and proxy errors", "fr": "Erreurs TLS/HTTPS et proxy" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "TLS errors", "fr": "erreurs TLS" },
|
||||||
|
"color": "#f0abfc",
|
||||||
|
"pattern": "(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": { "en": "proxy errors", "fr": "erreurs proxy" },
|
||||||
|
"color": "#fdba74",
|
||||||
|
"pattern": "(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": { "en": "System", "fr": "Système" },
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"id": "sys_auth",
|
||||||
|
"name": { "en": "SSH and logins", "fr": "SSH et connexions" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "login failures", "fr": "échecs de connexion" },
|
||||||
|
"color": "#fca5a5",
|
||||||
|
"pattern": "(?:Failed (?:password|publickey|none)|Invalid user|authentication failures?|Connection closed by (?:invalid|authenticating) user|maximum authentication attempts exceeded|FAILED (?:LOGIN|SU)|incorrect password attempts?|NOT in sudoers)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": { "en": "logins", "fr": "connexions" },
|
||||||
|
"color": "#86efac",
|
||||||
|
"pattern": "(?:Accepted (?:password|publickey|keyboard-interactive(?:/pam)?)|session opened for user|New session \\S+ of user)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sys_sudo",
|
||||||
|
"name": { "en": "sudo commands", "fr": "Commandes sudo" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "sudo commands", "fr": "commandes sudo" },
|
||||||
|
"color": "#fde68a",
|
||||||
|
"caseSensitive": true,
|
||||||
|
"pattern": "\\bCOMMAND=\\S+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sys_kernel",
|
||||||
|
"name": { "en": "Kernel: OOM, crashes, disks", "fr": "Noyau : OOM, plantages, disques" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "out of memory", "fr": "mémoire épuisée" },
|
||||||
|
"color": "#f87171",
|
||||||
|
"pattern": "(?:Out of memory|oom-kill(?:er)?|oom_reaper|Killed process \\d+|invoked oom-killer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": { "en": "kernel errors", "fr": "erreurs noyau" },
|
||||||
|
"color": "#fda4af",
|
||||||
|
"pattern": "(?:Kernel panic|\\bBUG: |\\bOops\\b|Call Trace|segfault at|general protection fault|I/O error|EXT4-fs error|Buffer I/O error|blocked for more than \\d+ seconds|Hardware Error|soft lockup|hard LOCKUP)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sys_systemd",
|
||||||
|
"name": { "en": "systemd services", "fr": "Services systemd" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "failed services", "fr": "services en échec" },
|
||||||
|
"color": "#fca5a5",
|
||||||
|
"pattern": "(?:Failed to start|failed with result|Main process exited, code=(?:exited|killed|dumped)|entered failed state|Start request repeated too quickly|Dependency failed)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": { "en": "service start/stop", "fr": "démarrage/arrêt de service" },
|
||||||
|
"color": "#bbf7d0",
|
||||||
|
"caseSensitive": true,
|
||||||
|
"pattern": "\\b(?:Started|Starting|Stopped|Stopping|Reloaded|Reloading|Reached target)\\b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sys_firewall",
|
||||||
|
"name": { "en": "Firewall and fail2ban", "fr": "Pare-feu et fail2ban" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "firewall", "fr": "pare-feu" },
|
||||||
|
"color": "#fdba74",
|
||||||
|
"caseSensitive": true,
|
||||||
|
"pattern": "(?:\\[UFW (?:BLOCK|ALLOW|AUDIT|LIMIT BLOCK)\\]|\\b(?:DROP|REJECT)\\b|\\b(?:Ban|Unban|Found) \\d{1,3}(?:\\.\\d{1,3}){3}\\b)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Applications",
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"id": "app_docker",
|
||||||
|
"name": { "en": "Docker and containers", "fr": "Docker et conteneurs" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "container problems", "fr": "problèmes de conteneur" },
|
||||||
|
"color": "#fcd34d",
|
||||||
|
"pattern": "(?:\\bOOMKilled\\b|exited with code [1-9]\\d*|exit code: [1-9]\\d*|health_status: unhealthy|\\bunhealthy\\b|Back-off restarting|CrashLoopBackOff|container (?:die|kill|oom)\\b|restarting \\(\\d+\\))"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "app_db",
|
||||||
|
"name": { "en": "Databases", "fr": "Bases de données" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "database errors", "fr": "erreurs base de données" },
|
||||||
|
"color": "#c4b5fd",
|
||||||
|
"pattern": "(?:\\bdeadlock(?: detected| found)?\\b|duplicate key|too many (?:connections|clients)|lock wait timeout|slow query|could not connect to server|server has gone away|out of shared memory|terminating connection|Access denied for user|password authentication failed)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": { "en": "General", "fr": "Général" },
|
||||||
|
"presets": [
|
||||||
|
{
|
||||||
|
"id": "gen_levels",
|
||||||
|
"name": { "en": "Log levels", "fr": "Niveaux de log" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "fatal / critical", "fr": "fatal / critique" },
|
||||||
|
"color": "#ef4444",
|
||||||
|
"pattern": "\\b(?:fatal|crit(?:ical)?|panic|emerg(?:ency)?)\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "info / notice",
|
||||||
|
"color": "#bfdbfe",
|
||||||
|
"pattern": "\\b(?:info|notice)\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "debug / trace",
|
||||||
|
"color": "#e5e7eb",
|
||||||
|
"pattern": "\\b(?:debug|trace)\\b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "ok",
|
||||||
|
"color": "#86efac",
|
||||||
|
"regex": false,
|
||||||
|
"wholeWord": true,
|
||||||
|
"pattern": "ok"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gen_ip",
|
||||||
|
"name": { "en": "IPv4 addresses", "fr": "Adresses IPv4" },
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"label": { "en": "IPv4 addresses", "fr": "adresses IPv4" },
|
||||||
|
"color": "#a5f3fc",
|
||||||
|
"pattern": "\\b(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuiltinPresets(t *testing.T) {
|
||||||
|
groups, err := parsePresets(builtinPresets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
t.Fatal("no built-in presets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPresetsFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "presets.json")
|
||||||
|
|
||||||
|
if res := loadPresets(path); res.Source != "builtin" || res.Error != "" {
|
||||||
|
t.Fatalf("missing file: got %q, error %q", res.Source, res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(path, []byte(`[{"group":"Mine","presets":[{"id":"a","name":{"en":"A","fr":"A fr"},
|
||||||
|
"tags":[{"label":"x","pattern":"foo|bar","color":"#112233"},{"pattern":"a.b","color":"#445566","regex":false}]}]}]`), 0o644)
|
||||||
|
res := loadPresets(path)
|
||||||
|
if res.Source != "file" || res.Error != "" || res.Groups[0].Presets[0].Name["fr"] != "A fr" || res.Groups[0].Group["en"] != "Mine" {
|
||||||
|
t.Fatalf("valid file: %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{
|
||||||
|
`not json`,
|
||||||
|
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"(","color":"#112233"}]}]}]`,
|
||||||
|
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"red"}]}]}]`,
|
||||||
|
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[]}]}]`,
|
||||||
|
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"#112233"}]},{"id":"a","name":"B","tags":[{"pattern":"y","color":"#112233"}]}]}]`,
|
||||||
|
} {
|
||||||
|
os.WriteFile(path, []byte(bad), 0o644)
|
||||||
|
if res := loadPresets(path); res.Source != "builtin" || res.Error == "" || len(res.Groups) == 0 {
|
||||||
|
t.Errorf("%s: got %q, error %q", bad, res.Source, res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
// Tag highlights a keyword in displayed messages.
|
// Tag highlights a keyword in displayed messages.
|
||||||
type Tag struct {
|
type Tag struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Code string `json:"code"` // two digits, shown on matching log lines
|
||||||
Pattern string `json:"pattern"`
|
Pattern string `json:"pattern"`
|
||||||
|
Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
WholeWord bool `json:"wholeWord"`
|
WholeWord bool `json:"wholeWord"`
|
||||||
CaseSensitive bool `json:"caseSensitive"`
|
CaseSensitive bool `json:"caseSensitive"`
|
||||||
@@ -26,12 +28,46 @@ type Tag struct {
|
|||||||
|
|
||||||
func defaultTags() []Tag {
|
func defaultTags() []Tag {
|
||||||
return []Tag{
|
return []Tag{
|
||||||
{ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
|
{ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
|
||||||
{ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
|
{ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
|
||||||
{ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var codeRe = regexp.MustCompile(`^[0-9]{2}$`)
|
||||||
|
|
||||||
|
// freeCode returns the lowest code from 01 to 99 not used by tags, or "" when
|
||||||
|
// all are taken. A code stays with its tag until the tag is deleted.
|
||||||
|
func freeCode(tags []Tag) string {
|
||||||
|
used := map[string]bool{}
|
||||||
|
for _, t := range tags {
|
||||||
|
used[t.Code] = true
|
||||||
|
}
|
||||||
|
for n := 1; n <= 99; n++ {
|
||||||
|
if c := fmt.Sprintf("%02d", n); !used[c] {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignCodes gives a code to the tags that have none (files written before
|
||||||
|
// codes existed) or share one with an earlier tag.
|
||||||
|
func assignCodes(tags []Tag) bool {
|
||||||
|
changed := false
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := range tags {
|
||||||
|
if codeRe.MatchString(tags[i].Code) && !seen[tags[i].Code] {
|
||||||
|
seen[tags[i].Code] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tags[i].Code = ""
|
||||||
|
tags[i].Code = freeCode(tags)
|
||||||
|
seen[tags[i].Code] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
// Colors of the default tags in earlier versions: still unchanged, they are
|
// Colors of the default tags in earlier versions: still unchanged, they are
|
||||||
// switched to the new pastel defaults when the file is loaded.
|
// switched to the new pastel defaults when the file is loaded.
|
||||||
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
|
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
|
||||||
@@ -67,11 +103,13 @@ func (e *codedError) Error() string {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"}
|
errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"}
|
||||||
|
errTooManyTags = &codedError{code: "too_many_tags", msg: "too many tags (99 at most)"}
|
||||||
colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t *Tag) validate() error {
|
func (t *Tag) validate() error {
|
||||||
t.Pattern = strings.TrimSpace(t.Pattern)
|
t.Pattern = strings.TrimSpace(t.Pattern)
|
||||||
|
t.Label = strings.TrimSpace(t.Label)
|
||||||
if t.Pattern == "" {
|
if t.Pattern == "" {
|
||||||
return &codedError{code: "pattern_required", msg: "the keyword is required"}
|
return &codedError{code: "pattern_required", msg: "the keyword is required"}
|
||||||
}
|
}
|
||||||
@@ -106,7 +144,7 @@ func LoadTagStore(path string) (*TagStore, error) {
|
|||||||
if err := json.Unmarshal(b, &s.tags); err != nil {
|
if err := json.Unmarshal(b, &s.tags); err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
if migrateDefaultColors(s.tags) {
|
if c1, c2 := migrateDefaultColors(s.tags), assignCodes(s.tags); c1 || c2 {
|
||||||
if err := s.save(); err != nil {
|
if err := s.save(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -143,6 +181,9 @@ func (s *TagStore) Create(t Tag) (Tag, error) {
|
|||||||
t.ID = newID()
|
t.ID = newID()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
if t.Code = freeCode(s.tags); t.Code == "" {
|
||||||
|
return t, errTooManyTags
|
||||||
|
}
|
||||||
s.tags = append(s.tags, t)
|
s.tags = append(s.tags, t)
|
||||||
return t, s.save()
|
return t, s.save()
|
||||||
}
|
}
|
||||||
@@ -156,6 +197,7 @@ func (s *TagStore) Update(id string, t Tag) (Tag, error) {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
for i := range s.tags {
|
for i := range s.tags {
|
||||||
if s.tags[i].ID == id {
|
if s.tags[i].ID == id {
|
||||||
|
t.Code = s.tags[i].Code // assigned by the server, never changed
|
||||||
s.tags[i] = t
|
s.tags[i] = t
|
||||||
return t, s.save()
|
return t, s.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagCodes(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "tags.json")
|
||||||
|
// File written before codes existed, with a duplicate code.
|
||||||
|
old := `[{"id":"a","pattern":"x","color":"#000000"},{"id":"b","code":"07","pattern":"y","color":"#000000"},{"id":"c","code":"07","pattern":"z","color":"#000000"}]`
|
||||||
|
if err := os.WriteFile(path, []byte(old), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := LoadTagStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := []string{}
|
||||||
|
for _, tg := range s.List() {
|
||||||
|
got = append(got, tg.Code)
|
||||||
|
}
|
||||||
|
if want := []string{"01", "07", "02"}; !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("migrated codes = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := s.Create(Tag{Pattern: "w", Color: "#000000"})
|
||||||
|
if err != nil || n.Code != "03" {
|
||||||
|
t.Fatalf("Create code = %q, %v; want 03", n.Code, err)
|
||||||
|
}
|
||||||
|
// The client cannot change a code.
|
||||||
|
u, err := s.Update("b", Tag{Code: "42", Pattern: "y2", Color: "#000000"})
|
||||||
|
if err != nil || u.Code != "07" {
|
||||||
|
t.Fatalf("Update code = %q, %v; want 07", u.Code, err)
|
||||||
|
}
|
||||||
|
// A deleted tag frees its code; the others keep theirs.
|
||||||
|
if err := s.Delete("a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, _ := s.Create(Tag{Pattern: "v", Color: "#000000"}); n.Code != "01" {
|
||||||
|
t.Fatalf("code after delete = %q, want 01", n.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codes are saved in the file.
|
||||||
|
s2, err := LoadTagStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := s2.List()[0].Code; got != "07" {
|
||||||
|
t.Fatalf("reloaded code = %q, want 07", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
-23
@@ -47,12 +47,16 @@ const I18N = {
|
|||||||
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
|
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
|
||||||
newTag: 'new',
|
newTag: 'new',
|
||||||
confirmDelete: (p) => `Delete tag "${p}"?`,
|
confirmDelete: (p) => `Delete tag "${p}"?`,
|
||||||
confirmReset: 'Replace all tags with the defaults (warning, error, ok)?',
|
confirmReset: 'Replace all tags with the defaults (warning, error)?',
|
||||||
|
presetAria: 'Add a preset', presetPick: '+ Preset…',
|
||||||
|
presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'),
|
||||||
|
presetsFileErr: (f) => `Invalid presets file ${f}, built-in presets used: `,
|
||||||
tagsLoadErr: 'Tags: ',
|
tagsLoadErr: 'Tags: ',
|
||||||
err_pattern_required: 'The keyword is required',
|
err_pattern_required: 'The keyword is required',
|
||||||
err_invalid_color: 'Invalid color (expected #rrggbb)',
|
err_invalid_color: 'Invalid color (expected #rrggbb)',
|
||||||
err_invalid_regex: 'Invalid regular expression',
|
err_invalid_regex: 'Invalid regular expression',
|
||||||
err_tag_not_found: 'Tag not found',
|
err_tag_not_found: 'Tag not found',
|
||||||
|
err_too_many_tags: 'Too many tags (99 at most)',
|
||||||
err_live_logsql: 'Live view is not available in LogsQL mode',
|
err_live_logsql: 'Live view is not available in LogsQL mode',
|
||||||
dateTime: 'Date & time',
|
dateTime: 'Date & time',
|
||||||
tzLabel: 'Time zone',
|
tzLabel: 'Time zone',
|
||||||
@@ -71,7 +75,7 @@ const I18N = {
|
|||||||
srcHost: 'Host system',
|
srcHost: 'Host system',
|
||||||
hostTitle: 'Host system logs',
|
hostTitle: 'Host system logs',
|
||||||
hostEnabled: 'Collect the system logs of this machine',
|
hostEnabled: 'Collect the system logs of this machine',
|
||||||
hostOff: 'Off: the system logs of the machine hosting Logstream are not collected.',
|
hostOff: 'Off: the system logs of the machine hosting LogStream are not collected.',
|
||||||
hostWaiting: 'Starting…',
|
hostWaiting: 'Starting…',
|
||||||
hostJournal: ({ n, r }) => `Reading the systemd journal (${n} files): ${r} entries since startup.`,
|
hostJournal: ({ n, r }) => `Reading the systemd journal (${n} files): ${r} entries since startup.`,
|
||||||
hostFiles: ({ n, r }) => `Following ${n} files of /var/log: ${r} lines since startup.`,
|
hostFiles: ({ n, r }) => `Following ${n} files of /var/log: ${r} lines since startup.`,
|
||||||
@@ -98,8 +102,8 @@ const I18N = {
|
|||||||
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
|
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
|
||||||
dockerNoMatch: 'No container',
|
dockerNoMatch: 'No container',
|
||||||
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
|
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
|
||||||
stLocked: 'excluded', stLockedTitle: 'Logstream itself, or label logstream.exclude=true',
|
stLocked: 'excluded', stLockedTitle: 'LogStream itself, or label logstream.exclude=true',
|
||||||
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: Logstream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
|
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: LogStream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
|
||||||
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
|
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
|
||||||
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
|
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
|
||||||
fUnit: 'systemd unit', fLogFile: 'log file',
|
fUnit: 'systemd unit', fLogFile: 'log file',
|
||||||
@@ -113,8 +117,9 @@ const I18N = {
|
|||||||
themeHelp: 'System follows the light/dark preference of your computer or phone.',
|
themeHelp: 'System follows the light/dark preference of your computer or phone.',
|
||||||
logDisplay: 'Log display', fontSize: 'Font size',
|
logDisplay: 'Log display', fontSize: 'Font size',
|
||||||
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
|
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
|
||||||
fontLabel: 'Font', fontSystem: 'System monospace (no download)',
|
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
|
||||||
fontHelp: 'Free fonts (SIL Open Font License) loaded by your browser from Bunny Fonts, a privacy-friendly European font service. Without internet access, the system font is used.',
|
fontLabel: 'Font', fontSystem: 'System monospace (no download)', fontBuiltin: 'built in, narrow',
|
||||||
|
fontHelp: 'Free fonts. The built-in ones (Inconsolata Condensed, the narrowest, Iosevka and Ubuntu Mono) are served by LogStream itself and work offline; they are narrow, so more text fits on each line. The others are loaded by your browser from Bunny Fonts, a privacy-friendly European font service; without internet access, the system font is used.',
|
||||||
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
||||||
dangerZone: 'Danger zone',
|
dangerZone: 'Danger zone',
|
||||||
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.',
|
||||||
@@ -142,7 +147,9 @@ const I18N = {
|
|||||||
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: '× Reset zoom', unitDay: 'd',
|
||||||
toTop: 'Back to top',
|
toTop: 'Back to top',
|
||||||
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colMsg: 'Message',
|
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
|
||||||
|
codesTitle: 'Codes of the color tags found in the message',
|
||||||
|
tagCodeTitle: 'Code shown on the log lines this tag matches',
|
||||||
colGrip: 'Drag to resize, double-click for the automatic width',
|
colGrip: 'Drag to resize, double-click for the automatic width',
|
||||||
resetCols: 'Reset column widths', colsReset: 'Column widths reset',
|
resetCols: 'Reset column widths', colsReset: 'Column widths reset',
|
||||||
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
|
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
|
||||||
@@ -191,12 +198,16 @@ const I18N = {
|
|||||||
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
|
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
|
||||||
newTag: 'nouveau',
|
newTag: 'nouveau',
|
||||||
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
|
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
|
||||||
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?',
|
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?',
|
||||||
|
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
|
||||||
|
presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'),
|
||||||
|
presetsFileErr: (f) => `Fichier de préréglages ${f} invalide, préréglages intégrés utilisés : `,
|
||||||
tagsLoadErr: 'Tags : ',
|
tagsLoadErr: 'Tags : ',
|
||||||
err_pattern_required: 'Le mot-clé est obligatoire',
|
err_pattern_required: 'Le mot-clé est obligatoire',
|
||||||
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
|
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
|
||||||
err_invalid_regex: 'Expression régulière invalide',
|
err_invalid_regex: 'Expression régulière invalide',
|
||||||
err_tag_not_found: 'Tag introuvable',
|
err_tag_not_found: 'Tag introuvable',
|
||||||
|
err_too_many_tags: 'Trop de tags (99 au maximum)',
|
||||||
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
|
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
|
||||||
dateTime: 'Date et heure',
|
dateTime: 'Date et heure',
|
||||||
tzLabel: 'Fuseau horaire',
|
tzLabel: 'Fuseau horaire',
|
||||||
@@ -215,7 +226,7 @@ const I18N = {
|
|||||||
srcHost: 'Système hôte',
|
srcHost: 'Système hôte',
|
||||||
hostTitle: 'Logs système de l\'hôte',
|
hostTitle: 'Logs système de l\'hôte',
|
||||||
hostEnabled: 'Collecter les logs système de cette machine',
|
hostEnabled: 'Collecter les logs système de cette machine',
|
||||||
hostOff: 'Désactivé : les logs système de la machine qui héberge Logstream ne sont pas collectés.',
|
hostOff: 'Désactivé : les logs système de la machine qui héberge LogStream ne sont pas collectés.',
|
||||||
hostWaiting: 'Démarrage…',
|
hostWaiting: 'Démarrage…',
|
||||||
hostJournal: ({ n, r }) => `Lecture du journal systemd (${n} fichiers) : ${r} entrées depuis le démarrage.`,
|
hostJournal: ({ n, r }) => `Lecture du journal systemd (${n} fichiers) : ${r} entrées depuis le démarrage.`,
|
||||||
hostFiles: ({ n, r }) => `Suivi de ${n} fichiers de /var/log : ${r} lignes depuis le démarrage.`,
|
hostFiles: ({ n, r }) => `Suivi de ${n} fichiers de /var/log : ${r} lignes depuis le démarrage.`,
|
||||||
@@ -242,8 +253,8 @@ const I18N = {
|
|||||||
dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.',
|
dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.',
|
||||||
dockerNoMatch: 'Aucun conteneur',
|
dockerNoMatch: 'Aucun conteneur',
|
||||||
stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté',
|
stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté',
|
||||||
stLocked: 'exclu', stLockedTitle: 'Logstream lui-même, ou étiquette logstream.exclude=true',
|
stLocked: 'exclu', stLockedTitle: 'LogStream lui-même, ou étiquette logstream.exclude=true',
|
||||||
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : Logstream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
|
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : LogStream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
|
||||||
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
|
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
|
||||||
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
|
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
|
||||||
fUnit: 'unité systemd', fLogFile: 'fichier de log',
|
fUnit: 'unité systemd', fLogFile: 'fichier de log',
|
||||||
@@ -257,8 +268,9 @@ const I18N = {
|
|||||||
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.',
|
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.',
|
||||||
logDisplay: 'Affichage des logs', fontSize: 'Taille du texte',
|
logDisplay: 'Affichage des logs', fontSize: 'Taille du texte',
|
||||||
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
|
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
|
||||||
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)',
|
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
|
||||||
fontHelp: 'Polices libres (licence SIL Open Font) chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée. Sans accès à internet, la police du système est utilisée.',
|
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', fontBuiltin: 'intégrée, étroite',
|
||||||
|
fontHelp: 'Polices libres. Les polices intégrées (Inconsolata Condensed, la plus étroite, Iosevka et Ubuntu Mono) sont servies par LogStream lui-même et fonctionnent hors ligne ; elles sont étroites, donc chaque ligne affiche plus de texte. Les autres sont chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée ; sans accès à internet, la police du système est utilisée.',
|
||||||
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
||||||
dangerZone: 'Zone de danger',
|
dangerZone: 'Zone de danger',
|
||||||
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.',
|
||||||
@@ -286,7 +298,9 @@ const I18N = {
|
|||||||
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: '× Annuler le zoom', unitDay: 'j',
|
||||||
toTop: 'Revenir en haut',
|
toTop: 'Revenir en haut',
|
||||||
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colMsg: 'Message',
|
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
|
||||||
|
codesTitle: 'Codes des tags de couleur trouvés dans le message',
|
||||||
|
tagCodeTitle: 'Code affiché sur les lignes de log où ce tag est trouvé',
|
||||||
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
|
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
|
||||||
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
|
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
|
||||||
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
|
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
|
||||||
@@ -437,6 +451,7 @@ const state = {
|
|||||||
rows: [], // displayed records, newest first
|
rows: [], // displayed records, newest first
|
||||||
pending: [], // live messages received while scrolled down
|
pending: [], // live messages received while scrolled down
|
||||||
tags: [],
|
tags: [],
|
||||||
|
presets: [], // preset groups from /api/presets
|
||||||
matchers: [], // compiled tags + search terms
|
matchers: [], // compiled tags + search terms
|
||||||
mode: store.get('mode', 'simple'),
|
mode: store.get('mode', 'simple'),
|
||||||
live: store.get('live', '1') === '1',
|
live: store.get('live', '1') === '1',
|
||||||
@@ -475,6 +490,7 @@ function setLang(next) {
|
|||||||
renderHisto();
|
renderHisto();
|
||||||
renderStats();
|
renderStats();
|
||||||
renderTagList();
|
renderTagList();
|
||||||
|
renderPresets();
|
||||||
renderTimeSettings();
|
renderTimeSettings();
|
||||||
renderPurge();
|
renderPurge();
|
||||||
renderInterface();
|
renderInterface();
|
||||||
@@ -519,16 +535,19 @@ $('#themeSwitch').addEventListener('click', (ev) => {
|
|||||||
|
|
||||||
/* ================= Log font and size ================= */
|
/* ================= Log font and size ================= */
|
||||||
|
|
||||||
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net).
|
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net), except the
|
||||||
|
// built-in ones (web/fonts, declared in style.css), which also work offline.
|
||||||
const LOG_FONTS = [
|
const LOG_FONTS = [
|
||||||
{ id: 'system' },
|
{ id: 'system' },
|
||||||
|
{ id: 'inconsolata-condensed', family: 'Inconsolata Condensed', builtin: true },
|
||||||
|
{ id: 'iosevka', family: 'Iosevka', builtin: true },
|
||||||
|
{ id: 'ubuntu-mono', family: 'Ubuntu Mono', builtin: true },
|
||||||
{ id: 'jetbrains-mono', family: 'JetBrains Mono' },
|
{ id: 'jetbrains-mono', family: 'JetBrains Mono' },
|
||||||
{ id: 'fira-code', family: 'Fira Code' },
|
{ id: 'fira-code', family: 'Fira Code' },
|
||||||
{ id: 'source-code-pro', family: 'Source Code Pro' },
|
{ id: 'source-code-pro', family: 'Source Code Pro' },
|
||||||
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono' },
|
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono' },
|
||||||
{ id: 'cascadia-code', family: 'Cascadia Code' },
|
{ id: 'cascadia-code', family: 'Cascadia Code' },
|
||||||
{ id: 'roboto-mono', family: 'Roboto Mono' },
|
{ id: 'roboto-mono', family: 'Roboto Mono' },
|
||||||
{ id: 'ubuntu-mono', family: 'Ubuntu Mono' },
|
|
||||||
{ id: 'inconsolata', family: 'Inconsolata' },
|
{ id: 'inconsolata', family: 'Inconsolata' },
|
||||||
{ id: 'red-hat-mono', family: 'Red Hat Mono' },
|
{ id: 'red-hat-mono', family: 'Red Hat Mono' },
|
||||||
{ id: 'noto-sans-mono', family: 'Noto Sans Mono' },
|
{ id: 'noto-sans-mono', family: 'Noto Sans Mono' },
|
||||||
@@ -536,7 +555,8 @@ const LOG_FONTS = [
|
|||||||
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
|
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
|
||||||
];
|
];
|
||||||
const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' };
|
const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' };
|
||||||
const ui = { font: 'system', size: 'medium' };
|
const LOG_DENSITIES = ['normal', 'compact'];
|
||||||
|
const ui = { font: 'system', size: 'medium', density: 'normal' };
|
||||||
|
|
||||||
function applyLogFont(id) {
|
function applyLogFont(id) {
|
||||||
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
|
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
|
||||||
@@ -546,6 +566,10 @@ function applyLogFont(id) {
|
|||||||
root.removeProperty('--log-font');
|
root.removeProperty('--log-font');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (f.builtin) {
|
||||||
|
root.setProperty('--log-font', `'${f.family}', var(--mono)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`;
|
const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`;
|
||||||
let link = document.getElementById('logFontCss');
|
let link = document.getElementById('logFontCss');
|
||||||
if (!link) {
|
if (!link) {
|
||||||
@@ -561,13 +585,22 @@ function applyLogSize(id) {
|
|||||||
document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]);
|
document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyLogDensity(id) {
|
||||||
|
ui.density = LOG_DENSITIES.includes(id) ? id : 'normal';
|
||||||
|
document.documentElement.dataset.density = ui.density;
|
||||||
|
}
|
||||||
|
|
||||||
function renderInterface() {
|
function renderInterface() {
|
||||||
renderThemeSwitch();
|
renderThemeSwitch();
|
||||||
for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) {
|
for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) {
|
||||||
b.setAttribute('aria-checked', String(b.dataset.size === ui.size));
|
b.setAttribute('aria-checked', String(b.dataset.size === ui.size));
|
||||||
}
|
}
|
||||||
|
for (const b of document.querySelectorAll('#densitySwitch [data-density]')) {
|
||||||
|
b.setAttribute('aria-checked', String(b.dataset.density === ui.density));
|
||||||
|
}
|
||||||
const sel = $('#fontSelect');
|
const sel = $('#fontSelect');
|
||||||
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(f.family || t('fontSystem'))}</option>`).join('');
|
const fontName = (f) => (!f.family ? t('fontSystem') : f.builtin ? `${f.family} (${t('fontBuiltin')})` : f.family);
|
||||||
|
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(fontName(f))}</option>`).join('');
|
||||||
sel.value = ui.font;
|
sel.value = ui.font;
|
||||||
renderFontPreview();
|
renderFontPreview();
|
||||||
renderHistoSettings();
|
renderHistoSettings();
|
||||||
@@ -591,6 +624,13 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
|
|||||||
store.set('logSize', ui.size);
|
store.set('logSize', ui.size);
|
||||||
renderInterface();
|
renderInterface();
|
||||||
});
|
});
|
||||||
|
$('#densitySwitch').addEventListener('click', (ev) => {
|
||||||
|
const b = ev.target.closest('[data-density]');
|
||||||
|
if (!b) return;
|
||||||
|
applyLogDensity(b.dataset.density);
|
||||||
|
store.set('logDensity', ui.density);
|
||||||
|
renderInterface();
|
||||||
|
});
|
||||||
$('#fontSelect').addEventListener('change', (ev) => {
|
$('#fontSelect').addEventListener('change', (ev) => {
|
||||||
applyLogFont(ev.target.value);
|
applyLogFont(ev.target.value);
|
||||||
store.set('logFont', ui.font);
|
store.set('logFont', ui.font);
|
||||||
@@ -681,7 +721,11 @@ function compileMatchers() {
|
|||||||
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
|
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
|
||||||
if (tag.wholeWord) src = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
|
if (tag.wholeWord) src = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
|
||||||
try {
|
try {
|
||||||
out.push({ re: new RegExp(src, 'gu' + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` });
|
// A regex may name a group "hl" to color only that part of the match.
|
||||||
|
const hl = tag.regex && /\(\?P?<hl>/.test(tag.pattern);
|
||||||
|
if (hl) src = src.replace(/\(\?P<hl>/g, '(?<hl>');
|
||||||
|
out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">`,
|
||||||
|
code: tag.code, name: tag.label || tag.pattern });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
|
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
|
||||||
}
|
}
|
||||||
@@ -706,6 +750,8 @@ function highlight(text) {
|
|||||||
let x;
|
let x;
|
||||||
while ((x = m.re.exec(text)) !== null) {
|
while ((x = m.re.exec(text)) !== null) {
|
||||||
if (x[0] === '') { m.re.lastIndex++; continue; }
|
if (x[0] === '') { m.re.lastIndex++; continue; }
|
||||||
|
const g = x.indices?.groups?.hl;
|
||||||
|
if (g) { if (g[1] > g[0]) spans.push({ s: g[0], e: g[1], prio, html: m.html }); continue; }
|
||||||
spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html });
|
spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -721,6 +767,30 @@ function highlight(text) {
|
|||||||
return out + esc(text.slice(pos));
|
return out + esc(text.slice(pos));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Badges with the codes of the tags found in a message, in list order. The
|
||||||
|
// column has room for 3: beyond that, 2 badges and "+N" (all in the tooltip).
|
||||||
|
function codesHTML(text) {
|
||||||
|
text = String(text ?? '');
|
||||||
|
const found = [];
|
||||||
|
for (const m of state.matchers) {
|
||||||
|
if (!m.code || !text) continue;
|
||||||
|
m.re.lastIndex = 0;
|
||||||
|
let x;
|
||||||
|
while ((x = m.re.exec(text)) !== null) {
|
||||||
|
if (x[0] === '') { m.re.lastIndex++; continue; }
|
||||||
|
const g = x.indices?.groups?.hl;
|
||||||
|
if (!x.indices?.groups || g) { found.push(m); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found.length) return '';
|
||||||
|
const shown = found.length > 3 ? found.slice(0, 2) : found;
|
||||||
|
const title = found.map((m) => `${m.code} ${m.name}`).join('\n');
|
||||||
|
return `<span title="${esc(title)}">`
|
||||||
|
+ shown.map((m) => `<b>${esc(m.code)}</b>`).join('')
|
||||||
|
+ (found.length > 3 ? `<b class="more">+${found.length - 2}</b>` : '')
|
||||||
|
+ '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= List rendering ================= */
|
/* ================= List rendering ================= */
|
||||||
|
|
||||||
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
|
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
|
||||||
@@ -746,6 +816,7 @@ function rowHTML(r, isNew) {
|
|||||||
+ (r.app
|
+ (r.app
|
||||||
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
|
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
|
||||||
: '<span class="app"></span>')
|
: '<span class="app"></span>')
|
||||||
|
+ `<span class="codes">${codesHTML(r._msg)}</span>`
|
||||||
+ `<div class="msg">${highlight(r._msg)}</div>`
|
+ `<div class="msg">${highlight(r._msg)}</div>`
|
||||||
+ '</article>';
|
+ '</article>';
|
||||||
}
|
}
|
||||||
@@ -812,7 +883,9 @@ function renderList() {
|
|||||||
function rehighlight() {
|
function rehighlight() {
|
||||||
for (const row of list.children) {
|
for (const row of list.children) {
|
||||||
const r = recOf.get(row);
|
const r = recOf.get(row);
|
||||||
if (r) row.querySelector('.msg').innerHTML = highlight(r._msg);
|
if (!r) continue;
|
||||||
|
row.querySelector('.msg').innerHTML = highlight(r._msg);
|
||||||
|
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1978,6 +2051,25 @@ $('#purgeBtn').addEventListener('click', async () => {
|
|||||||
|
|
||||||
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
|
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
|
||||||
|
|
||||||
|
// Ready-made tags, from the presets file (or the built-in presets.json):
|
||||||
|
// see docs/presets.md. A text is a string or an object per language.
|
||||||
|
const pickText = (x) => (typeof x === 'string' ? x : (x?.[lang] ?? x?.en ?? Object.values(x || {})[0] ?? ''));
|
||||||
|
|
||||||
|
async function loadPresets() {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/presets');
|
||||||
|
state.presets = res.groups || [];
|
||||||
|
if (res.error) toast(t('presetsFileErr', res.file) + res.error);
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
renderPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPresets() {
|
||||||
|
$('#presetTags').innerHTML = `<option value="">${esc(t('presetPick'))}</option>`
|
||||||
|
+ state.presets.map((g) => `<optgroup label="${esc(pickText(g.group))}">`
|
||||||
|
+ g.presets.map((p) => `<option value="${esc(p.id)}">${esc(pickText(p.name))}</option>`).join('') + '</optgroup>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTags() {
|
async function loadTags() {
|
||||||
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
|
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
|
||||||
compileMatchers();
|
compileMatchers();
|
||||||
@@ -1987,9 +2079,10 @@ function tagRowHTML(tag) {
|
|||||||
const opt = (field, label, title) =>
|
const opt = (field, label, title) =>
|
||||||
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`;
|
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`;
|
||||||
return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
|
return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
|
||||||
|
<span class="tag-code" title="${esc(t('tagCodeTitle'))}">${esc(tag.code || '··')}</span>
|
||||||
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}">
|
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}">
|
||||||
<input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
|
<input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
|
||||||
<span class="preview"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.pattern || t('preview'))}</mark></span>
|
<span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span>
|
||||||
<div class="opts">
|
<div class="opts">
|
||||||
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))}
|
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))}
|
||||||
${opt('caseSensitive', 'Aa', t('optCaseTitle'))}
|
${opt('caseSensitive', 'Aa', t('optCaseTitle'))}
|
||||||
@@ -2032,7 +2125,7 @@ $('#tagList').addEventListener('input', (ev) => {
|
|||||||
tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
|
tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
|
||||||
rowEl.classList.toggle('off', !tag.enabled);
|
rowEl.classList.toggle('off', !tag.enabled);
|
||||||
const mark = rowEl.querySelector('.preview mark');
|
const mark = rowEl.querySelector('.preview mark');
|
||||||
mark.textContent = tag.pattern || t('preview');
|
mark.textContent = tag.label || tag.pattern || t('preview');
|
||||||
mark.setAttribute('style', tagStyle(tag.color));
|
mark.setAttribute('style', tagStyle(tag.color));
|
||||||
scheduleSave(tag, rowEl);
|
scheduleSave(tag, rowEl);
|
||||||
applyTags();
|
applyTags();
|
||||||
@@ -2065,6 +2158,25 @@ $('#addTag').addEventListener('click', async () => {
|
|||||||
} catch (e) { toast(e.message); }
|
} catch (e) { toast(e.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Adds a preset group, skipping tags whose pattern is already in the list.
|
||||||
|
$('#presetTags').addEventListener('change', async (ev) => {
|
||||||
|
const preset = state.presets.flatMap((g) => g.presets).find((p) => p.id === ev.target.value);
|
||||||
|
ev.target.value = '';
|
||||||
|
if (!preset) return;
|
||||||
|
let added = 0;
|
||||||
|
try {
|
||||||
|
for (const p of preset.tags) {
|
||||||
|
if (state.tags.some((x) => x.pattern === p.pattern)) continue;
|
||||||
|
const tag = { ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true };
|
||||||
|
state.tags.push(await api('/api/tags', { method: 'POST', body: tag }));
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
renderTagList();
|
||||||
|
applyTags();
|
||||||
|
toast(t('presetAdded', added));
|
||||||
|
});
|
||||||
|
|
||||||
$('#resetTags').addEventListener('click', async () => {
|
$('#resetTags').addEventListener('click', async () => {
|
||||||
if (!confirm(t('confirmReset'))) return;
|
if (!confirm(t('confirmReset'))) return;
|
||||||
try {
|
try {
|
||||||
@@ -2077,6 +2189,7 @@ $('#resetTags').addEventListener('click', async () => {
|
|||||||
$('#settingsBtn').addEventListener('click', () => {
|
$('#settingsBtn').addEventListener('click', () => {
|
||||||
renderTimeSettings();
|
renderTimeSettings();
|
||||||
renderTagList();
|
renderTagList();
|
||||||
|
loadPresets();
|
||||||
renderInterface();
|
renderInterface();
|
||||||
loadPurgeStatus();
|
loadPurgeStatus();
|
||||||
loadSyslog();
|
loadSyslog();
|
||||||
@@ -2098,12 +2211,13 @@ $('#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'));
|
||||||
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', '');
|
||||||
|
|
||||||
// With OIDC login, show who is logged in and the log out button.
|
// With a login (local or OIDC), show who is logged in and the log out button.
|
||||||
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 || !me.user) return;
|
||||||
const btn = $('#logoutBtn');
|
const btn = $('#logoutBtn');
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2006 The Inconsolata Project Authors (https://github.com/cyrealtype/Inconsolata)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
Copyright (c) 2015-2023, Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
|
||||||
|
SIL Open Font License v1.1
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
----------
|
||||||
|
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
|
||||||
|
Definitions
|
||||||
|
-------------
|
||||||
|
|
||||||
|
`"Font Software"` refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
`"Reserved Font Name"` refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
`"Original Version"` refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
`"Modified Version"` refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
`"Author"` refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
|
||||||
|
Permission & Conditions
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1. Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2. Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3. No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5. The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Termination
|
||||||
|
-----------
|
||||||
|
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-------------------------------
|
||||||
|
UBUNTU FONT LICENCE Version 1.0
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
This licence allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely. The fonts, including any derivative works, can be
|
||||||
|
bundled, embedded, and redistributed provided the terms of this licence
|
||||||
|
are met. The fonts and derivatives, however, cannot be released under
|
||||||
|
any other licence. The requirement for fonts to remain under this
|
||||||
|
licence does not require any document created using the fonts or their
|
||||||
|
derivatives to be published under this licence, as long as the primary
|
||||||
|
purpose of the document is not to be a vehicle for the distribution of
|
||||||
|
the fonts.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this licence and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components
|
||||||
|
as received under this licence.
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to
|
||||||
|
a new environment.
|
||||||
|
|
||||||
|
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||||
|
copyright ownership of the Font Software.
|
||||||
|
|
||||||
|
"Substantially Changed" refers to Modified Versions which can be easily
|
||||||
|
identified as dissimilar to the Font Software by users of the Font
|
||||||
|
Software comparing the Original Version with the Modified Version.
|
||||||
|
|
||||||
|
To "Propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification and with or without charging
|
||||||
|
a redistribution fee), making available to the public, and in some
|
||||||
|
countries other activities as well.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
This licence does not grant any rights under trademark law and all such
|
||||||
|
rights are reserved.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
copy of the Font Software, to propagate the Font Software, subject to
|
||||||
|
the below conditions:
|
||||||
|
|
||||||
|
1) Each copy of the Font Software must contain the above copyright
|
||||||
|
notice and this licence. These can be included either as stand-alone
|
||||||
|
text files, human-readable headers or in the appropriate machine-
|
||||||
|
readable metadata fields within text or binary files as long as those
|
||||||
|
fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
2) The font name complies with the following:
|
||||||
|
(a) The Original Version must retain its name, unmodified.
|
||||||
|
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||||
|
avoid use of the name of the Original Version or similar names entirely.
|
||||||
|
(c) Modified Versions which are not Substantially Changed must be
|
||||||
|
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||||
|
additional naming elements to distinguish the Modified Version from the
|
||||||
|
Original Version. The name of such Modified Versions must be the name of
|
||||||
|
the Original Version, with "derivative X" where X represents the name of
|
||||||
|
the new work, appended to that name.
|
||||||
|
|
||||||
|
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||||
|
Font Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except (i) as required by this licence, (ii) to
|
||||||
|
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||||
|
their explicit written permission.
|
||||||
|
|
||||||
|
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||||
|
be distributed entirely under this licence, and must not be distributed
|
||||||
|
under any other licence. The requirement for fonts to remain under this
|
||||||
|
licence does not affect any document created using the Font Software,
|
||||||
|
except any version of the Font Software extracted from a document
|
||||||
|
created using the Font Software may only be distributed under this
|
||||||
|
licence.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This licence becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||||
|
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||||
|
DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
-3
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Logstream</title>
|
<title>LogStream</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
<script>
|
<script>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
||||||
<span>Logstream</span>
|
<span>LogStream</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
|
<span data-col="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Filters</span></span>
|
||||||
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
|
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
|
||||||
</div>
|
</div>
|
||||||
<main id="list" class="list"></main>
|
<main id="list" class="list"></main>
|
||||||
@@ -188,7 +189,12 @@
|
|||||||
<p class="muted small" data-i18n="tagsHelp"></p>
|
<p class="muted small" data-i18n="tagsHelp"></p>
|
||||||
<div id="tagList" class="tag-list"></div>
|
<div id="tagList" class="tag-list"></div>
|
||||||
<footer>
|
<footer>
|
||||||
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
|
<span class="tag-add">
|
||||||
|
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
|
||||||
|
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
|
||||||
|
<option value="" data-i18n="presetPick">+ Preset…</option>
|
||||||
|
</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>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
@@ -271,6 +277,11 @@
|
|||||||
<button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button>
|
<button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button>
|
||||||
<button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button>
|
<button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="lbl" data-i18n="density">Density</span>
|
||||||
|
<div id="densitySwitch" class="seg" role="radiogroup" data-i18n-aria="density">
|
||||||
|
<button type="button" role="radio" data-density="normal" data-i18n="densityNormal">Normal</button>
|
||||||
|
<button type="button" role="radio" data-density="compact" data-i18n="densityCompact">Compact</button>
|
||||||
|
</div>
|
||||||
<label for="fontSelect" data-i18n="fontLabel">Font</label>
|
<label for="fontSelect" data-i18n="fontLabel">Font</label>
|
||||||
<select id="fontSelect" class="field"></select>
|
<select id="fontSelect" class="field"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>LogStream</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script>
|
||||||
|
// Same saved theme and language as the UI, applied before first paint.
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('logstream.theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
||||||
|
var l = localStorage.getItem('logstream.lang');
|
||||||
|
if (l) document.documentElement.lang = l;
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-tools">
|
||||||
|
<div class="seg" role="radiogroup" id="langSwitch">
|
||||||
|
<button type="button" role="radio" data-lang="fr">FR</button>
|
||||||
|
<button type="button" role="radio" data-lang="en">EN</button>
|
||||||
|
</div>
|
||||||
|
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
|
||||||
|
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||||
|
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="login-card">
|
||||||
|
<img id="logo" class="login-logo" src="auth/logo" alt="" hidden>
|
||||||
|
<div class="brand">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
||||||
|
<span>LogStream</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted" data-i18n="intro">Sign in to view the logs.</p>
|
||||||
|
|
||||||
|
<form method="post" action="auth/login">
|
||||||
|
<input type="hidden" name="r" id="ret">
|
||||||
|
<label for="user" data-i18n="user">User</label>
|
||||||
|
<input id="user" name="user" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
||||||
|
<label for="pass" data-i18n="pass">Password</label>
|
||||||
|
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
|
||||||
|
<p id="err" class="login-err" role="alert" data-i18n="error" hidden>Wrong user or password.</p>
|
||||||
|
<button class="btn primary" type="submit" data-i18n="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var I18N = {
|
||||||
|
en: { intro: 'Sign in to view the logs.', user: 'User', pass: 'Password', submit: 'Sign in',
|
||||||
|
error: 'Wrong user or password.', theme: 'Light / dark theme' },
|
||||||
|
fr: { intro: 'Connectez-vous pour consulter les logs.', user: 'Utilisateur', pass: 'Mot de passe',
|
||||||
|
submit: 'Se connecter', error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre' },
|
||||||
|
};
|
||||||
|
function get(k) { try { return localStorage.getItem('logstream.' + k); } catch (e) { return null; } }
|
||||||
|
function set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch (e) {} }
|
||||||
|
var lang = get('lang');
|
||||||
|
if (!I18N[lang]) lang = /^fr\b/i.test(navigator.language || '') ? 'fr' : 'en';
|
||||||
|
|
||||||
|
function applyLang() {
|
||||||
|
var d = I18N[lang];
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = d[el.dataset.i18n]; });
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.title = d[el.dataset.i18nTitle]; });
|
||||||
|
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', d[el.dataset.i18nAria]); });
|
||||||
|
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); });
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { lang = b.dataset.lang; set('lang', lang); applyLang(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('themeBtn').addEventListener('click', function () {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
root.dataset.theme = dark ? 'light' : 'dark';
|
||||||
|
set('theme', root.dataset.theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
document.getElementById('ret').value = q.get('r') || '/';
|
||||||
|
document.getElementById('err').hidden = q.get('e') !== '1';
|
||||||
|
|
||||||
|
// LOGIN_LOGO: shown only when the server has one.
|
||||||
|
var logo = document.getElementById('logo');
|
||||||
|
logo.addEventListener('load', function () { logo.hidden = false; });
|
||||||
|
if (logo.complete && logo.naturalWidth) logo.hidden = false;
|
||||||
|
|
||||||
|
applyLang();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+69
-10
@@ -1,3 +1,13 @@
|
|||||||
|
/* Narrow monospace fonts served by LogStream itself, so they also work offline (licences in
|
||||||
|
web/fonts). Latin subset only; other scripts fall back to --mono. Inconsolata Condensed is
|
||||||
|
Inconsolata's variable font pinned at width 75 (0.4em per character, others 0.5em). */
|
||||||
|
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
|
||||||
|
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
|
||||||
|
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
|
||||||
|
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
|
||||||
|
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
|
||||||
|
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
|
||||||
|
|
||||||
/* ---------- Theme: everything goes through these variables ---------- */
|
/* ---------- Theme: everything goes through these variables ---------- */
|
||||||
:root {
|
:root {
|
||||||
--bg: #f6f7f9;
|
--bg: #f6f7f9;
|
||||||
@@ -38,6 +48,8 @@
|
|||||||
--tag-warning-text: #111827;
|
--tag-warning-text: #111827;
|
||||||
|
|
||||||
--log-size: 12.5px; /* Settings > Interface > Font size */
|
--log-size: 12.5px; /* Settings > Interface > Font size */
|
||||||
|
--codes-w: 4.9rem; /* tag codes column: room for 3 badges */
|
||||||
|
--code-bg: #858c97; /* tag code badges */
|
||||||
/* --log-font is set by Settings > Interface > Font (defaults to --mono) */
|
/* --log-font is set by Settings > Interface > Font (defaults to --mono) */
|
||||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
@@ -296,13 +308,13 @@ body.busy .progress::after {
|
|||||||
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||||
.list:empty { display: none; }
|
.list:empty { display: none; }
|
||||||
|
|
||||||
/* Columns: received, message time, severity, host, app, message. Widths come from
|
/* Columns: received, message time, severity, host, app, tag codes, message. Widths come from
|
||||||
--col-* (set on #table when a column has been resized, see app.js), shared by
|
--col-* (set on #table when a column has been resized, see app.js), shared by
|
||||||
the header and every row through subgrid so that the columns line up. */
|
the header and every row through subgrid so that the columns line up. */
|
||||||
.table {
|
.table {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
|
grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
|
||||||
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) minmax(0, 1fr);
|
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) var(--codes-w) minmax(0, 1fr);
|
||||||
column-gap: 12px;
|
column-gap: 12px;
|
||||||
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */
|
overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */
|
||||||
@@ -331,7 +343,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr;
|
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) var(--codes-w) 1fr;
|
||||||
gap: 0 12px; align-items: baseline;
|
gap: 0 12px; align-items: baseline;
|
||||||
padding: 5px 14px 5px 11px;
|
padding: 5px 14px 5px 11px;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
@@ -341,6 +353,10 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.row:last-child { border-bottom: 0; }
|
.row:last-child { border-bottom: 0; }
|
||||||
|
/* Settings > Interface > Density: compact fits about 50% more lines on screen */
|
||||||
|
:root[data-density="compact"] .row { padding-top: 1px; padding-bottom: 1px; line-height: 1.25; }
|
||||||
|
:root[data-density="compact"] .row .sev { padding-top: 0; padding-bottom: 0; line-height: 1.3; }
|
||||||
|
:root[data-density="compact"] .row .codes b { padding-top: 0; padding-bottom: 0; line-height: 1.2; }
|
||||||
.row:hover { background: var(--row-hover); }
|
.row:hover { background: var(--row-hover); }
|
||||||
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.row.new { animation: flash 1.2s ease-out; }
|
.row.new { animation: flash 1.2s ease-out; }
|
||||||
@@ -372,6 +388,17 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
|
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
|
||||||
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
|
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
|
||||||
.row .host[data-act], .row .app[data-act] { cursor: pointer; }
|
.row .host[data-act], .row .app[data-act] { cursor: pointer; }
|
||||||
|
/* Codes of the tags found in the message: grey badges, fixed size */
|
||||||
|
.row .codes { white-space: nowrap; overflow: hidden; }
|
||||||
|
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
|
||||||
|
.row .codes b, .tag-code {
|
||||||
|
min-width: 2.2ch; padding: 1px 4px; border-radius: 5px; text-align: center;
|
||||||
|
/* same font as the severity badges (the log font) */
|
||||||
|
font-family: var(--log-font, var(--mono)); font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
|
||||||
|
line-height: 1.35; font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--code-bg); color: #0b0f17;
|
||||||
|
}
|
||||||
|
.row .codes b.more { background: none; box-shadow: inset 0 0 0 1px var(--code-bg); color: var(--muted); }
|
||||||
.row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
.row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
|
||||||
mark.tag {
|
mark.tag {
|
||||||
@@ -475,6 +502,7 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
|
|||||||
.preview-list .row .host { grid-area: host; }
|
.preview-list .row .host { grid-area: host; }
|
||||||
.preview-list .row .app { grid-area: app; display: block; }
|
.preview-list .row .app { grid-area: app; display: block; }
|
||||||
.preview-list .row .msg { grid-area: msg; }
|
.preview-list .row .msg { grid-area: msg; }
|
||||||
|
.preview-list .row .codes { display: none; }
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
|
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
|
||||||
@@ -578,6 +606,8 @@ 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; }
|
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
|
||||||
|
.tag-add { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.tag-add select.field { width: 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 {
|
||||||
@@ -590,11 +620,12 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||||
.tag-row {
|
.tag-row {
|
||||||
display: grid; align-items: center; gap: 8px 10px;
|
display: grid; align-items: center; gap: 8px 10px;
|
||||||
grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px;
|
grid-template-columns: auto 38px minmax(8rem, 1fr) 7.5rem auto 36px;
|
||||||
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
|
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
}
|
}
|
||||||
.tag-row.off { opacity: .55; }
|
.tag-row.off { opacity: .55; }
|
||||||
|
.tag-row .tag-code { font-size: 12px; padding: 3px 6px; cursor: default; }
|
||||||
.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: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
|
||||||
background: none; cursor: pointer;
|
background: none; cursor: pointer;
|
||||||
@@ -637,8 +668,8 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.table .list { display: block; margin: 0; }
|
.table .list { display: block; margin: 0; }
|
||||||
.list-head { display: none; }
|
.list-head { display: none; }
|
||||||
.row, .table .row {
|
.row, .table .row {
|
||||||
grid-template-columns: auto auto 1fr;
|
grid-template-columns: auto auto 1fr auto;
|
||||||
grid-template-areas: "rcv sev host" "msg msg msg";
|
grid-template-areas: "rcv sev host codes" "msg msg msg msg";
|
||||||
gap: 3px 8px; padding: 8px 12px 8px 10px;
|
gap: 3px 8px; padding: 8px 12px 8px 10px;
|
||||||
}
|
}
|
||||||
.row time.rcv { grid-area: rcv; }
|
.row time.rcv { grid-area: rcv; }
|
||||||
@@ -646,6 +677,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.row .sev { grid-area: sev; }
|
.row .sev { grid-area: sev; }
|
||||||
.row .host { grid-area: host; justify-self: end; max-width: 100%; }
|
.row .host { grid-area: host; justify-self: end; max-width: 100%; }
|
||||||
.row .app { display: none; }
|
.row .app { display: none; }
|
||||||
|
.row .codes { grid-area: codes; }
|
||||||
.row .msg { grid-area: msg; }
|
.row .msg { grid-area: msg; }
|
||||||
.details { grid-column: 1 / -1; }
|
.details { grid-column: 1 / -1; }
|
||||||
.details dl { grid-template-columns: 1fr; }
|
.details dl { grid-template-columns: 1fr; }
|
||||||
@@ -663,12 +695,39 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
}
|
}
|
||||||
.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: 0 16px 18px; }
|
||||||
#sizeSwitch { display: flex; }
|
#sizeSwitch, #densitySwitch { display: flex; }
|
||||||
#sizeSwitch 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; }
|
||||||
.field-grid select { margin-bottom: 6px; }
|
.field-grid select { margin-bottom: 6px; }
|
||||||
.status { padding: 6px 16px; }
|
.status { padding: 6px 16px; }
|
||||||
.tag-row { grid-template-columns: 38px 1fr 36px; }
|
.tag-row { grid-template-columns: auto 38px 1fr 36px; }
|
||||||
.tag-row .preview { display: none; }
|
.tag-row .preview { display: none; }
|
||||||
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
||||||
.tag-row [data-del] { grid-column: 3; grid-row: 1; }
|
.tag-row [data-del] { grid-column: 4; grid-row: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Login page (AUTH_MODE=local) ---------- */
|
||||||
|
body.login-page {
|
||||||
|
min-height: 100vh; padding: 16px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.login-tools { position: fixed; top: 12px; right: 16px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.login-card {
|
||||||
|
width: 100%; max-width: 360px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.login-logo { max-width: 200px; max-height: 96px; object-fit: contain; margin-bottom: 6px; }
|
||||||
|
.login-card .brand { font-size: 20px; }
|
||||||
|
.login-card .brand svg { width: 32px; height: 32px; }
|
||||||
|
.login-card > p { margin: 0 0 8px; text-align: center; }
|
||||||
|
.login-card form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.login-card label { font-size: 13px; font-weight: 550; }
|
||||||
|
.login-card input {
|
||||||
|
height: 38px; padding: 0 11px; margin-bottom: 6px;
|
||||||
|
border: 1px solid var(--border); border-radius: 9px; background: var(--bg);
|
||||||
|
}
|
||||||
|
.login-card input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
.login-card .btn.primary { height: 38px; justify-content: center; margin-top: 6px; font-size: 14px; }
|
||||||
|
.login-err { margin: 0; color: var(--sev-err); font-size: 13px; }
|
||||||
Reference in new issue
Block a user