Author SHA1 Message Date
cedricandClaude Opus 5.5 3c25b1e4e2 Default tags: keep warning and error, move ok to the Log levels preset
Existing tags.json files are unchanged; only new installs and "Restore
default tags" get the shorter list.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:35:20 +02:00
claude Bot 1a21656546 Merge pull request 'Préréglages de tags couleur pour les logs HTTP/HTTPS' (#10) from feat/filtres-http into main 2026-10-03 15:17:58 +02:00
26 changed files with 1240 additions and 93 deletions

No files matched your search

+5
View File
@@ -12,6 +12,11 @@ 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
+1 -1
View File
@@ -4,7 +4,7 @@
FROM golang:1.27.1-alpine3.24 AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
COPY *.go presets.json ./
COPY web ./web
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream .
+25 -12
View File
@@ -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.
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
et le message. Un clic sur un hôte ou une application filtre dessus.
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application,
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
l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même
@@ -225,27 +225,38 @@ couleur, est mémorisé par navigateur.
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
`/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
couleurs pastel.
Le menu *+ Préréglage…* ajoute des tags tout faits pour les logs d'accès HTTP/HTTPS (nginx et
Apache common/combined, Traefik CLF et JSON, Caddy JSON, HAProxy httplog) : codes de statut
(2xx vert, 3xx bleu, 4xx orange, 5xx rouge), méthodes, sondes et attaques (`wp-login.php`,
`/.env`, `../`…), robots et scripts, erreurs TLS et proxy. Les tags déjà présents ne sont pas
ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression
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**
- *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.
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande) et police :
la police monospace du système, ou l'une des 12 polices libres conçues pour le texte dense
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande), densité
(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,
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
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
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
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
@@ -341,6 +352,7 @@ résolutions.
| `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 |
| `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_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 |
@@ -407,6 +419,7 @@ Pour mettre à jour l'une d'elles :
| `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` |
| `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/*` |
| `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 |
+25 -13
View File
@@ -72,7 +72,7 @@ message, host and app. Words are combined with AND.
The live view is disabled in this mode.
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.
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
@@ -205,24 +205,34 @@ remembered per browser.
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`
(`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
earlier versions are switched to the pastel ones automatically.
The *+ Preset…* menu adds ready-made tags for HTTP/HTTPS access logs (nginx and Apache
common/combined, Traefik CLF and JSON, Caddy JSON, HAProxy httplog): status codes (2xx green,
3xx blue, 4xx orange, 5xx red), methods, probes and attacks (`wp-login.php`, `/.env`,
`../`…), bots and scripts, TLS and proxy errors. Tags already in the list are skipped, and the
added tags can be edited like any other. In a regular expression, a group named `hl`
`warning` (orange) and `error` (red); `ok` (green) is in the *Log levels* preset. Default
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**
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
button in the header switches between light and dark.
- *Log display*: font size (tiny, small, medium, large) and font: the system monospace
font, or one of 12 free fonts made for dense text (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). They are loaded by the browser from
- *Log display*: font size (tiny, small, medium, large), density (normal, or compact to
fit about 50% more lines on screen) and font: the system monospace font, one of 3 narrow
built-in fonts served by LogStream itself, which work offline (Inconsolata Condensed, the
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
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
`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.
@@ -311,6 +321,7 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
| `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_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 |
@@ -374,6 +385,7 @@ To update one of them:
| `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 |
| `tags.go` | color tag storage |
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
| `api.go` | `/api/*` HTTP routes |
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
+6
View File
@@ -16,6 +16,7 @@ type API struct {
store *Store
hub *Hub
tags *TagStore
presets string // presets file, built-in presets when missing
rdns *ReverseDNS
allowPurge bool
exportMax int
@@ -34,6 +35,7 @@ func (a *API) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/tags", a.listTags)
mux.HandleFunc("POST /api/tags", a.createTag)
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("DELETE /api/tags/{id}", a.deleteTag)
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())
}
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) {
var t Tag
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t)
+3
View File
@@ -18,6 +18,7 @@ services:
AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
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_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
@@ -41,6 +42,8 @@ services:
- /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:
logstream.exclude: "true" # pas de collect des logs logstream
+114
View File
@@ -0,0 +1,114 @@
[English](presets.md) · **Français**
# Préréglages de tags de couleur
Dans **Paramètres › Filtres**, le menu **+ Préréglage…** ajoute d'un clic un groupe de tags de
couleur tout faits. Les tags ajoutés sont des tags ordinaires : vous pouvez changer leur couleur,
leur motif ou leurs options, ou les supprimer. Un tag dont le motif est déjà dans la liste n'est
pas ajouté en double.
La liste vient d'un fichier texte, [`presets.json`](../presets.json), intégré à LogStream. Vous
pouvez le remplacer par votre propre fichier (voir [Utiliser votre propre fichier](#utiliser-votre-propre-fichier)).
## Préréglages intégrés
### HTTP/HTTPS
Ces préréglages lisent les logs d'accès de nginx et Apache (formats common et combined), Traefik
(CLF et JSON), Caddy (JSON) et HAProxy (`option httplog`).
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Codes HTTP | `HTTP 2xx` vert, `HTTP 3xx` bleu, `HTTP 4xx` orange, `HTTP 5xx` rouge | seulement le code de statut, par exemple `404` dans `"GET /x HTTP/1.1" 404 153`, `"status":404` ou `"DownstreamStatus":404`. Les autres nombres de la ligne (taille, chemin) ne sont pas touchés. |
| Méthodes HTTP | `GET/HEAD/OPTIONS` gris, `POST/PUT/PATCH` violet, `DELETE` rose | seulement la méthode dans `"GET /chemin` ou `"method":"GET"` (en majuscules uniquement) |
| Sondes et attaques | `sondes / attaques` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
| Robots et scripts | `robots / scripts` | les mots finissant par `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
| Erreurs TLS/HTTPS et proxy | `erreurs TLS`, `erreurs proxy` | échecs de handshake TLS, certificats expirés ou refusés, `x509:` ; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
### Système
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| SSH et connexions | `échecs de connexion` rouge, `connexions` vert | sshd/PAM : `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`… ; `Accepted publickey`, `session opened for user`, `New session … of user` |
| Commandes sudo | `commandes sudo` | la commande lancée, par exemple `COMMAND=/usr/bin/apt` |
| Noyau : OOM, plantages, disques | `mémoire épuisée`, `erreurs noyau` | `Out of memory`, `oom-killer`, `Killed process 4242` ; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
| Services systemd | `services en échec` rouge, `démarrage/arrêt de service` vert | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly` ; `Started`, `Stopping`, `Reloaded`, `Reached target` |
| Pare-feu et fail2ban | `pare-feu` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
### Applications
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Docker et conteneurs | `problèmes de conteneur` | `exited with code 137` (codes non nuls seulement), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
| Bases de données | `erreurs base de données` | PostgreSQL et MySQL/MariaDB : `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
### Général
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Niveaux de log | `fatal / critique` rouge, `info / notice` bleu, `debug / trace` gris, `ok` vert | ces mots en mots entiers, quelle que soit la casse (les tags par défaut `warning` et `error` couvrent le reste) |
| Adresses IPv4 | `adresses IPv4` | `192.168.1.20`, `203.0.113.9`… Les numéros de version à quatre parties comme `1.2.3.4` sont aussi colorés. |
Quand des tags se chevauchent, celui placé le plus haut dans la liste l'emporte : les
préréglages ajoutés après les tags par défaut ne les masquent donc jamais.
## Utiliser votre propre fichier
LogStream lit le fichier indiqué par `PRESETS_FILE`, `/data/presets.json` par défaut (dans le
volume `logstream-data`). S'il n'existe pas, la liste intégrée est utilisée. Le fichier est relu
à chaque ouverture des Paramètres : pas besoin de redémarrer après une modification.
Avec docker-compose, le plus simple est de garder le fichier à côté de `docker-compose.yml` :
1. Copiez [`presets.json`](../presets.json) depuis ce dépôt et modifiez-le.
2. Dans `docker-compose.yml`, décommentez la ligne `- ./presets.json:/config/presets.json:ro`.
3. Dans `.env`, mettez `PRESETS_FILE=/config/presets.json`, puis lancez `docker compose up -d`.
Si le fichier est invalide (erreur JSON, expression régulière ou couleur incorrecte, id en
double), les Paramètres affichent l'erreur et la liste intégrée est utilisée jusqu'à correction.
## Format du fichier
Le fichier est une liste JSON de groupes. Chaque groupe a un nom et une liste de préréglages ;
chaque préréglage a un `id`, un nom et ses tags.
```json
[
{
"group": { "en": "My apps", "fr": "Mes applis" },
"presets": [
{
"id": "monappli",
"name": "Mon appli",
"tags": [
{ "label": "paiement refusé", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
{ "label": "commande", "color": "#86efac", "pattern": "order #\\d+" },
{ "label": "lent", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
]
}
]
}
]
```
| Champ | Obligatoire | Signification |
| --- | --- | --- |
| `group` | oui | nom du groupe dans le menu |
| `id` | oui | identifiant unique du préréglage |
| `name` | oui | nom du préréglage dans le menu |
| `tags[].pattern` | oui | ce qu'il faut colorer : une expression régulière, ou du texte simple avec `"regex": false` |
| `tags[].color` | oui | couleur de fond, `#rrggbb` |
| `tags[].label` | non | nom affiché dans la liste des tags à la place du motif |
| `tags[].regex` | non | `true` par défaut |
| `tags[].wholeWord` | non | mots entiers seulement, `false` par défaut |
| `tags[].caseSensitive` | non | respecter la casse, `false` par défaut |
Les noms et libellés sont soit un seul texte pour toutes les langues (`"Mon appli"`), soit un
texte par langue (`{ "en": "My app", "fr": "Mon appli" }`) ; une langue absente se rabat sur
l'anglais.
Les expressions régulières doivent fonctionner à la fois dans le navigateur (JavaScript) et en
Go, qui les vérifie : évitez les assertions arrière `(?<=…)`, avant `(?=…)` et les références
arrière `\1`. En JSON, chaque barre oblique inverse s'écrit deux fois : `\d` devient `"\\d"`. Un
groupe nommé `hl`, `(?<hl>…)`, ne colore que cette partie de la correspondance, comme le font
les préréglages HTTP avec `(?<hl>5\\d\\d)`.
+111
View File
@@ -0,0 +1,111 @@
**English** · [Français](presets.fr.md)
# Color tag presets
In **Settings › Filters**, the **+ Preset…** menu adds a group of ready-made color tags in one
click. Added tags are ordinary tags: you can change their color, pattern or options, or delete
them. A tag whose pattern is already in the list is not added twice.
The list comes from a text file, [`presets.json`](../presets.json), built into LogStream. You
can replace it with your own file (see [Using your own file](#using-your-own-file)).
## Built-in presets
### HTTP/HTTPS
These presets read access logs from nginx and Apache (common and combined formats), Traefik
(CLF and JSON), Caddy (JSON) and HAProxy (`option httplog`).
| Preset | Tags | What gets colored |
| --- | --- | --- |
| HTTP status codes | `HTTP 2xx` green, `HTTP 3xx` blue, `HTTP 4xx` orange, `HTTP 5xx` red | only the status code, e.g. `404` in `"GET /x HTTP/1.1" 404 153`, `"status":404` or `"DownstreamStatus":404`. Other numbers on the line (size, path) are left alone. |
| HTTP methods | `GET/HEAD/OPTIONS` grey, `POST/PUT/PATCH` purple, `DELETE` pink | only the method in `"GET /path` or `"method":"GET"` (upper case only) |
| Probes and attacks | `probes / attacks` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
| Bots and scripts | `bots / scripts` | words ending in `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
| TLS/HTTPS and proxy errors | `TLS errors`, `proxy errors` | TLS handshake failures, expired or rejected certificates, `x509:`; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
### System
| Preset | Tags | What gets colored |
| --- | --- | --- |
| SSH and logins | `login failures` red, `logins` green | sshd/PAM: `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`…; `Accepted publickey`, `session opened for user`, `New session … of user` |
| sudo commands | `sudo commands` | the command run, e.g. `COMMAND=/usr/bin/apt` |
| Kernel: OOM, crashes, disks | `out of memory`, `kernel errors` | `Out of memory`, `oom-killer`, `Killed process 4242`; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
| systemd services | `failed services` red, `service start/stop` green | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly`; `Started`, `Stopping`, `Reloaded`, `Reached target` |
| Firewall and fail2ban | `firewall` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
### Applications
| Preset | Tags | What gets colored |
| --- | --- | --- |
| Docker and containers | `container problems` | `exited with code 137` (non-zero codes only), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
| Databases | `database errors` | PostgreSQL and MySQL/MariaDB: `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
### General
| Preset | Tags | What gets colored |
| --- | --- | --- |
| Log levels | `fatal / critical` red, `info / notice` blue, `debug / trace` grey, `ok` green | these words as whole words, any case (the default `warning` and `error` tags cover the rest) |
| IPv4 addresses | `IPv4 addresses` | `192.168.1.20`, `203.0.113.9`… Four-part version numbers such as `1.2.3.4` are colored too. |
When tags overlap, the one highest in the tag list wins, so presets added after the default
tags never hide them.
## Using your own file
LogStream reads the file named by `PRESETS_FILE`, `/data/presets.json` by default (in the
`logstream-data` volume). When the file does not exist, the built-in list is used. The file is
read again each time Settings is opened: no restart is needed after an edit.
With docker-compose, the simplest is to keep the file next to `docker-compose.yml`:
1. Copy [`presets.json`](../presets.json) from this repository and edit it.
2. In `docker-compose.yml`, uncomment the line `- ./presets.json:/config/presets.json:ro`.
3. In `.env`, set `PRESETS_FILE=/config/presets.json`, then run `docker compose up -d`.
If the file is invalid (JSON error, bad regular expression or color, duplicate id), Settings
shows the error and the built-in list is used until the file is fixed.
## File format
The file is a JSON list of groups. Each group has a name and a list of presets; each preset has
an `id`, a name and its tags.
```json
[
{
"group": { "en": "My apps", "fr": "Mes applis" },
"presets": [
{
"id": "myapp",
"name": "My app",
"tags": [
{ "label": "payment failed", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
{ "label": "order", "color": "#86efac", "pattern": "order #\\d+" },
{ "label": "slow", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
]
}
]
}
]
```
| Field | Required | Meaning |
| --- | --- | --- |
| `group` | yes | name of the group in the menu |
| `id` | yes | unique identifier of the preset |
| `name` | yes | name of the preset in the menu |
| `tags[].pattern` | yes | what to color: a regular expression, or plain text with `"regex": false` |
| `tags[].color` | yes | background color, `#rrggbb` |
| `tags[].label` | no | name shown in the tag list instead of the pattern |
| `tags[].regex` | no | `true` by default |
| `tags[].wholeWord` | no | only match whole words, `false` by default |
| `tags[].caseSensitive` | no | match case, `false` by default |
Names and labels are either one text for every language (`"My app"`) or one text per language
(`{ "en": "My app", "fr": "Mon appli" }`); a missing language falls back to English.
Regular expressions must work both in the browser (JavaScript) and in Go, which checks them:
avoid look-behind `(?<=…)`, look-ahead `(?=…)` and back-references `\1`. In JSON, every
backslash is written twice: `\d` becomes `"\\d"`. A group named `hl`, `(?<hl>…)`, colors only
that part of the match, as the HTTP presets do with `(?<hl>5\\d\\d)`.
+2 -1
View File
@@ -143,7 +143,8 @@ func main() {
log.Fatal(err)
}
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 {
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
if err != nil {
+110
View File
@@ -0,0 +1,110 @@
package main
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"os"
)
// Built-in tag presets, used when no presets file is found (PRESETS_FILE,
// /data/presets.json by default). See docs/presets.md.
//
//go:embed presets.json
var builtinPresets []byte
// i18nText is a text per language ({"en": "...", "fr": "..."}) or one plain
// string for every language.
type i18nText map[string]string
func (t *i18nText) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
*t = i18nText{"en": s}
return nil
}
var m map[string]string
if err := json.Unmarshal(b, &m); err != nil {
return errors.New("expected a string or an object of strings per language")
}
*t = m
return nil
}
type PresetTag struct {
Label i18nText `json:"label,omitempty"`
Pattern string `json:"pattern"`
Color string `json:"color"`
Regex *bool `json:"regex,omitempty"` // regular expression unless false
WholeWord bool `json:"wholeWord,omitempty"`
CaseSensitive bool `json:"caseSensitive,omitempty"`
}
type Preset struct {
ID string `json:"id"`
Name i18nText `json:"name"`
Tags []PresetTag `json:"tags"`
}
type PresetGroup struct {
Group i18nText `json:"group"`
Presets []Preset `json:"presets"`
}
// parsePresets decodes and checks a presets file with the same rules as the
// tags, so that every preset can be added as is.
func parsePresets(b []byte) ([]PresetGroup, error) {
var groups []PresetGroup
if err := json.Unmarshal(b, &groups); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, g := range groups {
for _, p := range g.Presets {
if p.ID == "" || seen[p.ID] {
return nil, fmt.Errorf("preset %q: missing or duplicate id", p.ID)
}
seen[p.ID] = true
if len(p.Tags) == 0 {
return nil, fmt.Errorf("preset %q: no tags", p.ID)
}
for i, pt := range p.Tags {
t := Tag{Pattern: pt.Pattern, Color: pt.Color, Regex: pt.Regex == nil || *pt.Regex}
if err := t.validate(); err != nil {
return nil, fmt.Errorf("preset %q, tag %d: %w", p.ID, i+1, err)
}
}
}
}
return groups, nil
}
// presetsResponse is what GET /api/presets returns.
type presetsResponse struct {
Source string `json:"source"` // "file" or "builtin"
File string `json:"file"`
Error string `json:"error,omitempty"` // the file is invalid: built-in presets are used
Groups []PresetGroup `json:"groups"`
}
// loadPresets reads the presets file on every call, so that edits apply
// without a restart, and falls back to the built-in presets.
func loadPresets(path string) presetsResponse {
res := presetsResponse{Source: "builtin", File: path}
if path != "" {
b, err := os.ReadFile(path)
switch {
case err == nil:
if res.Groups, err = parsePresets(b); err == nil {
res.Source = "file"
return res
}
res.Error = err.Error()
case !errors.Is(err, os.ErrNotExist):
res.Error = err.Error()
}
}
res.Groups, _ = parsePresets(builtinPresets) // checked by the tests
return res
}
+244
View File
@@ -0,0 +1,244 @@
[
{
"group": "HTTP/HTTPS",
"presets": [
{
"id": "http_status",
"name": { "en": "HTTP status codes", "fr": "Codes HTTP" },
"tags": [
{
"label": "HTTP 2xx",
"color": "#86efac",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>2\\d\\d)\\b"
},
{
"label": "HTTP 3xx",
"color": "#93c5fd",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>3\\d\\d)\\b"
},
{
"label": "HTTP 4xx",
"color": "#fdba74",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>4\\d\\d)\\b"
},
{
"label": "HTTP 5xx",
"color": "#f87171",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>5\\d\\d)\\b"
}
]
},
{
"id": "http_methods",
"name": { "en": "HTTP methods", "fr": "Méthodes HTTP" },
"tags": [
{
"label": "GET/HEAD/OPTIONS",
"color": "#cbd5e1",
"caseSensitive": true,
"pattern": "\"(?<hl>GET|HEAD|OPTIONS)[ \"]"
},
{
"label": "POST/PUT/PATCH",
"color": "#c4b5fd",
"caseSensitive": true,
"pattern": "\"(?<hl>POST|PUT|PATCH)[ \"]"
},
{
"label": "DELETE",
"color": "#f9a8d4",
"caseSensitive": true,
"pattern": "\"(?<hl>DELETE)[ \"]"
}
]
},
{
"id": "http_probes",
"name": { "en": "Probes and attacks", "fr": "Sondes et attaques" },
"tags": [
{
"label": { "en": "probes / attacks", "fr": "sondes / attaques" },
"color": "#fda4af",
"pattern": "(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|<script|union(?:\\s|%20|\\+)+select)"
}
]
},
{
"id": "http_bots",
"name": { "en": "Bots and scripts", "fr": "Robots et scripts" },
"tags": [
{
"label": { "en": "bots / scripts", "fr": "robots / scripts" },
"color": "#fde68a",
"pattern": "\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b"
}
]
},
{
"id": "http_errors",
"name": { "en": "TLS/HTTPS and proxy errors", "fr": "Erreurs TLS/HTTPS et proxy" },
"tags": [
{
"label": { "en": "TLS errors", "fr": "erreurs TLS" },
"color": "#f0abfc",
"pattern": "(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)"
},
{
"label": { "en": "proxy errors", "fr": "erreurs proxy" },
"color": "#fdba74",
"pattern": "(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)"
}
]
}
]
},
{
"group": { "en": "System", "fr": "Système" },
"presets": [
{
"id": "sys_auth",
"name": { "en": "SSH and logins", "fr": "SSH et connexions" },
"tags": [
{
"label": { "en": "login failures", "fr": "échecs de connexion" },
"color": "#fca5a5",
"pattern": "(?:Failed (?:password|publickey|none)|Invalid user|authentication failures?|Connection closed by (?:invalid|authenticating) user|maximum authentication attempts exceeded|FAILED (?:LOGIN|SU)|incorrect password attempts?|NOT in sudoers)"
},
{
"label": { "en": "logins", "fr": "connexions" },
"color": "#86efac",
"pattern": "(?:Accepted (?:password|publickey|keyboard-interactive(?:/pam)?)|session opened for user|New session \\S+ of user)"
}
]
},
{
"id": "sys_sudo",
"name": { "en": "sudo commands", "fr": "Commandes sudo" },
"tags": [
{
"label": { "en": "sudo commands", "fr": "commandes sudo" },
"color": "#fde68a",
"caseSensitive": true,
"pattern": "\\bCOMMAND=\\S+"
}
]
},
{
"id": "sys_kernel",
"name": { "en": "Kernel: OOM, crashes, disks", "fr": "Noyau : OOM, plantages, disques" },
"tags": [
{
"label": { "en": "out of memory", "fr": "mémoire épuisée" },
"color": "#f87171",
"pattern": "(?:Out of memory|oom-kill(?:er)?|oom_reaper|Killed process \\d+|invoked oom-killer)"
},
{
"label": { "en": "kernel errors", "fr": "erreurs noyau" },
"color": "#fda4af",
"pattern": "(?:Kernel panic|\\bBUG: |\\bOops\\b|Call Trace|segfault at|general protection fault|I/O error|EXT4-fs error|Buffer I/O error|blocked for more than \\d+ seconds|Hardware Error|soft lockup|hard LOCKUP)"
}
]
},
{
"id": "sys_systemd",
"name": { "en": "systemd services", "fr": "Services systemd" },
"tags": [
{
"label": { "en": "failed services", "fr": "services en échec" },
"color": "#fca5a5",
"pattern": "(?:Failed to start|failed with result|Main process exited, code=(?:exited|killed|dumped)|entered failed state|Start request repeated too quickly|Dependency failed)"
},
{
"label": { "en": "service start/stop", "fr": "démarrage/arrêt de service" },
"color": "#bbf7d0",
"caseSensitive": true,
"pattern": "\\b(?:Started|Starting|Stopped|Stopping|Reloaded|Reloading|Reached target)\\b"
}
]
},
{
"id": "sys_firewall",
"name": { "en": "Firewall and fail2ban", "fr": "Pare-feu et fail2ban" },
"tags": [
{
"label": { "en": "firewall", "fr": "pare-feu" },
"color": "#fdba74",
"caseSensitive": true,
"pattern": "(?:\\[UFW (?:BLOCK|ALLOW|AUDIT|LIMIT BLOCK)\\]|\\b(?:DROP|REJECT)\\b|\\b(?:Ban|Unban|Found) \\d{1,3}(?:\\.\\d{1,3}){3}\\b)"
}
]
}
]
},
{
"group": "Applications",
"presets": [
{
"id": "app_docker",
"name": { "en": "Docker and containers", "fr": "Docker et conteneurs" },
"tags": [
{
"label": { "en": "container problems", "fr": "problèmes de conteneur" },
"color": "#fcd34d",
"pattern": "(?:\\bOOMKilled\\b|exited with code [1-9]\\d*|exit code: [1-9]\\d*|health_status: unhealthy|\\bunhealthy\\b|Back-off restarting|CrashLoopBackOff|container (?:die|kill|oom)\\b|restarting \\(\\d+\\))"
}
]
},
{
"id": "app_db",
"name": { "en": "Databases", "fr": "Bases de données" },
"tags": [
{
"label": { "en": "database errors", "fr": "erreurs base de données" },
"color": "#c4b5fd",
"pattern": "(?:\\bdeadlock(?: detected| found)?\\b|duplicate key|too many (?:connections|clients)|lock wait timeout|slow query|could not connect to server|server has gone away|out of shared memory|terminating connection|Access denied for user|password authentication failed)"
}
]
}
]
},
{
"group": { "en": "General", "fr": "Général" },
"presets": [
{
"id": "gen_levels",
"name": { "en": "Log levels", "fr": "Niveaux de log" },
"tags": [
{
"label": { "en": "fatal / critical", "fr": "fatal / critique" },
"color": "#ef4444",
"pattern": "\\b(?:fatal|crit(?:ical)?|panic|emerg(?:ency)?)\\b"
},
{
"label": "info / notice",
"color": "#bfdbfe",
"pattern": "\\b(?:info|notice)\\b"
},
{
"label": "debug / trace",
"color": "#e5e7eb",
"pattern": "\\b(?:debug|trace)\\b"
},
{
"label": "ok",
"color": "#86efac",
"regex": false,
"wholeWord": true,
"pattern": "ok"
}
]
},
{
"id": "gen_ip",
"name": { "en": "IPv4 addresses", "fr": "Adresses IPv4" },
"tags": [
{
"label": { "en": "IPv4 addresses", "fr": "adresses IPv4" },
"color": "#a5f3fc",
"pattern": "\\b(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b"
}
]
}
]
}
]
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestBuiltinPresets(t *testing.T) {
groups, err := parsePresets(builtinPresets)
if err != nil {
t.Fatal(err)
}
if len(groups) == 0 {
t.Fatal("no built-in presets")
}
}
func TestLoadPresetsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "presets.json")
if res := loadPresets(path); res.Source != "builtin" || res.Error != "" {
t.Fatalf("missing file: got %q, error %q", res.Source, res.Error)
}
os.WriteFile(path, []byte(`[{"group":"Mine","presets":[{"id":"a","name":{"en":"A","fr":"A fr"},
"tags":[{"label":"x","pattern":"foo|bar","color":"#112233"},{"pattern":"a.b","color":"#445566","regex":false}]}]}]`), 0o644)
res := loadPresets(path)
if res.Source != "file" || res.Error != "" || res.Groups[0].Presets[0].Name["fr"] != "A fr" || res.Groups[0].Group["en"] != "Mine" {
t.Fatalf("valid file: %+v", res)
}
for _, bad := range []string{
`not json`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"(","color":"#112233"}]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"red"}]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"#112233"}]},{"id":"a","name":"B","tags":[{"pattern":"y","color":"#112233"}]}]}]`,
} {
os.WriteFile(path, []byte(bad), 0o644)
if res := loadPresets(path); res.Source != "builtin" || res.Error == "" || len(res.Groups) == 0 {
t.Errorf("%s: got %q, error %q", bad, res.Source, res.Error)
}
}
}
+44 -4
View File
@@ -16,6 +16,7 @@ import (
// Tag highlights a keyword in displayed messages.
type Tag struct {
ID string `json:"id"`
Code string `json:"code"` // two digits, shown on matching log lines
Pattern string `json:"pattern"`
Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
Color string `json:"color"`
@@ -27,12 +28,46 @@ type Tag struct {
func defaultTags() []Tag {
return []Tag{
{ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
{ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
{ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
{ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
{ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", 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
// switched to the new pastel defaults when the file is loaded.
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
@@ -68,6 +103,7 @@ func (e *codedError) Error() string {
var (
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}$`)
)
@@ -108,7 +144,7 @@ func LoadTagStore(path string) (*TagStore, error) {
if err := json.Unmarshal(b, &s.tags); err != nil {
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 {
return nil, err
}
@@ -145,6 +181,9 @@ func (s *TagStore) Create(t Tag) (Tag, error) {
t.ID = newID()
s.mu.Lock()
defer s.mu.Unlock()
if t.Code = freeCode(s.tags); t.Code == "" {
return t, errTooManyTags
}
s.tags = append(s.tags, t)
return t, s.save()
}
@@ -158,6 +197,7 @@ func (s *TagStore) Update(id string, t Tag) (Tag, error) {
defer s.mu.Unlock()
for i := range s.tags {
if s.tags[i].ID == id {
t.Code = s.tags[i].Code // assigned by the server, never changed
s.tags[i] = t
return t, s.save()
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestTagCodes(t *testing.T) {
path := filepath.Join(t.TempDir(), "tags.json")
// File written before codes existed, with a duplicate code.
old := `[{"id":"a","pattern":"x","color":"#000000"},{"id":"b","code":"07","pattern":"y","color":"#000000"},{"id":"c","code":"07","pattern":"z","color":"#000000"}]`
if err := os.WriteFile(path, []byte(old), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
got := []string{}
for _, tg := range s.List() {
got = append(got, tg.Code)
}
if want := []string{"01", "07", "02"}; !slices.Equal(got, want) {
t.Fatalf("migrated codes = %v, want %v", got, want)
}
n, err := s.Create(Tag{Pattern: "w", Color: "#000000"})
if err != nil || n.Code != "03" {
t.Fatalf("Create code = %q, %v; want 03", n.Code, err)
}
// The client cannot change a code.
u, err := s.Update("b", Tag{Code: "42", Pattern: "y2", Color: "#000000"})
if err != nil || u.Code != "07" {
t.Fatalf("Update code = %q, %v; want 07", u.Code, err)
}
// A deleted tag frees its code; the others keep theirs.
if err := s.Delete("a"); err != nil {
t.Fatal(err)
}
if n, _ := s.Create(Tag{Pattern: "v", Color: "#000000"}); n.Code != "01" {
t.Fatalf("code after delete = %q, want 01", n.Code)
}
// Codes are saved in the file.
s2, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
if got := s2.List()[0].Code; got != "07" {
t.Fatalf("reloaded code = %q, want 07", got)
}
}
+104 -47
View File
@@ -47,18 +47,16 @@ const I18N = {
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
newTag: 'new',
confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error, ok)?',
confirmReset: 'Replace all tags with the defaults (warning, error)?',
presetAria: 'Add a preset', presetPick: '+ Preset…',
preset_http_status: 'HTTP status codes', preset_http_methods: 'HTTP methods',
preset_http_probes: 'Probes and attacks', preset_http_bots: 'Bots and scripts',
preset_http_errors: 'TLS/HTTPS and proxy errors',
pl_probes: 'probes / attacks', pl_bots: 'bots / scripts', pl_tls: 'TLS errors', pl_proxy: 'proxy errors',
presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'),
presetsFileErr: (f) => `Invalid presets file ${f}, built-in presets used: `,
tagsLoadErr: 'Tags: ',
err_pattern_required: 'The keyword is required',
err_invalid_color: 'Invalid color (expected #rrggbb)',
err_invalid_regex: 'Invalid regular expression',
err_tag_not_found: 'Tag not found',
err_too_many_tags: 'Too many tags (99 at most)',
err_live_logsql: 'Live view is not available in LogsQL mode',
dateTime: 'Date & time',
tzLabel: 'Time zone',
@@ -119,8 +117,9 @@ const I18N = {
themeHelp: 'System follows the light/dark preference of your computer or phone.',
logDisplay: 'Log display', fontSize: 'Font size',
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
fontLabel: 'Font', fontSystem: 'System monospace (no download)',
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.',
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
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)'],
dangerZone: 'Danger zone',
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
@@ -148,7 +147,9 @@ const I18N = {
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Reset zoom', unitDay: 'd',
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',
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).',
@@ -197,18 +198,16 @@ const I18N = {
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
newTag: 'nouveau',
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?',
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?',
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
preset_http_status: 'Codes HTTP', preset_http_methods: 'Méthodes HTTP',
preset_http_probes: 'Sondes et attaques', preset_http_bots: 'Robots et scripts',
preset_http_errors: 'Erreurs TLS/HTTPS et proxy',
pl_probes: 'sondes / attaques', pl_bots: 'robots / scripts', pl_tls: 'erreurs TLS', pl_proxy: 'erreurs proxy',
presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'),
presetsFileErr: (f) => `Fichier de préréglages ${f} invalide, préréglages intégrés utilisés : `,
tagsLoadErr: 'Tags : ',
err_pattern_required: 'Le mot-clé est obligatoire',
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
err_invalid_regex: 'Expression régulière invalide',
err_tag_not_found: 'Tag introuvable',
err_too_many_tags: 'Trop de tags (99 au maximum)',
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire',
@@ -269,8 +268,9 @@ const I18N = {
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',
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)',
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.',
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
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)'],
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.',
@@ -298,7 +298,9 @@ const I18N = {
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Annuler le zoom', unitDay: 'j',
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',
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).',
@@ -449,6 +451,7 @@ const state = {
rows: [], // displayed records, newest first
pending: [], // live messages received while scrolled down
tags: [],
presets: [], // preset groups from /api/presets
matchers: [], // compiled tags + search terms
mode: store.get('mode', 'simple'),
live: store.get('live', '1') === '1',
@@ -487,6 +490,7 @@ function setLang(next) {
renderHisto();
renderStats();
renderTagList();
renderPresets();
renderTimeSettings();
renderPurge();
renderInterface();
@@ -531,16 +535,19 @@ $('#themeSwitch').addEventListener('click', (ev) => {
/* ================= 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 = [
{ 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: 'fira-code', family: 'Fira Code' },
{ id: 'source-code-pro', family: 'Source Code Pro' },
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono' },
{ id: 'cascadia-code', family: 'Cascadia Code' },
{ id: 'roboto-mono', family: 'Roboto Mono' },
{ id: 'ubuntu-mono', family: 'Ubuntu Mono' },
{ id: 'inconsolata', family: 'Inconsolata' },
{ id: 'red-hat-mono', family: 'Red Hat Mono' },
{ id: 'noto-sans-mono', family: 'Noto Sans Mono' },
@@ -548,7 +555,8 @@ const LOG_FONTS = [
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
];
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) {
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
@@ -558,6 +566,10 @@ function applyLogFont(id) {
root.removeProperty('--log-font');
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`;
let link = document.getElementById('logFontCss');
if (!link) {
@@ -573,13 +585,22 @@ function applyLogSize(id) {
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() {
renderThemeSwitch();
for (const b of document.querySelectorAll('#sizeSwitch [data-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');
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;
renderFontPreview();
renderHistoSettings();
@@ -603,6 +624,13 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
store.set('logSize', ui.size);
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) => {
applyLogFont(ev.target.value);
store.set('logFont', ui.font);
@@ -696,7 +724,8 @@ function compileMatchers() {
// 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)}">` });
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) {
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
}
@@ -738,6 +767,30 @@ function highlight(text) {
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 ================= */
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
@@ -763,6 +816,7 @@ function rowHTML(r, isNew) {
+ (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"></span>')
+ `<span class="codes">${codesHTML(r._msg)}</span>`
+ `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>';
}
@@ -829,7 +883,9 @@ function renderList() {
function rehighlight() {
for (const row of list.children) {
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);
}
}
@@ -1995,27 +2051,24 @@ $('#purgeBtn').addEventListener('click', async () => {
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
// Ready-made tags for HTTP/HTTPS access logs. The patterns are valid in both
// JavaScript and Go (RE2) and cover nginx/Apache (common, combined), Traefik
// (CLF, JSON), Caddy (JSON) and HAProxy (httplog). For status codes and
// methods only the "hl" group is colored, not the context around it.
const HTTP_STATUS_CTX = '(?:" |"(?:status|DownstreamStatus|OriginStatus|status_code)": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )';
const httpStatus = (d, color) => ({ label: `HTTP ${d}xx`, pattern: `${HTTP_STATUS_CTX}(?<hl>${d}\\d\\d)\\b`, color });
const httpMethod = (m, color) => ({ label: m.replace(/\|/g, '/'), pattern: `"(?<hl>${m})[ "]`, color, caseSensitive: true });
const PRESETS = {
http_status: () => [httpStatus(2, '#86efac'), httpStatus(3, '#93c5fd'), httpStatus(4, '#fdba74'), httpStatus(5, '#f87171')],
http_methods: () => [httpMethod('GET|HEAD|OPTIONS', '#cbd5e1'), httpMethod('POST|PUT|PATCH', '#c4b5fd'), httpMethod('DELETE', '#f9a8d4')],
http_probes: () => [{ label: t('pl_probes'), 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)' }],
http_bots: () => [{ label: t('pl_bots'), color: '#fde68a',
pattern: '\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b' }],
http_errors: () => [
{ label: t('pl_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: t('pl_proxy'), color: '#fdba74',
pattern: '(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)' },
],
};
// 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() {
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
@@ -2026,6 +2079,7 @@ function tagRowHTML(tag) {
const opt = (field, label, title) =>
`<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)}">
<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="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
<span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span>
@@ -2106,14 +2160,15 @@ $('#addTag').addEventListener('click', async () => {
// Adds a preset group, skipping tags whose pattern is already in the list.
$('#presetTags').addEventListener('change', async (ev) => {
const make = PRESETS[ev.target.value];
const preset = state.presets.flatMap((g) => g.presets).find((p) => p.id === ev.target.value);
ev.target.value = '';
if (!make) return;
if (!preset) return;
let added = 0;
try {
for (const p of make()) {
for (const p of preset.tags) {
if (state.tags.some((x) => x.pattern === p.pattern)) continue;
state.tags.push(await api('/api/tags', { method: 'POST', body: { regex: true, enabled: true, ...p } }));
const tag = { ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true };
state.tags.push(await api('/api/tags', { method: 'POST', body: tag }));
added++;
}
} catch (e) { toast(e.message); }
@@ -2134,6 +2189,7 @@ $('#resetTags').addEventListener('click', async () => {
$('#settingsBtn').addEventListener('click', () => {
renderTimeSettings();
renderTagList();
loadPresets();
renderInterface();
loadPurgeStatus();
loadSyslog();
@@ -2155,6 +2211,7 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
}
applyLogFont(store.get('logFont', 'system'));
applyLogSize(store.get('logSize', 'medium'));
applyLogDensity(store.get('logDensity', 'normal'));
applyLang();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
+93
View File
@@ -0,0 +1,93 @@
Copyright 2006 The Inconsolata Project Authors (https://github.com/cyrealtype/Inconsolata)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+110
View File
@@ -0,0 +1,110 @@
Copyright (c) 2015-2023, Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
--------------------------
SIL Open Font License v1.1
====================================================
Preamble
----------
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
Definitions
-------------
`"Font Software"` refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
`"Reserved Font Name"` refers to any names specified as such after the
copyright statement(s).
`"Original Version"` refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
`"Modified Version"` refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
`"Author"` refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
Permission & Conditions
------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5. The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
Termination
-----------
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+96
View File
@@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -5
View File
@@ -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="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="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>
</div>
<main id="list" class="list"></main>
@@ -192,11 +193,6 @@
<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>
<option value="http_status" data-i18n="preset_http_status">HTTP status codes</option>
<option value="http_methods" data-i18n="preset_http_methods">HTTP methods</option>
<option value="http_probes" data-i18n="preset_http_probes">Probes and attacks</option>
<option value="http_bots" data-i18n="preset_http_bots">Bots and scripts</option>
<option value="http_errors" data-i18n="preset_http_errors">TLS/HTTPS and proxy errors</option>
</select>
</span>
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
@@ -281,6 +277,11 @@
<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>
</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>
<select id="fontSelect" class="field"></select>
</div>
+41 -10
View File
@@ -1,3 +1,13 @@
/* Narrow monospace fonts served by LogStream itself, so they also work offline (licences in
web/fonts). Latin subset only; other scripts fall back to --mono. Inconsolata Condensed is
Inconsolata's variable font pinned at width 75 (0.4em per character, others 0.5em). */
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
/* ---------- Theme: everything goes through these variables ---------- */
:root {
--bg: #f6f7f9;
@@ -38,6 +48,8 @@
--tag-warning-text: #111827;
--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) */
--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;
@@ -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: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
the header and every row through subgrid so that the columns line up. */
.table {
display: grid;
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;
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) */
@@ -331,7 +343,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.row {
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;
padding: 5px 14px 5px 11px;
border-left: 3px solid transparent;
@@ -341,6 +353,10 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
cursor: pointer;
}
.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:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.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 { background: color-mix(in srgb, var(--sev) 7%, transparent); }
.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; }
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 .app { grid-area: app; display: block; }
.preview-list .row .msg { grid-area: msg; }
.preview-list .row .codes { display: none; }
dialog {
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
@@ -592,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-row {
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;
background: var(--panel-2);
}
.tag-row.off { opacity: .55; }
.tag-row .tag-code { font-size: 12px; padding: 3px 6px; cursor: default; }
.tag-row input[type="color"] {
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
background: none; cursor: pointer;
@@ -639,8 +668,8 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.table .list { display: block; margin: 0; }
.list-head { display: none; }
.row, .table .row {
grid-template-columns: auto auto 1fr;
grid-template-areas: "rcv sev host" "msg msg msg";
grid-template-columns: auto auto 1fr auto;
grid-template-areas: "rcv sev host codes" "msg msg msg msg";
gap: 3px 8px; padding: 8px 12px 8px 10px;
}
.row time.rcv { grid-area: rcv; }
@@ -648,6 +677,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.row .sev { grid-area: sev; }
.row .host { grid-area: host; justify-self: end; max-width: 100%; }
.row .app { display: none; }
.row .codes { grid-area: codes; }
.row .msg { grid-area: msg; }
.details { grid-column: 1 / -1; }
.details dl { grid-template-columns: 1fr; }
@@ -665,14 +695,15 @@ 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-panels { padding: 0 16px 18px; }
#sizeSwitch { display: flex; }
#sizeSwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
#sizeSwitch, #densitySwitch { display: flex; }
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
:root[data-density="compact"] .row { padding-top: 3px; padding-bottom: 3px; }
.field-grid select { margin-bottom: 6px; }
.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 .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) ---------- */