Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e703c8792 | ||
|
|
367420961e | ||
|
|
7ee27bb1ab | ||
|
|
e77fcb326d | ||
|
|
c8f45044a3 | ||
|
|
d61384ed2c | ||
|
|
dd8ca74cad | ||
|
|
2ca89b1c6d | ||
|
|
1a3b3ee23a | ||
|
|
194edee6de | ||
|
|
dcb09e619e | ||
|
|
087d9e7b30 | ||
|
|
e87186e476 | ||
|
|
7e9aefa755 | ||
|
|
78e363652b | ||
|
|
a57531c95f | ||
|
|
7f053a0fe9 | ||
|
|
5b28bff48b | ||
|
|
c2c3926135 |
No files matched your search
+6
-2
@@ -1,6 +1,6 @@
|
||||
# Copy this file to .env and adjust it.
|
||||
SYSLOG_PORT=514
|
||||
HTTP_PORT=8080
|
||||
SYSLOG_PORT=5514
|
||||
HTTP_PORT=8989
|
||||
TZ=Europe/Paris
|
||||
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
||||
RETENTION=30d
|
||||
@@ -67,3 +67,7 @@ DMESG_BACKFILL=1h
|
||||
AUDIT_BACKFILL=1h
|
||||
# Host name of the audit events (empty: node= of the records, else /host/etc/hostname, else localhost)
|
||||
AUDIT_HOST=
|
||||
# Log files of other programs (Traefik, nginx, applications…), declared in Settings > Log sources >
|
||||
# Log files: mount their directory read-only in docker-compose.yml (e.g. /var/log/traefik:/logs/traefik:ro).
|
||||
# History read when a file is turned on (e.g. 30m, 1h, 24h; 0 = only new lines)
|
||||
FILES_BACKFILL=1h
|
||||
+172
-19
@@ -55,7 +55,7 @@ Ouvrez ensuite <http://localhost:8080>.
|
||||
- **Test manuel** : `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) ou
|
||||
`echo "<14>test ok" | nc -u -w1 127.0.0.1 514`.
|
||||
|
||||
**Paramètres > Sources > Syslog** active ou désactive la réception syslog et choisit les
|
||||
**Paramètres > Sources de logs > Syslog** active ou désactive la réception syslog et choisit les
|
||||
protocoles (UDP, TCP) sans redémarrage ; le choix est enregistré dans `/data/syslog.json`. Cet
|
||||
onglet affiche aussi l'état d'écoute (et l'erreur si le port est déjà utilisé). Le port
|
||||
lui-même est publié par docker-compose : modifiez `SYSLOG_PORT` dans `.env`, puis lancez
|
||||
@@ -92,13 +92,17 @@ une base propre.
|
||||
Les badges de sévérité `err`/`crit` et `warning` reprennent les couleurs des tags `error` et
|
||||
`warning`.
|
||||
|
||||
Chaque ligne commence par un badge de largeur fixe qui indique sa source, avec une couleur par
|
||||
source : **SYSLOG**, **DOCKER**, **KERNEL** (dmesg), **HOST** (fichiers de `/var/log`), **AUDIT**,
|
||||
**FILE** et **LS** (logs de LogStream lui-même).
|
||||
|
||||
Raccourcis : `/` place le curseur dans la recherche, `Esc` la vide. Un clic sur une ligne
|
||||
affiche tous ses champs.
|
||||
|
||||
Les en-têtes de colonnes restent visibles pendant le défilement. Faites glisser le bord d'un
|
||||
en-tête (réception, heure message, sévérité, hôte, app) pour redimensionner la colonne, et
|
||||
double-cliquez dessus pour revenir à la largeur automatique ; le message occupe l'espace
|
||||
restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Interface >
|
||||
restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Affichage >
|
||||
Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
|
||||
présentation sur deux lignes, sans colonnes.
|
||||
|
||||
@@ -126,7 +130,7 @@ lignes).
|
||||
chaque nouvel intervalle. Rien n'est rafraîchi tant que l'onglet du navigateur est masqué ;
|
||||
la frise se met à jour dès qu'il redevient visible.
|
||||
|
||||
**Paramètres > Interface > Frise** (mémorisé par navigateur) : échelle (linéaire, √ par défaut,
|
||||
**Paramètres > Affichage > Frise** (mémorisé par navigateur) : échelle (linéaire, √ par défaut,
|
||||
log), hauteur (S/M/L : 40/80/120 px), couleur (empilement par sévérité, intensité comparée à
|
||||
la médiane de la fenêtre : calme, rafale au-delà de 3×, anomalie au-delà de 10×, ou aucune),
|
||||
barres ou aire, division (automatique, environ 100 intervalles, ou fixe : 1 s, 10 s, 1 min,
|
||||
@@ -155,7 +159,7 @@ colorés et exportés comme les messages syslog :
|
||||
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, ou un niveau en majuscules au début de
|
||||
la ligne (`ERROR`, `WARN`…). Sinon, elle vaut `info`. Les codes de couleur du terminal sont
|
||||
supprimés.
|
||||
- **Paramètres > Sources** affiche une étiquette par conteneur (`project/service`) dans un seul
|
||||
- **Paramètres > Sources de logs** affiche une étiquette par conteneur (`project/service`) dans un seul
|
||||
champ : les conteneurs suivis en couleur, puis les non suivis en gris ; un clic bascule une
|
||||
étiquette. La couleur identifie le projet compose, et les noms d'applications Docker de la
|
||||
liste utilisent la même couleur. Les conteneurs arrêtés sont masqués par défaut (« Afficher
|
||||
@@ -181,7 +185,7 @@ moteur (`GET` uniquement).
|
||||
|
||||
Logstream peut aussi collecter les logs système de la machine qui héberge la stack, sans rien
|
||||
configurer sur l'hôte. La source est **désactivée par défaut** : activez-la dans
|
||||
**Paramètres > Sources > Logs système de l'hôte** (enregistré dans `/data/hostlogs.json`).
|
||||
**Paramètres > Sources de logs > Logs système de l'hôte** (enregistré dans `/data/hostlogs.json`).
|
||||
|
||||
- `docker-compose.yml` monte `/var/log` et `/run/log/journal` en lecture seule sous `/host`.
|
||||
- Si l'hôte utilise systemd, Logstream lit directement les fichiers du **journal systemd**
|
||||
@@ -196,7 +200,7 @@ configurer sur l'hôte. La source est **désactivée par défaut** : activez-la
|
||||
- **Droits** : le conteneur tourne avec un utilisateur sans privilège et reçoit le groupe `adm`
|
||||
(gid 4), qui peut lire le journal et `/var/log` sur Debian et Ubuntu. Sur d'autres systèmes,
|
||||
réglez `HOST_LOGS_GID` dans `.env` sur le gid de `systemd-journal`
|
||||
(`getent group systemd-journal | cut -d: -f3`). Paramètres > Sources affiche un message clair
|
||||
(`getent group systemd-journal | cut -d: -f3`). Paramètres > Sources de logs affiche un message clair
|
||||
si l'accès est refusé.
|
||||
- Limites : les champs du journal compressés par journald (messages de plus de 512 octets,
|
||||
compressés en zstd/lz4/xz) ne peuvent pas être décodés sans bibliothèque supplémentaire ; ils
|
||||
@@ -207,7 +211,7 @@ configurer sur l'hôte. La source est **désactivée par défaut** : activez-la
|
||||
|
||||
Logstream peut collecter le buffer circulaire du noyau de l'hôte (ce qu'affiche `dmesg` :
|
||||
matériel, pilotes, OOM killer, pare-feu…) en lisant `/dev/kmsg`. La source est **désactivée par
|
||||
défaut** : activez-la dans **Paramètres > Sources > Messages du noyau (dmesg)** (enregistré dans
|
||||
défaut** : activez-la dans **Paramètres > Sources de logs > Messages du noyau (dmesg)** (enregistré dans
|
||||
`/data/dmesg.json`).
|
||||
|
||||
- **Périphérique** : le conteneur ne voit pas `/dev/kmsg` par défaut. Décommentez la section
|
||||
@@ -223,7 +227,7 @@ défaut** : activez-la dans **Paramètres > Sources > Messages du noyau (dmesg)*
|
||||
- ou lancer Logstream en root avec la capacité `SYSLOG` : décommentez `user: "0:0"` et
|
||||
`cap_add: [SYSLOG]` dans `docker-compose.yml`.
|
||||
|
||||
Paramètres > Sources indique si le périphérique est absent ou l'accès refusé ; le reste de
|
||||
Paramètres > Sources de logs indique si le périphérique est absent ou l'accès refusé ; le reste de
|
||||
Logstream continue de fonctionner et le périphérique est réessayé toutes les 10 secondes.
|
||||
- **Champs** : **app** vaut `kernel` (ou le nom du programme pour les messages écrits par des
|
||||
programmes, comme `systemd[1]: …`), la sévérité et la facility viennent de l'enregistrement,
|
||||
@@ -278,7 +282,7 @@ source est **désactivée par défaut** : activez-la dans **Réglages > Sources
|
||||
ne peut pas être résolu), `exe`, `res` (`success` / `failed`, d'après `res=` ou `success=`) et
|
||||
`key` (clé de la règle). La sévérité est `warning` en cas d'échec, `info` sinon ; facility
|
||||
`authpriv` ; source `audit`. Dans la liste, les lignes d'audit ont un liseré gauche violet,
|
||||
un fond légèrement teinté et un badge **AUDIT** devant l'app ; le filtre **Source** permet de
|
||||
un fond légèrement teinté et le badge de source **AUDIT** ; le filtre **Source** permet de
|
||||
les afficher seules.
|
||||
- **Noms** : `user` vient du format enrichi (`log_format = ENRICHED`, le défaut des versions
|
||||
récentes d'auditd), sinon du `/etc/passwd` de l'hôte s'il est monté en `/host/etc/passwd`.
|
||||
@@ -289,10 +293,71 @@ source est **désactivée par défaut** : activez-la dans **Réglages > Sources
|
||||
enregistrés dans `/data/audit-state.json` : après un redémarrage, la lecture reprend là ;
|
||||
après une rotation par auditd, la fin de `audit.log.1` est lue avant le nouveau fichier.
|
||||
|
||||
## Fichiers de logs (Traefik, nginx, applications…)
|
||||
|
||||
Logstream peut suivre les fichiers de logs d'autres programmes : Traefik, nginx, Apache, ou
|
||||
toute application qui écrit dans un fichier. Déclarez chaque fichier dans **Paramètres > Sources
|
||||
de logs > Fichiers de logs** (enregistré dans `/data/files.json`, 50 au plus) : un **nom** (l'**app**
|
||||
de ses entrées, par défaut le nom du fichier), le **chemin** du fichier tel que monté dans le
|
||||
conteneur, un **format** (*Automatique* par défaut), un **hôte** facultatif (par défaut
|
||||
`/host/etc/hostname`, sinon `localhost`) et un interrupteur.
|
||||
|
||||
- **Montage** du dossier en lecture seule, par ex. dans `docker-compose.yml` :
|
||||
`- /var/log/traefik:/logs/traefik:ro`, puis `docker compose up -d`. Les fichiers de `/var/log`
|
||||
sont déjà accessibles sous `/host/var/log/…`. Les fichiers doivent être lisibles par l'UID
|
||||
`10001` ou le groupe de `HOST_LOGS_GID` (`adm` par défaut) ; Paramètres > Sources de logs
|
||||
indique quand aucun fichier ne correspond ou que l'accès est refusé.
|
||||
- **Chemin** : un fichier, par ex. `/logs/traefik/access.log` (pas de jokers). Dès la saisie, le
|
||||
fichier est vérifié : trouvé ou non, lisible ou non, son format détecté et sa dernière ligne.
|
||||
- **Détection du format** (*Automatique*) : à partir des dernières lignes du fichier (50 au
|
||||
plus), le format structuré que suivent au moins 80 % d'entre elles — accès Traefik JSON, JSON
|
||||
lines, journal d'accès CLF, logfmt — sinon texte brut. Elle est refaite quand le format est
|
||||
changé ; le format trouvé s'affiche dans l'état du fichier. Choisissez un autre format pour
|
||||
le forcer (une expression régulière n'est jamais détectée).
|
||||
- **Formats** :
|
||||
- *Texte brut* : une entrée par ligne ; le niveau est détecté comme pour les logs Docker, un
|
||||
horodatage en début de ligne (ISO 8601, `2006/01/02 15:04:05`, `[02/Jan/2006:15:04:05 +0200]`,
|
||||
`Jan 2 15:04:05`) devient l'heure du message et est retiré du message. Les lignes indentées
|
||||
(stack traces) sont rattachées à l'entrée précédente.
|
||||
- *JSON lines* : `msg` / `message`, `time` / `ts` / `@timestamp` (texte ou heure Unix) et
|
||||
`level` (mot ou nombre, pino/bunyan compris) sont reconnus ; les autres clés deviennent des
|
||||
champs, les objets imbriqués avec des noms pointés (`req.id`).
|
||||
- *logfmt* : paires `clé=valeur`, comme les logs par défaut de Traefik ou des programmes Go ;
|
||||
mêmes clés que le JSON.
|
||||
- *Journal d'accès (CLF)* : Common / Combined Log Format de nginx et Apache, et le journal
|
||||
d'accès de Traefik dans ce format (routeur, URL du serveur et durée compris). Champs
|
||||
`client_ip`, `http_method`, `http_path`, `http_status`, `http_size`, `user_agent`,
|
||||
`http_referer`, `router`, `backend`, `duration_ms`.
|
||||
- *Accès Traefik (JSON)* : le journal d'accès JSON de Traefik (`accessLog.format: json`),
|
||||
résumé en `GET app.example.com/ 200 · 12.5 ms · app@docker · 203.0.113.9`, toutes les clés
|
||||
dans les champs. Les logs de Traefik lui-même dans le même fichier sont lus comme du JSON.
|
||||
- *Expression régulière* (syntaxe RE2 / Go) : les groupes nommés `time`, `level`, `msg`,
|
||||
`host` et `app` remplissent ces champs, `status` donne le niveau d'un journal d'accès, les
|
||||
autres groupes deviennent des champs, par ex.
|
||||
`^(?P<time>\S+ \S+) \[(?P<level>\w+)\] (?P<module>\S+): (?P<msg>.*)$`. Une ligne qui ne
|
||||
correspond pas est rattachée à l'entrée précédente.
|
||||
|
||||
Pour les journaux d'accès, les statuts 5xx sont en `err`, les 4xx en `warning`, les autres en
|
||||
`info`. Une ligne qui ne suit pas le format choisi est gardée comme du texte brut. Les champs
|
||||
qui portent le nom d'un champ de LogStream (`host`, `app`…) sont renommés `field_host`,
|
||||
`field_app`…
|
||||
- **Affichage** : source `file`, le chemin dans `log_file`. Dans la liste, ces lignes ont un
|
||||
liseré gauche vert, une légère teinte et le badge de source **FILE** ; le filtre **Source**
|
||||
les affiche seules.
|
||||
- À l'activation d'un fichier, les lignes de la dernière heure déjà présentes sont lues d'abord
|
||||
(`FILES_BACKFILL`, `0` = seulement les nouvelles lignes ; les lignes sans horodatage sont alors
|
||||
ignorées). Le début de cette heure est trouvé par dichotomie sur les dates des lignes : un gros
|
||||
fichier qui contient des mois de logs n'est pas relu depuis son début ; un fichier créé ensuite
|
||||
est lu depuis son début, jusqu'à 64 Mo par seconde, l'état indiquant ce qu'il reste à lire. L'inode et une
|
||||
position sont enregistrés par fichier dans `/data/files-state.json` : après un redémarrage la
|
||||
lecture reprend là ; après une rotation (`access.log` renommé `access.log.1`), la fin de
|
||||
l'ancien fichier est lue avant le nouveau ; un fichier vidé sur place (`copytruncate`) est
|
||||
relu depuis son début.
|
||||
|
||||
## Logs internes de LogStream
|
||||
|
||||
Logstream enregistre ses propres événements comme des logs, avec l'**hôte** `logstream`,
|
||||
l'**app** du composant (`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `main`) et la
|
||||
l'**app** du composant (`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `files`, `main`) et la
|
||||
source `logstream` : VictoriaLogs injoignable puis de retour, lots perdus, lots refusés depuis
|
||||
le tampon disque, erreurs du listener syslog, API Docker ou flux d'un conteneur décrochés,
|
||||
sources qui ne peuvent pas lire, démarrage et arrêt. Ils sont actifs par défaut à partir du
|
||||
@@ -325,18 +390,96 @@ charge (pas de `| pipes`).
|
||||
|
||||
En ligne de commande : `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&range=24h&tz=Europe/Paris'`.
|
||||
|
||||
## API de lecture (n8n, scripts)
|
||||
|
||||
`GET /api/v1/logs` renvoie les logs stockés qui suivent un curseur, du plus ancien au plus
|
||||
récent : un outil appelé à intervalle régulier (n8n, un script, un connecteur SIEM) reçoit ainsi
|
||||
chaque nouveau log une fois, sans trou ni doublon, même quand beaucoup de logs ont le même
|
||||
horodatage.
|
||||
|
||||
L'accès passe par un **jeton d'API**, créé dans *Paramètres > API* par un administrateur et
|
||||
envoyé dans `Authorization: Bearer lst_…`. Un jeton est en lecture seule et n'ouvre que
|
||||
`/api/v1/` ; il fonctionne quel que soit le mode d'[authentification](#authentification)
|
||||
(`local`, `oidc` ou aucune). Il n'est affiché qu'une fois, à sa création : LogStream n'en garde
|
||||
que l'empreinte SHA-256, dans `/data/api_tokens.json`. Le révoquer (puis Valider) coupe l'accès immédiatement ;
|
||||
la liste indique quand et d'où chaque jeton a servi pour la dernière fois. Une session ouverte
|
||||
dans le navigateur peut aussi appeler la route.
|
||||
|
||||
| Paramètre | Rôle |
|
||||
|---|---|
|
||||
| `query` | filtre LogsQL (par défaut `*`), par exemple `severity:in(err,crit) app:sshd`. Les pipes (`\| ...`) sont refusés. |
|
||||
| `since` | point de départ : le `next_cursor` de la réponse précédente, une date RFC 3339 (`2026-11-01T08:00:00Z`) ou une durée avant maintenant (`15m`, `6h`, `7d`). Par défaut : `1h`. |
|
||||
| `limit` | lignes par réponse, de 1 à 10 000 (1 000 par défaut). |
|
||||
| `lag` | seuls les logs reçus depuis au moins cette durée sont renvoyés (`30s` par défaut, `1h` au plus), pour ne pas sauter les logs encore dans la file ou en attente de leur nom DNS. |
|
||||
|
||||
```json
|
||||
{
|
||||
"rows": [{"_time": "2026-11-01T14:02:11.52Z", "_msg": "…", "host": "fw01", "app": "sshd", "severity": "err", …}],
|
||||
"next_cursor": "eyJ0IjoiMjAyNi0xMS0wMVQxNDowMjoxMS41MjAwMDAwMDFaIiwicSI6IjJlYjE4YzNhIn0",
|
||||
"has_more": false,
|
||||
"until": "2026-11-01T14:05:30.1Z"
|
||||
}
|
||||
```
|
||||
|
||||
Les lignes contiennent les champs stockés (comme la vue JSON de l'interface), triées par
|
||||
`_time`, l'heure de réception. Renvoyez toujours `next_cursor`, même quand `rows` est vide.
|
||||
`has_more: true` signifie que d'autres logs sont prêts : rappelez tout de suite.
|
||||
`waiting: true` signifie que des logs du [tampon disque](#configuration) sont encore en cours
|
||||
d'envoi à VictoriaLogs : rien n'est renvoyé tant qu'ils ne sont pas stockés, pour n'en manquer
|
||||
aucun. Le curseur est lié à sa `query` : changer la requête garde la position dans le temps. Les
|
||||
erreurs arrivent sous la forme `{"error", "code"}` avec le statut 400 (paramètre ou LogsQL
|
||||
invalide), 401 (jeton invalide ou révoqué) ou 502 (VictoriaLogs injoignable).
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer lst_…" \
|
||||
"https://logs.example.org/api/v1/logs?query=severity:err&since=15m&limit=1000"
|
||||
```
|
||||
|
||||
### Exemple : workflow n8n
|
||||
|
||||
Quatre nœuds, le curseur gardé dans les données statiques du workflow d'une exécution à l'autre :
|
||||
|
||||
1. **Schedule Trigger** : toutes les minutes.
|
||||
2. **Code** (*Run Once for All Items*), lit le curseur :
|
||||
```js
|
||||
const data = $getWorkflowStaticData('global');
|
||||
return [{ json: { since: data.cursor || '15m' } }];
|
||||
```
|
||||
3. **HTTP Request** : méthode `GET`, URL `https://logs.example.org/api/v1/logs`.
|
||||
*Authentication* : Generic Credential Type > Header Auth, avec le nom `Authorization` et la
|
||||
valeur `Bearer lst_…`. *Send Query Parameters* : `query` = `severity:in(emerg,alert,crit,err)`,
|
||||
`since` = `{{ $json.since }}`, `limit` = `1000`.
|
||||
4. **Code** (*Run Once for All Items*), enregistre le curseur et fait de chaque log un item :
|
||||
```js
|
||||
const data = $getWorkflowStaticData('global');
|
||||
const page = $input.first().json;
|
||||
data.cursor = page.next_cursor;
|
||||
return page.rows.map((row) => ({ json: row }));
|
||||
```
|
||||
|
||||
Branchez ensuite ce que les logs doivent alimenter (Slack, mail, ticket, base de données…). n8n ne
|
||||
garde les données statiques que pour les exécutions du workflow actif, pas pour les tests
|
||||
manuels : pendant les essais, chaque exécution repart de `15m`. Au-delà de 1 000 logs par minute,
|
||||
`has_more` vaut true et les exécutions suivantes rattrapent le retard ; augmentez `limit` ou la
|
||||
fréquence du déclencheur si besoin.
|
||||
|
||||
## Paramètres
|
||||
|
||||
L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Tout, sauf les tags de
|
||||
couleur, est mémorisé par navigateur.
|
||||
L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Les modifications
|
||||
s'affichent tout de suite mais ne sont conservées qu'avec le bouton **Valider**, sous les libellés
|
||||
des onglets : il prend la couleur d'accent tant que des modifications sont en attente. **Annuler**
|
||||
revient aux paramètres enregistrés, et fermer la fenêtre avec des modifications non validées
|
||||
demande confirmation avant de les abandonner. Localisation et Affichage sont mémorisés par
|
||||
navigateur ; les tags de couleur et les sources de logs sont enregistrés sur le serveur. La purge
|
||||
et la réinitialisation des colonnes agissent immédiatement.
|
||||
|
||||
- **Localisation**
|
||||
- **Localisation et langue**
|
||||
- *Langue* : anglais ou français.
|
||||
- *Date et heure* : fuseau horaire (celui du navigateur, UTC ou environ 80 fuseaux courants)
|
||||
et format d'affichage de l'heure de réception : `DD/MM/YYYY HH:MM:SS` (par défaut,
|
||||
l'affichage français habituel), avec millisecondes, `YYYY-MM-DD`, horloge sur 12 heures,
|
||||
ISO 8601 ou epoch Unix. Le fuseau horaire s'applique à toutes les dates affichées.
|
||||
- **Filtres** : tags de couleur. Chaque tag a un mot-clé, une couleur de fond (le texte passe
|
||||
- **Motifs de recherche** : tags de couleur. Chaque tag a un mot-clé, une couleur de fond (le texte passe
|
||||
automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, respect
|
||||
de la casse, expression régulière, actif. Les tags sont stockés sur le serveur dans
|
||||
`/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs.
|
||||
@@ -355,10 +498,10 @@ couleur, est mémorisé par navigateur.
|
||||
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
|
||||
un aussi). La colonne *Motif de recherche* 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**
|
||||
- **Affichage**
|
||||
- *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), densité
|
||||
@@ -372,7 +515,7 @@ couleur, est mémorisé par navigateur.
|
||||
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. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed.
|
||||
- **Données** : chiffres de la base lus dans VictoriaLogs (actualisés au plus toutes les 30 s) :
|
||||
- **Base de données** : chiffres de la base lus dans VictoriaLogs (actualisés au plus toutes les 30 s) :
|
||||
lignes stockées, taille sur disque (index compris), taille brute et taux de compression,
|
||||
période couverte avec la rétention, lignes des dernières 24 h et de la dernière heure, hôtes
|
||||
et applications distincts, espace disque libre. Les tailles sont en Ko/Mo/Go (KB/MB/GB en
|
||||
@@ -382,6 +525,11 @@ couleur, est mémorisé par navigateur.
|
||||
désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
|
||||
alors purger : activez l'[authentification](#authentification) si l'interface est accessible
|
||||
à d'autres.
|
||||
- **API** : jetons de l'[API de lecture](#api-de-lecture-n8n-scripts) : en ajouter un avec un
|
||||
nom, en révoquer un, voir quand et d'où chacun a servi pour la dernière fois. Ajouts et
|
||||
révocations attendent **Valider** comme les autres réglages (**Annuler** les abandonne). Après
|
||||
Valider, chaque nouveau jeton est affiché une seule fois avec un bouton Copier, et l'exemple
|
||||
curl en dessous l'utilise, surligné, avec son propre bouton Copier. Réservé aux administrateurs.
|
||||
|
||||
## Authentification
|
||||
|
||||
@@ -503,6 +651,7 @@ résolutions.
|
||||
| `AUDIT_LOG` | `/var/log/audit/audit.log` | chemin par défaut du journal d'audit Linux (modifiable dans Réglages > Sources) |
|
||||
| `AUDIT_BACKFILL` | `1h` | historique lu à l'activation de la source « audit Linux » |
|
||||
| `AUDIT_HOST` | vide | nom d'hôte des événements d'audit quand les enregistrements n'ont pas de `node=` |
|
||||
| `FILES_BACKFILL` | `1h` | historique lu à l'activation d'un fichier de logs (Paramètres > Sources de logs > Fichiers de logs) |
|
||||
| `TZ` | `Europe/Paris` | fuseau horaire des horodatages RFC 3164 (qui n'en portent pas) |
|
||||
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | réglage de l'ingestion |
|
||||
| `SPOOL_MAX_MB` | `1024` | taille du tampon disque des lots refusés par VictoriaLogs (`0` = pas de tampon : 15 s de tentatives, puis perte) |
|
||||
@@ -543,7 +692,7 @@ Pour mettre à jour l'une d'elles :
|
||||
VictoriaLogs conserve son format de stockage entre versions mineures ; lisez le changelog
|
||||
avant un changement de version majeure.
|
||||
2. Modifiez la version dans le fichier indiqué ci-dessus, puis lancez `docker compose up -d --build`.
|
||||
3. Vérifiez la barre du bas (reçus / stockés / perdus) et **Paramètres > Sources**. Pour revenir
|
||||
3. Vérifiez la barre du bas (reçus / stockés / perdus) et **Paramètres > Sources de logs**. Pour revenir
|
||||
en arrière, remettez la version précédente et lancez la même commande.
|
||||
|
||||
## Organisation du code
|
||||
@@ -560,15 +709,19 @@ Pour mettre à jour l'une d'elles :
|
||||
| `hub.go` | envoie les nouveaux messages aux navigateurs (SSE) |
|
||||
| `rdns.go` | résolutions DNS inverses avec cache |
|
||||
| `export.go` | export CSV en flux |
|
||||
| `logsapi.go` | API de lecture `/api/v1/logs` : curseur, groupes de logs de même heure |
|
||||
| `apitokens.go` | jetons d'API : stockage (haché), contrôle Bearer, `/api/tokens` |
|
||||
| `docker.go` | logs des conteneurs Docker (API, lecteurs, positions, détection du niveau) |
|
||||
| `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources |
|
||||
| `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources de logs |
|
||||
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
|
||||
| `dmesg.go` | messages du noyau : lecture de `/dev/kmsg`, analyse des enregistrements, position par identifiant de démarrage et numéro de séquence |
|
||||
| `audit.go` | audit Linux : suivi de `audit.log` (rotation, position enregistrée), analyse des enregistrements, regroupement par événement, résumé |
|
||||
| `filesource.go`, `fileformats.go` | fichiers de logs : suivi (rotation, positions enregistrées, lignes de suite) et formats (détection, texte, JSON, logfmt, CLF, Traefik, expression régulière) |
|
||||
| `selflog.go` | logs internes de LogStream : `logEvent`, niveau minimal, limitation des répétitions, file bornée |
|
||||
| `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/*` |
|
||||
| `static.go` | fichiers de l'interface embarqués : ETag par fichier, revérifiés à chaque chargement (`no-cache`, 304 s'ils n'ont pas changé) pour toujours prendre une mise à jour ; polices en cache 30 jours |
|
||||
| `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
|
||||
|
||||
@@ -50,7 +50,7 @@ Then open <http://localhost:8080>.
|
||||
- **Manual test**: `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) or
|
||||
`echo "<14>test ok" | nc -u -w1 127.0.0.1 514`.
|
||||
|
||||
**Settings > Sources > Syslog** turns syslog reception on or off and chooses the protocols
|
||||
**Settings > Log sources > Syslog** turns syslog reception on or off and chooses the protocols
|
||||
(UDP, TCP) without restarting; the choice is saved in `/data/syslog.json`. It also shows the
|
||||
listening state (and the error if the port is already in use). The port itself is published
|
||||
by docker-compose: change `SYSLOG_PORT` in `.env`, then run `docker compose up -d`.
|
||||
@@ -83,13 +83,16 @@ versions before this change are indexed by their message timestamp; purge them f
|
||||
Settings to start clean.
|
||||
|
||||
Severity badges `err`/`crit` and `warning` use the colors of the `error` and `warning` tags.
|
||||
Each row starts with a fixed-width badge naming its source, one color per source: **SYSLOG**,
|
||||
**DOCKER**, **KERNEL** (dmesg), **HOST** (files of `/var/log`), **AUDIT**, **FILE** and **LS**
|
||||
(LogStream's own logs).
|
||||
|
||||
Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields.
|
||||
|
||||
The column headers stay visible while scrolling. Drag the edge of a header (received, message
|
||||
time, severity, host, app) to resize the column, double-click it to go back to the automatic
|
||||
width; the message takes the remaining space. Widths are remembered by the browser
|
||||
(**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its
|
||||
(**Settings > Appearance > Reset column widths** restores them all). On phones the list keeps its
|
||||
two-line layout without columns.
|
||||
|
||||
## Stream and Time range modes
|
||||
@@ -114,7 +117,7 @@ time** on the server clock (so it matches the times shown in the rows).
|
||||
- In live mode the last interval grows as messages arrive, and the timeline reloads at each new
|
||||
interval. Nothing is refreshed while the browser tab is hidden; it catches up when shown again.
|
||||
|
||||
**Settings > Interface > Timeline** (remembered per browser): scale (linear, √ by default, log),
|
||||
**Settings > Appearance > Timeline** (remembered per browser): scale (linear, √ by default, log),
|
||||
height (S/M/L: 40/80/120 px), color (stacked by severity, intensity compared with the median of
|
||||
the window: calm, burst above 3×, anomaly above 10×, or none), bars or area, division
|
||||
(automatic, about 100 intervals, or fixed: 1 s, 10 s, 1 min, 5 min, 1 h, 1 day) and refresh
|
||||
@@ -141,7 +144,7 @@ colored and exported like syslog messages:
|
||||
- The severity comes from the line itself when the application writes it: JSON
|
||||
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, or an upper-case level word at the
|
||||
start of the line (`ERROR`, `WARN`…). Otherwise it is `info`. Terminal color codes are removed.
|
||||
- **Settings > Sources** shows one label per container (`project/service`) in a single field:
|
||||
- **Settings > Log sources** shows one label per container (`project/service`) in a single field:
|
||||
followed containers in color, then not followed ones in grey; a click switches a label.
|
||||
The color identifies the compose project, and Docker app names in the log list use the
|
||||
same color. Stopped containers are hidden by default ("Show stopped containers" displays
|
||||
@@ -165,7 +168,7 @@ which only lets through listing containers, reading logs, events and engine info
|
||||
|
||||
Logstream can also collect the system logs of the machine hosting the stack, without
|
||||
configuring anything on the host. The source is **off by default**: turn it on in
|
||||
**Settings > Sources > Host system logs** (saved in `/data/hostlogs.json`).
|
||||
**Settings > Log sources > Host system logs** (saved in `/data/hostlogs.json`).
|
||||
|
||||
- `docker-compose.yml` mounts `/var/log` and `/run/log/journal` read-only under `/host`.
|
||||
- When the host runs systemd, Logstream reads the **systemd journal** files directly (no
|
||||
@@ -180,7 +183,7 @@ configuring anything on the host. The source is **off by default**: turn it on i
|
||||
- **Permissions**: the container runs as an unprivileged user and gets the `adm` group
|
||||
(gid 4), which can read the journal and `/var/log` on Debian and Ubuntu. On other systems,
|
||||
set `HOST_LOGS_GID` in `.env` to the gid of `systemd-journal`
|
||||
(`getent group systemd-journal | cut -d: -f3`). Settings > Sources shows a clear message when
|
||||
(`getent group systemd-journal | cut -d: -f3`). Settings > Log sources shows a clear message when
|
||||
access is denied.
|
||||
- Limits: journal fields compressed by journald (messages longer than 512 bytes, compressed
|
||||
with zstd/lz4/xz) cannot be decoded without extra libraries; they are counted in Settings and
|
||||
@@ -191,7 +194,7 @@ configuring anything on the host. The source is **off by default**: turn it on i
|
||||
|
||||
Logstream can collect the kernel ring buffer of the host (what `dmesg` shows: hardware,
|
||||
drivers, OOM killer, firewall…) by reading `/dev/kmsg`. The source is **off by default**: turn it
|
||||
on in **Settings > Sources > Kernel messages (dmesg)** (saved in `/data/dmesg.json`).
|
||||
on in **Settings > Log sources > Kernel messages (dmesg)** (saved in `/data/dmesg.json`).
|
||||
|
||||
- **Device**: the container does not see `/dev/kmsg` by default. Uncomment the `devices`
|
||||
section of `docker-compose.yml` (`/dev/kmsg:/dev/kmsg:r`, read-only), then
|
||||
@@ -205,7 +208,7 @@ on in **Settings > Sources > Kernel messages (dmesg)** (saved in `/data/dmesg.js
|
||||
- or run Logstream as root with the `SYSLOG` capability: uncomment `user: "0:0"` and
|
||||
`cap_add: [SYSLOG]` in `docker-compose.yml`.
|
||||
|
||||
Settings > Sources says when the device is missing or access is denied; the rest of
|
||||
Settings > Log sources says when the device is missing or access is denied; the rest of
|
||||
Logstream keeps working and the device is tried again every 10 seconds.
|
||||
- **Fields**: **app** is `kernel` (or the program name for messages written by programs such
|
||||
as `systemd[1]: …`), severity and facility come from the record, **host** is `localhost`,
|
||||
@@ -224,7 +227,7 @@ on in **Settings > Sources > Kernel messages (dmesg)** (saved in `/data/dmesg.js
|
||||
|
||||
Logstream can follow the log of the Linux audit daemon (`/var/log/audit/audit.log`): logins,
|
||||
`sudo`, watched files, syscalls matched by your `auditctl` rules… The source is **off by
|
||||
default**: turn it on in **Settings > Sources > Linux audit (auditd)**, where the file path can
|
||||
default**: turn it on in **Settings > Log sources > Linux audit (auditd)**, where the file path can
|
||||
also be changed (saved in `/data/audit.json`; `AUDIT_LOG` sets the default path).
|
||||
|
||||
- **Mount**: uncomment `/var/log/audit:/var/log/audit:ro` in `docker-compose.yml`, then
|
||||
@@ -240,7 +243,7 @@ also be changed (saved in `/data/audit.json`; `AUDIT_LOG` sets the default path)
|
||||
```
|
||||
auditd then creates every rotated file with mode `0640` and group `adm`. An ACL also works
|
||||
(`sudo setfacl -m u:10001:rx /var/log/audit && sudo setfacl -m u:10001:r /var/log/audit/audit.log`)
|
||||
but must be applied again to each new file after a rotation. Settings > Sources says when the
|
||||
but must be applied again to each new file after a rotation. Settings > Log sources says when the
|
||||
file is missing or access is denied; it is tried again every second.
|
||||
- **Events**: the records of one event (`SYSCALL`, `EXECVE`, `CWD`, `PATH`, `PROCTITLE`…) share
|
||||
the same `msg=audit(time:serial)` and become **one** log, once no record came for 2 seconds.
|
||||
@@ -254,7 +257,7 @@ also be changed (saved in `/data/audit.json`; `AUDIT_LOG` sets the default path)
|
||||
`auid` (login user ID), `user` (its name, or the uid when it cannot be resolved), `exe`, `res`
|
||||
(`success` / `failed`, from `res=` or `success=`) and `key` (rule key). Severity is `warning`
|
||||
when the result is a failure, `info` otherwise; facility `authpriv`; source `audit`. In the
|
||||
list, audit lines have a violet left edge, a light tint and an **AUDIT** badge before the app;
|
||||
list, audit lines have a violet left edge, a light tint and the **AUDIT** source badge;
|
||||
the **Source** filter shows them alone.
|
||||
- **Names**: `user` comes from the enriched format (`log_format = ENRICHED`, the default of
|
||||
recent auditd versions), else from the host's `/etc/passwd` when it is mounted as
|
||||
@@ -265,13 +268,68 @@ also be changed (saved in `/data/audit.json`; `AUDIT_LOG` sets the default path)
|
||||
`/data/audit-state.json`: after a restart, reading resumes there; after a rotation by auditd,
|
||||
the end of `audit.log.1` is read before the new file.
|
||||
|
||||
## Log files (Traefik, nginx, applications…)
|
||||
|
||||
Logstream can follow the log files of other programs: Traefik, nginx, Apache, or any application
|
||||
that writes to a file. Declare each file in **Settings > Log sources > Log files** (saved in
|
||||
`/data/files.json`, up to 50): a **name** (the **app** of its entries, by default the file name),
|
||||
the **path** of the file as mounted in the container, a **format** (*Automatic* by default), an
|
||||
optional **host** (default: `/host/etc/hostname`, else `localhost`) and a switch.
|
||||
|
||||
- **Mount** the directory read-only, e.g. in `docker-compose.yml`:
|
||||
`- /var/log/traefik:/logs/traefik:ro`, then `docker compose up -d`. Files under `/var/log`
|
||||
are already reachable as `/host/var/log/…`. The files must be readable by UID `10001` or the
|
||||
group of `HOST_LOGS_GID` (`adm` by default); Settings > Log sources says when no file matches
|
||||
or access is denied.
|
||||
- **Path**: one file, e.g. `/logs/traefik/access.log` (no wildcards). As soon as it is typed, the
|
||||
file is checked: found or not, readable or not, its detected format and its last line.
|
||||
- **Format detection** (*Automatic*): from the last lines of the file (50 at most), the
|
||||
structured format that at least 80 % of them follow — Traefik access JSON, JSON lines, access
|
||||
log CLF, logfmt — else plain text. It is done again when the format is changed; the format
|
||||
found is shown in the status of the file. Choose another format to force it (a regular
|
||||
expression is never detected).
|
||||
- **Formats**:
|
||||
- *Plain text*: one entry per line; the level is detected as for Docker logs, a timestamp at
|
||||
the start of the line (ISO 8601, `2006/01/02 15:04:05`, `[02/Jan/2006:15:04:05 +0200]`,
|
||||
`Jan 2 15:04:05`) becomes the message time and is removed from the message. Indented
|
||||
lines (stack traces) are added to the previous entry.
|
||||
- *JSON lines*: `msg` / `message`, `time` / `ts` / `@timestamp` (text or Unix time) and
|
||||
`level` (word or number, pino/bunyan included) are recognized; the other keys become
|
||||
fields, nested objects with dotted names (`req.id`).
|
||||
- *logfmt*: `key=value` pairs, as in Traefik's default logs or Go programs; same keys as JSON.
|
||||
- *Access log (CLF)*: Common / Combined Log Format of nginx and Apache, and Traefik's access
|
||||
log in this format (router, server URL and duration included). Fields `client_ip`,
|
||||
`http_method`, `http_path`, `http_status`, `http_size`, `user_agent`, `http_referer`,
|
||||
`router`, `backend`, `duration_ms`.
|
||||
- *Traefik access (JSON)*: the JSON access log of Traefik (`accessLog.format: json`),
|
||||
summarized as `GET app.example.com/ 200 · 12.5 ms · app@docker · 203.0.113.9`, every key in
|
||||
the fields. Traefik's own logs in the same file are read as JSON lines.
|
||||
- *Regular expression* (RE2 / Go syntax): the named groups `time`, `level`, `msg`, `host`
|
||||
and `app` fill these fields, `status` gives the level of an access log, the other groups
|
||||
become fields, e.g. `^(?P<time>\S+ \S+) \[(?P<level>\w+)\] (?P<module>\S+): (?P<msg>.*)$`.
|
||||
A line that does not match is added to the previous entry.
|
||||
|
||||
For access logs, 5xx statuses are `err`, 4xx `warning`, the others `info`. A line that does
|
||||
not fit the chosen format is kept as plain text. Fields named like LogStream's own ones
|
||||
(`host`, `app`…) are renamed `field_host`, `field_app`…
|
||||
- **Display**: source `file`, the path in `log_file`. In the list these lines have a green left
|
||||
edge, a light tint and the **FILE** source badge; the **Source** filter shows them alone.
|
||||
- When a file is turned on, the lines of the last hour already in it are read first
|
||||
(`FILES_BACKFILL`, `0` = only new lines; lines without a timestamp are then left out). The
|
||||
start of that hour is found by bisection on the dates of the lines, so a large file that holds
|
||||
months of logs is not read from its start; a file created later is read from its start, up to
|
||||
64 MB per second, the status showing what is left to read. The inode and an offset are saved per file in
|
||||
`/data/files-state.json`: after a restart reading resumes there; after a rotation
|
||||
(`access.log` renamed `access.log.1`), the end of the old file is read before the new one;
|
||||
a file truncated in place (`copytruncate`) is read again from its start.
|
||||
|
||||
## LogStream internal logs
|
||||
|
||||
Logstream records its own events as logs, with **host** `logstream`, **app** the component
|
||||
(`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `main`) and the `logstream` source:
|
||||
(`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `files`, `main`) and the `logstream` source:
|
||||
VictoriaLogs unreachable and back, lost batches, batches refused from the disk buffer, syslog
|
||||
listener errors, Docker API or container streams lost, sources that cannot read, start and
|
||||
stop. They are on by default from level **warning**; **Settings > Sources > LogStream internal
|
||||
stop. They are on by default from level **warning**; **Settings > Log sources > LogStream internal
|
||||
logs** turns them off or changes the minimum level (saved in `/data/selflog.json`). The same
|
||||
event (component and message) is recorded at most once every 30 seconds, with the number of
|
||||
repeats left out; events go through a bounded queue and never wait for the storage, so an
|
||||
@@ -296,10 +354,82 @@ chosen in Settings), `severity`, `facility`, `host`, `host_ip`, `app`, `pid`, `s
|
||||
|
||||
From the command line: `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&range=24h&tz=Europe/Paris'`.
|
||||
|
||||
## Reading API (n8n, scripts)
|
||||
|
||||
`GET /api/v1/logs` returns the stored logs that follow a cursor, oldest first, so that a tool
|
||||
called on a schedule (n8n, a script, a SIEM connector) gets every new log once, without gaps
|
||||
or duplicates, even when many logs share the same timestamp.
|
||||
|
||||
Access goes through an **API token**, created in *Settings > API* by an administrator and sent
|
||||
as `Authorization: Bearer lst_…`. A token is read-only and only opens `/api/v1/`; it works
|
||||
whatever the [authentication](#authentication) mode (`local`, `oidc`, or none). It is shown once,
|
||||
when it is created: LogStream only keeps its SHA-256 hash, in `/data/api_tokens.json`.
|
||||
Revoking it (then Save) cuts off access at once; the list shows when and from where each token was last
|
||||
used. A logged-in browser session can call the route too.
|
||||
|
||||
| Parameter | Meaning |
|
||||
|---|---|
|
||||
| `query` | LogsQL filter (default `*`), e.g. `severity:in(err,crit) app:sshd`. Pipes (`\| ...`) are refused. |
|
||||
| `since` | where to start: the `next_cursor` of the previous answer, an RFC 3339 time (`2026-11-01T08:00:00Z`) or a duration back from now (`15m`, `6h`, `7d`). Default: `1h`. |
|
||||
| `limit` | rows per answer, 1 to 10,000 (default 1,000). |
|
||||
| `lag` | only logs received at least this long ago are returned (default `30s`, at most `1h`), so that the logs still in the queue or waiting for their DNS name are not skipped. |
|
||||
|
||||
```json
|
||||
{
|
||||
"rows": [{"_time": "2026-11-01T14:02:11.52Z", "_msg": "…", "host": "fw01", "app": "sshd", "severity": "err", …}],
|
||||
"next_cursor": "eyJ0IjoiMjAyNi0xMS0wMVQxNDowMjoxMS41MjAwMDAwMDFaIiwicSI6IjJlYjE4YzNhIn0",
|
||||
"has_more": false,
|
||||
"until": "2026-11-01T14:05:30.1Z"
|
||||
}
|
||||
```
|
||||
|
||||
Rows have the stored fields (as in the UI's JSON view), sorted by `_time`, the reception time.
|
||||
Always send back `next_cursor`, even when `rows` is empty. `has_more: true` means more logs are
|
||||
ready: call again at once. `waiting: true` means logs from the [disk buffer](#configuration) are
|
||||
still being sent to VictoriaLogs: nothing is returned until they are stored, so none is missed.
|
||||
The cursor belongs to its `query`: changing the query keeps the position in time. Errors come
|
||||
as `{"error", "code"}` with status 400 (bad parameter or LogsQL), 401 (invalid or revoked token)
|
||||
or 502 (VictoriaLogs unreachable).
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer lst_…" \
|
||||
"https://logs.example.org/api/v1/logs?query=severity:err&since=15m&limit=1000"
|
||||
```
|
||||
|
||||
### Example: n8n workflow
|
||||
|
||||
Four nodes, the cursor kept in the workflow's static data between runs:
|
||||
|
||||
1. **Schedule Trigger**: every minute.
|
||||
2. **Code** (*Run Once for All Items*), reads the cursor:
|
||||
```js
|
||||
const data = $getWorkflowStaticData('global');
|
||||
return [{ json: { since: data.cursor || '15m' } }];
|
||||
```
|
||||
3. **HTTP Request**: method `GET`, URL `https://logs.example.org/api/v1/logs`.
|
||||
*Authentication*: Generic Credential Type > Header Auth, with name `Authorization` and value
|
||||
`Bearer lst_…`. *Send Query Parameters*: `query` = `severity:in(emerg,alert,crit,err)`,
|
||||
`since` = `{{ $json.since }}`, `limit` = `1000`.
|
||||
4. **Code** (*Run Once for All Items*), saves the cursor and turns each log into an item:
|
||||
```js
|
||||
const data = $getWorkflowStaticData('global');
|
||||
const page = $input.first().json;
|
||||
data.cursor = page.next_cursor;
|
||||
return page.rows.map((row) => ({ json: row }));
|
||||
```
|
||||
|
||||
Then connect what the logs should feed (Slack, mail, ticket, database…). n8n only keeps static
|
||||
data for runs of the active workflow, not for manual test runs: while testing, each run starts
|
||||
again from `15m`. When more than 1,000 logs arrive per minute, `has_more` is true and the
|
||||
following runs catch up; raise `limit` or run the trigger more often if needed.
|
||||
|
||||
## Settings
|
||||
|
||||
The gear icon opens the settings, organized in tabs. Everything except color tags is
|
||||
remembered per browser.
|
||||
The gear icon opens the settings, organized in tabs. Changes show at once but are only kept
|
||||
with the **Save** button, under the tab labels: it takes the accent color while changes are
|
||||
waiting. **Cancel** goes back to the saved settings, and closing the window with unsaved changes
|
||||
asks before dropping them. Localization and Appearance are remembered per browser; color tags
|
||||
and log sources are saved on the server. Purging and resetting column widths act at once.
|
||||
|
||||
- **Localization**
|
||||
- *Language*: English or French.
|
||||
@@ -307,7 +437,7 @@ remembered per browser.
|
||||
format of the reception time: `DD/MM/YYYY HH:MM:SS` (default, the usual French display),
|
||||
with milliseconds, `YYYY-MM-DD`, 12-hour clock, ISO 8601 or Unix epoch. The time zone
|
||||
applies to every date shown.
|
||||
- **Filters**: color tags. Each tag has a keyword, a background color (the text automatically
|
||||
- **Patterns**: color tags. Each tag has a keyword, a background color (the text automatically
|
||||
switches to black or white to stay readable) and options: whole word, match case,
|
||||
regular expression, active. Tags are stored on the server in `/data/tags.json`
|
||||
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
|
||||
@@ -324,10 +454,10 @@ remembered per browser.
|
||||
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
|
||||
*Pattern* 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**
|
||||
- **Appearance**
|
||||
- *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), density (normal, or compact to
|
||||
@@ -339,7 +469,7 @@ remembered per browser.
|
||||
[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. The densest setting is Tiny + Compact + Inconsolata Condensed.
|
||||
- **Data**: database figures read from VictoriaLogs (refreshed at most every 30 s): stored
|
||||
- **Database**: database figures read from VictoriaLogs (refreshed at most every 30 s): stored
|
||||
lines, size on disk (index included), raw size and compression ratio, period covered with
|
||||
the retention, lines of the last 24 h and last hour, distinct hosts and apps, free disk
|
||||
space. Sizes use KB/MB/GB (Ko/Mo/Go in French). "Delete all logs" permanently erases every stored log (you must type
|
||||
@@ -347,6 +477,11 @@ remembered per browser.
|
||||
(already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
|
||||
to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
|
||||
is reachable by others.
|
||||
- **API**: API tokens for the [reading API](#reading-api-n8n-scripts): add one with a name,
|
||||
revoke one, see when and from where each was last used. Additions and revocations wait for
|
||||
**Save** like the other settings (**Cancel** drops them). After Save, each new token is shown
|
||||
once with a copy button, and the curl example below uses it, highlighted, with its own copy
|
||||
button. Reserved to administrators.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -457,9 +592,10 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
|
||||
| `HOST_LOGS_ROOT` | `/host` | where the host directories are mounted |
|
||||
| `DMESG_BACKFILL` | `1h` | history read when the kernel messages source is turned on |
|
||||
| `DMESG_DEVICE` | `/dev/kmsg` | kernel log device read by the kernel messages source |
|
||||
| `AUDIT_LOG` | `/var/log/audit/audit.log` | default path of the Linux audit log (can be changed in Settings > Sources) |
|
||||
| `AUDIT_LOG` | `/var/log/audit/audit.log` | default path of the Linux audit log (can be changed in Settings > Log sources) |
|
||||
| `AUDIT_BACKFILL` | `1h` | history read when the Linux audit source is turned on |
|
||||
| `AUDIT_HOST` | empty | host name of the audit events when the records carry no `node=` |
|
||||
| `FILES_BACKFILL` | `1h` | history read when a log file is turned on (Settings > Log sources > Log files) |
|
||||
| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) |
|
||||
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning |
|
||||
| `SPOOL_MAX_MB` | `1024` | disk buffer size for batches VictoriaLogs could not take (`0` = no buffer: retried for 15 s, then dropped) |
|
||||
@@ -498,7 +634,7 @@ To update one of them:
|
||||
VictoriaLogs keeps its storage format across minor versions; read the changelog before a
|
||||
major version change.
|
||||
2. Change the version in the file above, then run `docker compose up -d --build`.
|
||||
3. Check the bottom bar (received / stored / dropped) and **Settings > Sources**. To go back,
|
||||
3. Check the bottom bar (received / stored / dropped) and **Settings > Log sources**. To go back,
|
||||
restore the previous version and run the same command.
|
||||
|
||||
## Code layout
|
||||
@@ -515,15 +651,19 @@ To update one of them:
|
||||
| `hub.go` | pushes new messages to browsers (SSE) |
|
||||
| `rdns.go` | cached reverse DNS lookups |
|
||||
| `export.go` | streamed CSV export |
|
||||
| `logsapi.go` | reading API `/api/v1/logs`: cursor, groups of logs with the same time |
|
||||
| `apitokens.go` | API tokens: storage (hashed), Bearer check, `/api/tokens` |
|
||||
| `docker.go` | Docker container logs (API, followers, positions, level detection) |
|
||||
| `syslogserver.go` | syslog listeners opened and closed from Settings > Sources |
|
||||
| `syslogserver.go` | syslog listeners opened and closed from Settings > Log sources |
|
||||
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
|
||||
| `dmesg.go` | kernel messages: `/dev/kmsg` reader, record parsing, position by boot ID and sequence number |
|
||||
| `audit.go` | Linux audit: `audit.log` follower (rotation, saved offset), record parsing, grouping by event, summary |
|
||||
| `filesource.go`, `fileformats.go` | log files: follower (rotation, saved offsets, continuation lines) and formats (detection, text, JSON, logfmt, CLF, Traefik, regular expression) |
|
||||
| `selflog.go` | LogStream internal logs: `logEvent`, minimum level, throttling, bounded queue |
|
||||
| `tags.go` | color tag storage |
|
||||
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
|
||||
| `api.go` | `/api/*` HTTP routes |
|
||||
| `static.go` | embedded UI files: ETag per file, revalidated on each load (`no-cache`, 304 when unchanged) so an update is always picked up; fonts cached 30 days |
|
||||
| `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
|
||||
|
||||
@@ -25,13 +25,19 @@ type API struct {
|
||||
host *HostLogs
|
||||
dmesg *Dmesg
|
||||
audit *Audit
|
||||
files *Files
|
||||
selfLog *SelfLog
|
||||
dbstats dbStatsCache
|
||||
tokens *TokenStore
|
||||
}
|
||||
|
||||
func (a *API) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", a.health)
|
||||
mux.HandleFunc("GET /api/logs", a.logs)
|
||||
mux.HandleFunc("GET /api/v1/logs", a.logsV1)
|
||||
mux.HandleFunc("GET /api/tokens", a.listTokens)
|
||||
mux.HandleFunc("POST /api/tokens", a.createToken)
|
||||
mux.HandleFunc("DELETE /api/tokens/{id}", a.deleteToken)
|
||||
mux.HandleFunc("GET /api/histogram", a.histogram)
|
||||
mux.HandleFunc("GET /api/facets", a.facets)
|
||||
mux.HandleFunc("GET /api/stream", a.stream)
|
||||
@@ -39,6 +45,8 @@ func (a *API) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/dbstats", a.dbStats)
|
||||
mux.HandleFunc("GET /api/tags", a.listTags)
|
||||
mux.HandleFunc("POST /api/tags", a.createTag)
|
||||
mux.HandleFunc("PUT /api/tags", a.replaceTags)
|
||||
mux.HandleFunc("GET /api/tags/defaults", a.defaultTags)
|
||||
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
|
||||
mux.HandleFunc("GET /api/presets", a.listPresets)
|
||||
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
|
||||
@@ -56,6 +64,9 @@ func (a *API) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /api/dmesg", a.dmesgConfigure)
|
||||
mux.HandleFunc("GET /api/audit", a.auditStatus)
|
||||
mux.HandleFunc("PUT /api/audit", a.auditConfigure)
|
||||
mux.HandleFunc("GET /api/files", a.filesStatus)
|
||||
mux.HandleFunc("PUT /api/files", a.filesConfigure)
|
||||
mux.HandleFunc("POST /api/files/detect", a.filesDetect)
|
||||
mux.HandleFunc("GET /api/selflog", a.selfLogStatus)
|
||||
mux.HandleFunc("PUT /api/selflog", a.selfLogConfigure)
|
||||
}
|
||||
@@ -80,6 +91,41 @@ func (a *API) auditConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.audit.Status())
|
||||
}
|
||||
|
||||
// GET /api/files: the log files of the "Files" source (Settings > Sources).
|
||||
func (a *API) filesStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.files.Status())
|
||||
}
|
||||
|
||||
// PUT /api/files {"sources": [{"id", "name", "path", "format" (auto or a format), "regex", "host", "enabled"}]}
|
||||
// (no id: a new file).
|
||||
func (a *API) filesConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg filesConfig
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&cfg); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := a.files.Configure(cfg); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
log.Printf("file sources changed from %s: %d files", r.RemoteAddr, len(cfg.Sources))
|
||||
writeJSON(w, http.StatusOK, a.files.Status())
|
||||
}
|
||||
|
||||
// POST /api/files/detect {"path": "/logs/traefik/access.log"}: whether the file
|
||||
// can be read, its detected format and its last line (Settings > Sources). A
|
||||
// POST, so that read-only accounts cannot read files through it.
|
||||
func (a *API) filesDetect(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, DetectFile(req.Path))
|
||||
}
|
||||
|
||||
// GET /api/selflog: state of the LogStream internal logs (Settings > Sources).
|
||||
func (a *API) selfLogStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.selfLog.Status())
|
||||
@@ -492,6 +538,41 @@ func (a *API) purge(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"task_id": id})
|
||||
}
|
||||
|
||||
// PUT /api/tags [tags]: replaces the whole list. On an invalid tag, "index"
|
||||
// gives its position and nothing is saved.
|
||||
func (a *API) replaceTags(w http.ResponseWriter, r *http.Request) {
|
||||
var in []Tag
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
tags, err := a.tags.Replace(in)
|
||||
var le *tagListError
|
||||
switch {
|
||||
case errors.As(err, &le):
|
||||
body := map[string]any{"error": err.Error(), "index": le.index}
|
||||
var ce *codedError
|
||||
if errors.As(err, &ce) {
|
||||
body["code"] = ce.code
|
||||
if ce.detail != "" {
|
||||
body["detail"] = ce.detail
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, body)
|
||||
case errors.Is(err, errTooManyTags):
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
case err != nil:
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, tags)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/tags/defaults: the tags "Restore default tags" shows before saving.
|
||||
func (a *API) defaultTags(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, defaultTags())
|
||||
}
|
||||
|
||||
func (a *API) resetTags(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := a.tags.Reset()
|
||||
if err != nil {
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// API tokens: read-only access to /api/v1/ for scripts and automation tools
|
||||
// (n8n…), sent as "Authorization: Bearer lst_…". They are created and revoked
|
||||
// in Settings > API by an administrator, and work whatever the AUTH_MODE,
|
||||
// including without authentication. Only a SHA-256 of each token is kept, in
|
||||
// DATA_DIR/api_tokens.json: a token is shown once, when it is created.
|
||||
|
||||
const (
|
||||
tokenPrefix = "lst_"
|
||||
maxTokens = 100
|
||||
lastUsedPeriod = time.Minute // last use of a token is written to disk at most this often (or on a new IP)
|
||||
)
|
||||
|
||||
var errTokenNotFound = errors.New("token not found")
|
||||
|
||||
type apiToken struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint"` // start of the token, to recognize it
|
||||
Hash string `json:"hash"` // hex SHA-256 of the whole token
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed time.Time `json:"last_used,omitzero"`
|
||||
LastIP string `json:"last_ip,omitempty"`
|
||||
}
|
||||
|
||||
// apiTokenInfo is what the UI sees of a token (never its hash).
|
||||
type apiTokenInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint"`
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed time.Time `json:"last_used,omitzero"`
|
||||
LastIP string `json:"last_ip,omitempty"`
|
||||
}
|
||||
|
||||
type TokenStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
tokens []apiToken
|
||||
}
|
||||
|
||||
func LoadTokenStore(path string) (*TokenStore, error) {
|
||||
s := &TokenStore{path: path}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &s.tokens); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// save writes the file atomically, readable by its owner only; the caller holds the lock.
|
||||
func (s *TokenStore) save() error {
|
||||
b, err := json.MarshalIndent(s.tokens, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *TokenStore) List() []apiTokenInfo {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]apiTokenInfo, len(s.tokens))
|
||||
for i, t := range s.tokens {
|
||||
out[i] = apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created, LastUsed: t.LastUsed, LastIP: t.LastIP}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Create makes a new token and returns it in clear, the only time it exists so.
|
||||
func (s *TokenStore) Create(name string) (apiTokenInfo, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || utf8.RuneCountInString(name) > 80 {
|
||||
return apiTokenInfo{}, "", &codedError{code: "token_name", msg: "give the token a name of 1 to 80 characters"}
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return apiTokenInfo{}, "", err
|
||||
}
|
||||
secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(b)
|
||||
t := apiToken{ID: newID(), Name: name, Hint: secret[:len(tokenPrefix)+6], Hash: hashToken(secret), Created: time.Now().UTC()}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.tokens) >= maxTokens {
|
||||
return apiTokenInfo{}, "", &codedError{code: "token_max", msg: fmt.Sprintf("at most %d API tokens", maxTokens)}
|
||||
}
|
||||
s.tokens = append(s.tokens, t)
|
||||
if err := s.save(); err != nil {
|
||||
s.tokens = s.tokens[:len(s.tokens)-1]
|
||||
return apiTokenInfo{}, "", err
|
||||
}
|
||||
return apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created}, secret, nil
|
||||
}
|
||||
|
||||
func (s *TokenStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, t := range s.tokens {
|
||||
if t.ID == id {
|
||||
s.tokens = append(s.tokens[:i:i], s.tokens[i+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return errTokenNotFound
|
||||
}
|
||||
|
||||
// Check tells whether token is a valid API token, and notes its use.
|
||||
func (s *TokenStore) Check(token, ip string) (name string, ok bool) {
|
||||
if !strings.HasPrefix(token, tokenPrefix) {
|
||||
return "", false
|
||||
}
|
||||
h := []byte(hashToken(token))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.tokens {
|
||||
t := &s.tokens[i]
|
||||
if subtle.ConstantTimeCompare(h, []byte(t.Hash)) != 1 {
|
||||
continue
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
stale := now.Sub(t.LastUsed) >= lastUsedPeriod || t.LastIP != ip
|
||||
t.LastUsed, t.LastIP = now, ip
|
||||
if stale {
|
||||
if err := s.save(); err != nil {
|
||||
log.Printf("api tokens: %v", err)
|
||||
}
|
||||
}
|
||||
return t.Name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// bearerToken returns the token of an "Authorization: Bearer …" header.
|
||||
func bearerToken(r *http.Request) (string, bool) {
|
||||
scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(token), true
|
||||
}
|
||||
|
||||
// tokenAuth serves the requests carrying an API token itself, read-only and
|
||||
// limited to /api/v1/, before the session login (auth); api is the handler
|
||||
// behind the login.
|
||||
func tokenAuth(tokens *TokenStore, auth, api http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearerToken(r)
|
||||
if !ok {
|
||||
auth.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if _, valid := tokens.Check(token, ip); !valid {
|
||||
log.Printf("api: invalid or revoked token from %s", ip)
|
||||
time.Sleep(loginFailDelay)
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="logstream"`)
|
||||
writeErr(w, http.StatusUnauthorized, &codedError{code: "token", msg: "invalid or revoked API token"})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || !isSafeMethod(r.Method) {
|
||||
writeErr(w, http.StatusForbidden, &codedError{code: "token_scope", msg: "API tokens only give read access to /api/v1/"})
|
||||
return
|
||||
}
|
||||
api.ServeHTTP(w, asViewer(r))
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/tokens: the API tokens (Settings > API), for administrators.
|
||||
func (a *API) listTokens(w http.ResponseWriter, r *http.Request) {
|
||||
if isViewer(r) {
|
||||
writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a.tokens.List())
|
||||
}
|
||||
|
||||
// POST /api/tokens {"name": "n8n"}: returns the new token, with "token" in clear.
|
||||
func (a *API) createToken(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
info, secret, err := a.tokens.Create(body.Name)
|
||||
var ce *codedError
|
||||
switch {
|
||||
case errors.As(err, &ce):
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
case err != nil:
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
log.Printf("api token %q created from %s", info.Name, clientIP(r))
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
apiTokenInfo
|
||||
Token string `json:"token"`
|
||||
}{info, secret})
|
||||
}
|
||||
|
||||
// DELETE /api/tokens/{id}: revokes a token at once.
|
||||
func (a *API) deleteToken(w http.ResponseWriter, r *http.Request) {
|
||||
err := a.tokens.Delete(r.PathValue("id"))
|
||||
switch {
|
||||
case errors.Is(err, errTokenNotFound):
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
case err != nil:
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
default:
|
||||
log.Printf("api token %s revoked from %s", r.PathValue("id"), clientIP(r))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTokenStore(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "api_tokens.json")
|
||||
s, err := LoadTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Create(" "); err == nil {
|
||||
t.Fatal("a token without a name should be refused")
|
||||
}
|
||||
info, secret, err := s.Create("n8n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(secret, tokenPrefix) || len(secret) < 40 || !strings.HasPrefix(secret, info.Hint) {
|
||||
t.Fatalf("token %q, hint %q", secret, info.Hint)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(b), secret) || !strings.Contains(string(b), hashToken(secret)) {
|
||||
t.Fatal("the file must hold the hash of the token, not the token")
|
||||
}
|
||||
if st, _ := os.Stat(path); st.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("file mode %v, want 0600", st.Mode().Perm())
|
||||
}
|
||||
if _, ok := s.Check(secret+"x", "10.0.0.1"); ok {
|
||||
t.Fatal("a wrong token was accepted")
|
||||
}
|
||||
if name, ok := s.Check(secret, "10.0.0.1"); !ok || name != "n8n" {
|
||||
t.Fatal("the token was refused")
|
||||
}
|
||||
|
||||
// Reloaded from disk (restart): still valid, last use kept.
|
||||
s2, err := LoadTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := s2.List(); len(l) != 1 || l[0].LastIP != "10.0.0.1" || l[0].LastUsed.IsZero() {
|
||||
t.Fatalf("after reload: %+v", l)
|
||||
}
|
||||
if _, ok := s2.Check(secret, "10.0.0.2"); !ok {
|
||||
t.Fatal("token refused after reload")
|
||||
}
|
||||
if err := s2.Delete(info.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s2.Check(secret, "10.0.0.2"); ok {
|
||||
t.Fatal("a revoked token was accepted")
|
||||
}
|
||||
if err := s2.Delete(info.ID); err != errTokenNotFound {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAuth(t *testing.T) {
|
||||
loginFailDelay = 0
|
||||
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
||||
_, secret, _ := tokens.Create("n8n")
|
||||
|
||||
var reached string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = r.URL.Path
|
||||
if isViewer(r) {
|
||||
reached += " viewer"
|
||||
}
|
||||
})
|
||||
app := readOnly(mux)
|
||||
login := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeAuthRequired(w) })
|
||||
h := tokenAuth(tokens, login, app)
|
||||
|
||||
call := func(method, path, auth string) int {
|
||||
reached = ""
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
if code := call("GET", "/api/v1/logs", "Bearer "+secret); code != http.StatusOK || reached != "/api/v1/logs viewer" {
|
||||
t.Fatalf("valid token: %d, reached %q", code, reached)
|
||||
}
|
||||
if code := call("GET", "/api/v1/logs", "bearer "+secret); code != http.StatusOK {
|
||||
t.Fatalf("lower-case scheme: %d", code)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
method, path, auth string
|
||||
want int
|
||||
}{
|
||||
{"GET", "/api/v1/logs", "Bearer lst_wrong", http.StatusUnauthorized},
|
||||
{"GET", "/api/v1/logs", "Bearer ", http.StatusUnauthorized},
|
||||
{"GET", "/api/tags", "Bearer " + secret, http.StatusForbidden},
|
||||
{"GET", "/api/tokens", "Bearer " + secret, http.StatusForbidden},
|
||||
{"POST", "/api/v1/logs", "Bearer " + secret, http.StatusForbidden},
|
||||
{"GET", "/api/v1/logs", "", http.StatusUnauthorized}, // no token: the login decides
|
||||
{"GET", "/api/v1/logs", "Basic dTpw", http.StatusUnauthorized}, // Basic goes to the login
|
||||
} {
|
||||
if code := call(tc.method, tc.path, tc.auth); code != tc.want || reached != "" {
|
||||
t.Errorf("%s %s %q: %d (reached %q), want %d", tc.method, tc.path, tc.auth, code, reached, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With local authentication, a token opens /api/v1/ without a session.
|
||||
func TestTokenWithLocalAuth(t *testing.T) {
|
||||
loginFailDelay = 0
|
||||
dir := t.TempDir()
|
||||
tokens, _ := LoadTokenStore(filepath.Join(dir, "api_tokens.json"))
|
||||
_, secret, _ := tokens.Create("script")
|
||||
app := readOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }))
|
||||
auth, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", dataDir: dir, sessionTTL: time.Hour}, app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := tokenAuth(tokens, auth, app)
|
||||
req := httptest.NewRequest("GET", "/api/v1/logs", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("without token: %d", rec.Code)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
||||
t.Fatalf("with token: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandlers(t *testing.T) {
|
||||
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
||||
a := &API{tokens: tokens}
|
||||
mux := http.NewServeMux()
|
||||
a.Routes(mux)
|
||||
do := func(r *http.Request) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
readOnly(mux).ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
rec := do(httptest.NewRequest("POST", "/api/tokens", strings.NewReader(`{"name":"n8n"}`)))
|
||||
var created struct {
|
||||
ID, Token, Hint string
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &created); rec.Code != http.StatusCreated || err != nil || created.Token == "" {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = do(httptest.NewRequest("GET", "/api/tokens", nil))
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), created.Token) || strings.Contains(rec.Body.String(), "hash") {
|
||||
t.Fatalf("list must not show the token or its hash: %s", rec.Body)
|
||||
}
|
||||
if rec := do(asViewer(httptest.NewRequest("GET", "/api/tokens", nil))); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer list: %d", rec.Code)
|
||||
}
|
||||
if rec := do(asViewer(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil))); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer delete: %d", rec.Code)
|
||||
}
|
||||
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete again: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -6,10 +6,10 @@ services:
|
||||
depends_on:
|
||||
- victorialogs
|
||||
- docker-proxy
|
||||
ports:
|
||||
- "${SYSLOG_PORT:-514}:5514/udp"
|
||||
- "${SYSLOG_PORT:-514}:5514/tcp"
|
||||
- "${HTTP_PORT:-8080}:8080"
|
||||
# ports:
|
||||
# - "${SYSLOG_PORT:-5514}:5514/udp"
|
||||
# - "${SYSLOG_PORT:-5514}:5514/tcp"
|
||||
# - "${HTTP_PORT:-8989}:8080"
|
||||
environment:
|
||||
VLOGS_URL: http://victorialogs:9428
|
||||
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
||||
@@ -39,6 +39,7 @@ services:
|
||||
HOST_LOGS_BACKFILL: ${HOST_LOGS_BACKFILL:-1h} # historique lu a l'activation des logs systeme de l'hote
|
||||
DMESG_BACKFILL: ${DMESG_BACKFILL:-1h} # historique lu a l'activation des messages du noyau (dmesg)
|
||||
AUDIT_BACKFILL: ${AUDIT_BACKFILL:-1h} # historique lu a l'activation de l'audit Linux (auditd)
|
||||
FILES_BACKFILL: ${FILES_BACKFILL:-1h} # historique lu a l'activation d'un fichier de logs (Reglages > Sources)
|
||||
AUDIT_HOST: ${AUDIT_HOST:-} # nom d'hote des evenements d'audit (vide : node= des enregistrements, sinon /host/etc/hostname)
|
||||
group_add:
|
||||
- "${HOST_LOGS_GID:-4}" # groupe autorise a lire les logs de l'hote (4 = adm sur Debian/Ubuntu)
|
||||
@@ -59,10 +60,12 @@ services:
|
||||
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
|
||||
# audit Linux (auditd), source a activer dans Reglages > Sources ; le fichier doit etre
|
||||
# lisible par l'UID 10001 : log_group = adm dans /etc/audit/auditd.conf (voir le README)
|
||||
# - /var/log/audit:/var/log/audit:ro
|
||||
- /var/log/audit:/var/log/audit:ro
|
||||
# noms des utilisateurs et de l'hote pour les evenements d'audit (optionnel)
|
||||
# - /etc/passwd:/host/etc/passwd:ro
|
||||
# - /etc/hostname:/host/etc/hostname:ro
|
||||
- /etc/passwd:/host/etc/passwd:ro
|
||||
- /etc/hostname:/host/etc/hostname:ro
|
||||
# fichiers de logs d'autres programmes (Traefik, nginx...), a declarer dans Reglages > Sources > Fichiers de logs
|
||||
- /home/traefik/log:/home/logs/traefik:ro
|
||||
# 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
|
||||
@@ -99,7 +102,12 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
labels:
|
||||
logstream.exclude: "true" # pour exclure les logs propres de logstream
|
||||
networks:
|
||||
- traefik-net
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
volumes:
|
||||
logstream-data:
|
||||
vlogs-data:
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# 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
|
||||
Dans **Paramètres › Motifs de recherche**, 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.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# Color tag presets
|
||||
|
||||
In **Settings › Filters**, the **+ Preset…** menu adds a group of ready-made color tags in one
|
||||
In **Settings › Patterns**, 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.
|
||||
|
||||
|
||||
+689
@@ -0,0 +1,689 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Formats of the "Files" source (Settings > Sources), one per declared file.
|
||||
const (
|
||||
fmtAuto = "auto" // detected from the last lines of the file
|
||||
fmtText = "text" // one line per entry, level and leading timestamp detected
|
||||
fmtJSON = "json" // JSON lines: msg/time/level recognized, other keys as fields
|
||||
fmtLogfmt = "logfmt" // key=value pairs (Traefik "common" logs, Go programs…)
|
||||
fmtRegex = "regex" // regular expression with named groups
|
||||
fmtCLF = "clf" // access logs in Common/Combined Log Format (nginx, Apache, Traefik)
|
||||
fmtTraefik = "traefik" // Traefik access log in JSON
|
||||
)
|
||||
|
||||
var fileFormats = map[string]bool{fmtText: true, fmtJSON: true, fmtLogfmt: true, fmtRegex: true, fmtCLF: true, fmtTraefik: true}
|
||||
|
||||
// fileRec is one parsed line (or several, for continuation lines).
|
||||
type fileRec struct {
|
||||
t time.Time
|
||||
dated bool // t comes from the line
|
||||
sev int
|
||||
msg string
|
||||
host string
|
||||
app string
|
||||
fields map[string]string
|
||||
}
|
||||
|
||||
const (
|
||||
maxFileFields = 64
|
||||
maxFieldValue = 4096
|
||||
maxFileMsg = 64 << 10 // message with its continuation lines
|
||||
maxContinuation = 500 // continuation lines added to one entry
|
||||
)
|
||||
|
||||
// fileParser turns a line of a file into an entry, for one format.
|
||||
type fileParser struct {
|
||||
format string
|
||||
re *regexp.Regexp // fmtRegex
|
||||
}
|
||||
|
||||
func newFileParser(format, expr string) (*fileParser, error) {
|
||||
if !fileFormats[format] {
|
||||
return nil, fmt.Errorf("unknown format %q", format)
|
||||
}
|
||||
p := &fileParser{format: format}
|
||||
if format == fmtRegex {
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
return nil, fmt.Errorf("the regex format needs a regular expression")
|
||||
}
|
||||
re, err := regexp.Compile(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid regular expression: %v", err)
|
||||
}
|
||||
p.re = re
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// multiline tells whether lines that do not start an entry are added to the
|
||||
// previous one (stack traces…). Structured formats are one entry per line.
|
||||
func (p *fileParser) multiline() bool {
|
||||
return p.format == fmtText || p.format == fmtRegex
|
||||
}
|
||||
|
||||
// continuation reports a line that belongs to the previous entry: indented,
|
||||
// or not matching the regular expression.
|
||||
func (p *fileParser) continuation(line string) bool {
|
||||
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
||||
return true
|
||||
}
|
||||
return p.format == fmtRegex && !p.re.MatchString(line)
|
||||
}
|
||||
|
||||
// parse decodes one line; it never fails (a line in another format is kept as text).
|
||||
func (p *fileParser) parse(line string, now time.Time) *fileRec {
|
||||
line = cleanLine(line)
|
||||
switch p.format {
|
||||
case fmtJSON, fmtTraefik:
|
||||
if obj, ok := decodeJSONLine(line); ok {
|
||||
if p.format == fmtTraefik {
|
||||
if r := traefikRec(obj, line, now); r != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return jsonRec(obj, line, now)
|
||||
}
|
||||
case fmtLogfmt:
|
||||
if kv := parseLogfmt(line); len(kv) > 0 {
|
||||
return logfmtRec(kv, line, now)
|
||||
}
|
||||
case fmtRegex:
|
||||
if m := p.re.FindStringSubmatch(line); m != nil {
|
||||
return p.regexRec(m, line, now)
|
||||
}
|
||||
case fmtCLF:
|
||||
if r := clfRec(line, now); r != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return textRec(line, now)
|
||||
}
|
||||
|
||||
func cleanLine(s string) string {
|
||||
s = strings.TrimRight(s, "\r")
|
||||
s = ansiRe.ReplaceAllString(s, "")
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, "�")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// textRec: the line itself, without its leading timestamp when there is one.
|
||||
func textRec(line string, now time.Time) *fileRec {
|
||||
r := &fileRec{t: now, sev: 6, msg: line}
|
||||
if m := leadTimeRe.FindStringSubmatchIndex(line); m != nil {
|
||||
if t, ok := parseLogTime(line[m[2]:m[3]], now); ok {
|
||||
r.t, r.dated = t, true
|
||||
if rest := line[m[1]:]; strings.TrimSpace(rest) != "" {
|
||||
r.msg = rest
|
||||
}
|
||||
}
|
||||
}
|
||||
if n, ok := detectLevel(r.msg); ok {
|
||||
r.sev = n
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/* ---------- Timestamps ---------- */
|
||||
|
||||
// leadTimeRe: a timestamp at the start of a line, possibly in brackets
|
||||
// (ISO 8601 / RFC 3339, 2006/01/02 15:04:05, CLF, syslog "Jan _2 15:04:05").
|
||||
var leadTimeRe = regexp.MustCompile(`^\[?(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?: ?(?:Z|[+-]\d{2}:?\d{2}))?|\d{2}/[A-Z][a-z]{2}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}|[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}:\d{2})\]?[ \t]*(?:[-|:][ \t]+)?`)
|
||||
|
||||
var timeLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02T15:04:05Z0700",
|
||||
"2006-01-02T15:04:05",
|
||||
"02/Jan/2006:15:04:05 -0700",
|
||||
"Jan _2 15:04:05",
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.UnixDate,
|
||||
time.ANSIC,
|
||||
}
|
||||
|
||||
// parseLogTime reads the usual log timestamps; times without a zone are local.
|
||||
func parseLogTime(s string, now time.Time) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if s[0] >= '0' && s[0] <= '9' && !strings.ContainsAny(s, ":/-") {
|
||||
if whole, _, _ := strings.Cut(s, "."); len(whole) < 9 {
|
||||
return time.Time{}, false // not a Unix time of this century
|
||||
}
|
||||
return epochTime(s)
|
||||
}
|
||||
// 2006/01/02 15:04:05,123 +02:00 -> 2006-01-02T15:04:05.123+02:00
|
||||
if len(s) >= 19 && s[4] == '/' && s[7] == '/' {
|
||||
s = s[:4] + "-" + s[5:7] + "-" + s[8:]
|
||||
}
|
||||
if len(s) >= 19 && s[4] == '-' && s[10] == ' ' {
|
||||
s = s[:10] + "T" + s[11:]
|
||||
}
|
||||
if len(s) > 19 && s[4] == '-' {
|
||||
s = s[:19] + strings.Replace(strings.Replace(s[19:], ",", ".", 1), " ", "", 1)
|
||||
}
|
||||
s = strings.Join(strings.Fields(s), " ") // "Jan 5" -> "Jan 5"
|
||||
for _, layout := range timeLayouts {
|
||||
if layout == "Jan _2 15:04:05" {
|
||||
layout = "Jan 2 15:04:05"
|
||||
}
|
||||
t, err := time.ParseInLocation(layout, s, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if t.Year() == 0 { // syslog timestamps have no year
|
||||
t = t.AddDate(now.Year(), 0, 0)
|
||||
if t.After(now.Add(24 * time.Hour)) {
|
||||
t = t.AddDate(-1, 0, 0)
|
||||
}
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// epochTime reads a Unix time in seconds, milliseconds, microseconds or
|
||||
// nanoseconds (guessed from its size), with an optional fraction.
|
||||
func epochTime(s string) (time.Time, bool) {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || f <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
switch {
|
||||
case f < 1e11:
|
||||
return time.Unix(0, int64(f*1e9)), true
|
||||
case f < 1e14:
|
||||
return time.UnixMilli(int64(f)), true
|
||||
case f < 1e17:
|
||||
return time.UnixMicro(int64(f)), true
|
||||
}
|
||||
return time.Unix(0, int64(f)), true
|
||||
}
|
||||
|
||||
/* ---------- Fields ---------- */
|
||||
|
||||
// reservedFields are stored by LogStream itself: fields of the logs with the
|
||||
// same name are renamed field_<name>.
|
||||
var reservedFields = map[string]bool{
|
||||
"_time": true, "_msg": true, "_stream": true, "_stream_id": true, "received": true, "msg_time": true,
|
||||
"host": true, "host_name": true, "host_ip": true, "app": true, "facility": true, "severity": true,
|
||||
"sevnum": true, "source": true, "proto": true, "procid": true, "sd": true, "source_type": true, "log_file": true,
|
||||
}
|
||||
|
||||
func fieldName(k string) string {
|
||||
k = strings.Map(func(r rune) rune {
|
||||
if r == '_' || r == '.' || r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, k)
|
||||
if k == "" || reservedFields[k] || k[0] == '_' {
|
||||
k = "field_" + strings.TrimLeft(k, "_")
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func (r *fileRec) set(k, v string) {
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
if r.fields == nil {
|
||||
r.fields = map[string]string{}
|
||||
}
|
||||
k = fieldName(k)
|
||||
if _, ok := r.fields[k]; !ok && len(r.fields) >= maxFileFields {
|
||||
return
|
||||
}
|
||||
if len(v) > maxFieldValue {
|
||||
v = strings.ToValidUTF8(v[:maxFieldValue], "") + "…"
|
||||
}
|
||||
r.fields[k] = v
|
||||
}
|
||||
|
||||
/* ---------- JSON lines ---------- */
|
||||
|
||||
func decodeJSONLine(line string) (map[string]any, bool) {
|
||||
s := strings.TrimSpace(line)
|
||||
if len(s) < 2 || s[0] != '{' {
|
||||
return nil, false
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(s))
|
||||
dec.UseNumber()
|
||||
var obj map[string]any
|
||||
if dec.Decode(&obj) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return obj, true
|
||||
}
|
||||
|
||||
var (
|
||||
jsonMsgKeys = []string{"msg", "message", "@message", "log", "text"}
|
||||
jsonTimeKeys = []string{"time", "ts", "timestamp", "@timestamp", "@t", "t", "datetime", "date"}
|
||||
jsonLevelKeys = []string{"level", "lvl", "severity", "log.level", "@level", "@l", "loglevel", "levelname"}
|
||||
)
|
||||
|
||||
// takeString removes the first of keys found in obj and returns it as text.
|
||||
func takeString(obj map[string]any, keys []string) (string, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := obj[k]; ok {
|
||||
if s := scalarString(v); s != "" {
|
||||
delete(obj, k)
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func scalarString(v any) string {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func jsonRec(obj map[string]any, line string, now time.Time) *fileRec {
|
||||
r := &fileRec{t: now, sev: 6, msg: line}
|
||||
for _, k := range jsonTimeKeys {
|
||||
if t, ok := parseLogTime(scalarString(obj[k]), now); ok {
|
||||
r.t, r.dated = t, true
|
||||
delete(obj, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
lvl, hasLevel := takeString(obj, jsonLevelKeys)
|
||||
if hasLevel {
|
||||
if n, ok := levelNum(lvl); ok {
|
||||
r.sev = n
|
||||
} else if n, err := strconv.Atoi(lvl); err == nil {
|
||||
r.sev = numericLevel(n)
|
||||
} else {
|
||||
r.set("level", lvl)
|
||||
}
|
||||
}
|
||||
if s, ok := takeString(obj, jsonMsgKeys); ok {
|
||||
r.msg = strings.TrimRight(s, "\n")
|
||||
}
|
||||
flattenJSON("", obj, r)
|
||||
if !hasLevel {
|
||||
if n, ok := detectLevel(r.msg); ok {
|
||||
r.sev = n
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// numericLevel maps numeric levels: syslog (0-7) or pino/bunyan (10-60).
|
||||
func numericLevel(n int) int {
|
||||
switch {
|
||||
case n >= 0 && n <= 7:
|
||||
return n
|
||||
case n >= 60:
|
||||
return 2
|
||||
case n >= 50:
|
||||
return 3
|
||||
case n >= 40:
|
||||
return 4
|
||||
case n >= 30:
|
||||
return 6
|
||||
}
|
||||
return 7
|
||||
}
|
||||
|
||||
// flattenJSON stores the remaining keys as fields; nested objects give
|
||||
// dotted names, arrays stay in JSON.
|
||||
func flattenJSON(prefix string, obj map[string]any, r *fileRec) {
|
||||
for k, v := range obj {
|
||||
name := prefix + k
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
flattenJSON(name+".", v, r)
|
||||
case []any:
|
||||
b, _ := json.Marshal(v)
|
||||
r.set(name, string(b))
|
||||
case nil:
|
||||
default:
|
||||
r.set(name, scalarString(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- logfmt ---------- */
|
||||
|
||||
// parseLogfmt splits `k=v k2="v 2"` (with \" escapes); words without a value
|
||||
// are skipped. Nil when the line has no pair.
|
||||
func parseLogfmt(s string) [][2]string {
|
||||
var out [][2]string
|
||||
for i := 0; i < len(s); {
|
||||
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
start := i
|
||||
for i < len(s) && s[i] != '=' && s[i] != ' ' && s[i] != '\t' {
|
||||
i++
|
||||
}
|
||||
if i >= len(s) || s[i] != '=' || i == start {
|
||||
for i < len(s) && s[i] != ' ' && s[i] != '\t' {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
k := s[start:i]
|
||||
i++
|
||||
var v string
|
||||
if i < len(s) && s[i] == '"' {
|
||||
var b strings.Builder
|
||||
i++
|
||||
for i < len(s) && s[i] != '"' {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
switch s[i] {
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
} else {
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
i++
|
||||
}
|
||||
i++ // closing quote
|
||||
v = b.String()
|
||||
} else {
|
||||
end := i
|
||||
for end < len(s) && s[end] != ' ' && s[end] != '\t' {
|
||||
end++
|
||||
}
|
||||
v, i = s[i:end], end
|
||||
}
|
||||
out = append(out, [2]string{k, v})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func logfmtRec(kv [][2]string, line string, now time.Time) *fileRec {
|
||||
obj := make(map[string]any, len(kv))
|
||||
for _, p := range kv {
|
||||
obj[p[0]] = p[1]
|
||||
}
|
||||
return jsonRec(obj, line, now)
|
||||
}
|
||||
|
||||
/* ---------- Regular expression ---------- */
|
||||
|
||||
func (p *fileParser) regexRec(m []string, line string, now time.Time) *fileRec {
|
||||
r := &fileRec{t: now, sev: 6, msg: line}
|
||||
hasLevel := false
|
||||
for i, name := range p.re.SubexpNames() {
|
||||
if i == 0 || name == "" || m[i] == "" {
|
||||
continue
|
||||
}
|
||||
v := m[i]
|
||||
switch strings.ToLower(name) {
|
||||
case "time", "timestamp", "ts":
|
||||
if t, ok := parseLogTime(v, now); ok {
|
||||
r.t, r.dated = t, true
|
||||
}
|
||||
case "level", "severity", "lvl":
|
||||
if n, ok := levelNum(v); ok {
|
||||
r.sev, hasLevel = n, true
|
||||
}
|
||||
case "msg", "message":
|
||||
r.msg = v
|
||||
case "host":
|
||||
r.host = v
|
||||
case "app":
|
||||
r.app = v
|
||||
case "status":
|
||||
r.set(name, v)
|
||||
if n, ok := statusLevel(v); ok && !hasLevel {
|
||||
r.sev, hasLevel = n, true
|
||||
}
|
||||
default:
|
||||
r.set(name, v)
|
||||
}
|
||||
}
|
||||
if !hasLevel {
|
||||
if n, ok := detectLevel(r.msg); ok {
|
||||
r.sev = n
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/* ---------- Access logs ---------- */
|
||||
|
||||
// statusLevel: 5xx are errors, 4xx warnings, the rest info.
|
||||
func statusLevel(s string) (int, bool) {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 100 || n > 999 {
|
||||
return 0, false
|
||||
}
|
||||
switch {
|
||||
case n >= 500:
|
||||
return 3, true
|
||||
case n >= 400:
|
||||
return 4, true
|
||||
}
|
||||
return 6, true
|
||||
}
|
||||
|
||||
// clfRe: Common Log Format, the combined extension (referer, user agent) and
|
||||
// the Traefik one (request count, router, server URL, duration).
|
||||
var clfRe = regexp.MustCompile(`^(\S+) (\S+) (\S+) \[([^\]]+)\] "((?:[^"\\]|\\.)*)" (\d{3}) (\d+|-)(?: "((?:[^"\\]|\\.)*)" "((?:[^"\\]|\\.)*)")?(?: (\d+) "([^"]*)" "([^"]*)" (\d+)ms)?`)
|
||||
|
||||
func clfRec(line string, now time.Time) *fileRec {
|
||||
m := clfRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
r := &fileRec{t: now, sev: 6, msg: line}
|
||||
if t, ok := parseLogTime(m[4], now); ok {
|
||||
r.t, r.dated = t, true
|
||||
}
|
||||
dash := func(s string) string {
|
||||
if s == "-" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
r.set("client_ip", m[1])
|
||||
r.set("http_user", dash(m[3]))
|
||||
if req := strings.Fields(m[5]); len(req) == 3 {
|
||||
r.set("http_method", req[0])
|
||||
r.set("http_path", req[1])
|
||||
r.set("http_proto", req[2])
|
||||
} else {
|
||||
r.set("http_request", m[5])
|
||||
}
|
||||
r.set("http_status", m[6])
|
||||
r.set("http_size", dash(m[7]))
|
||||
r.set("http_referer", dash(m[8]))
|
||||
r.set("user_agent", dash(m[9]))
|
||||
r.set("router", dash(m[11]))
|
||||
r.set("backend", dash(m[12]))
|
||||
r.set("duration_ms", m[13])
|
||||
r.sev, _ = statusLevel(m[6])
|
||||
return r
|
||||
}
|
||||
|
||||
// traefikRec reads a Traefik access log line in JSON; nil for the other
|
||||
// lines (Traefik's own logs), read as generic JSON.
|
||||
func traefikRec(obj map[string]any, line string, now time.Time) *fileRec {
|
||||
status := scalarString(obj["DownstreamStatus"])
|
||||
if status == "" {
|
||||
status = scalarString(obj["OriginStatus"])
|
||||
}
|
||||
method := scalarString(obj["RequestMethod"])
|
||||
if status == "" && method == "" {
|
||||
return nil
|
||||
}
|
||||
r := &fileRec{t: now, sev: 6}
|
||||
if t, ok := parseLogTime(firstNonEmpty(scalarString(obj["StartUTC"]), scalarString(obj["time"])), now); ok {
|
||||
r.t, r.dated = t, true
|
||||
}
|
||||
if n, ok := statusLevel(status); ok {
|
||||
r.sev = n
|
||||
}
|
||||
rename := map[string]string{
|
||||
"ClientHost": "client_ip", "ClientUsername": "http_user", "RequestMethod": "http_method",
|
||||
"RequestHost": "http_host", "RequestPath": "http_path", "RequestProtocol": "http_proto",
|
||||
"DownstreamStatus": "http_status", "DownstreamContentSize": "http_size", "OriginStatus": "origin_status",
|
||||
"RouterName": "router", "ServiceName": "service", "ServiceURL": "backend", "entryPointName": "entrypoint",
|
||||
"request_User-Agent": "user_agent", "request_Referer": "http_referer",
|
||||
}
|
||||
dur := ""
|
||||
if d, err := strconv.ParseFloat(scalarString(obj["Duration"]), 64); err == nil {
|
||||
dur = strconv.FormatFloat(d/1e6, 'f', -1, 64)
|
||||
r.set("duration_ms", dur)
|
||||
}
|
||||
for _, k := range []string{"Duration", "StartUTC", "StartLocal", "time", "level", "msg"} {
|
||||
delete(obj, k)
|
||||
}
|
||||
for k, v := range obj {
|
||||
if to, ok := rename[k]; ok {
|
||||
r.set(to, scalarString(v))
|
||||
delete(obj, k)
|
||||
}
|
||||
}
|
||||
flattenJSON("", obj, r)
|
||||
// "GET example.com/path 404 · 12.5 ms · router@docker"
|
||||
var b strings.Builder
|
||||
b.WriteString(firstNonEmpty(method, "-"))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(r.fields["http_host"])
|
||||
b.WriteString(r.fields["http_path"])
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(firstNonEmpty(status, "-"))
|
||||
if dur != "" {
|
||||
b.WriteString(" · " + dur + " ms")
|
||||
}
|
||||
if rt := r.fields["router"]; rt != "" {
|
||||
b.WriteString(" · " + rt)
|
||||
}
|
||||
if ip := r.fields["client_ip"]; ip != "" {
|
||||
b.WriteString(" · " + ip)
|
||||
}
|
||||
r.msg = b.String()
|
||||
return r
|
||||
}
|
||||
|
||||
/* ---------- Detection ---------- */
|
||||
|
||||
const detectWindow = 64 << 10
|
||||
|
||||
// sampleLines returns the last complete lines of a file (50 at most).
|
||||
func sampleLines(path string) ([]string, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fh.Close()
|
||||
fi, err := fh.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
off := fi.Size() - detectWindow
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
buf, err := io.ReadAll(io.NewSectionReader(fh, off, detectWindow))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := bytes.LastIndexByte(buf, '\n')
|
||||
if end < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
buf = buf[:end]
|
||||
if off > 0 { // first line cut by the window
|
||||
if nl := bytes.IndexByte(buf, '\n'); nl >= 0 {
|
||||
buf = buf[nl+1:]
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
var lines []string
|
||||
for _, b := range bytes.Split(buf, []byte{'\n'}) {
|
||||
if len(bytes.TrimSpace(b)) > 0 {
|
||||
lines = append(lines, string(bytes.TrimRight(b, "\r")))
|
||||
}
|
||||
}
|
||||
if len(lines) > 50 {
|
||||
lines = lines[len(lines)-50:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// logfmtKeys: the pairs have one of the usual logfmt keys.
|
||||
func logfmtKeys(kv [][2]string) bool {
|
||||
for _, p := range kv {
|
||||
switch strings.ToLower(p[0]) {
|
||||
case "level", "lvl", "msg", "message", "time", "ts", "t":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectFormat guesses the format of a file from some of its lines: the
|
||||
// structured format most lines follow, else plain text (a regular
|
||||
// expression is never guessed).
|
||||
func detectFormat(lines []string) string {
|
||||
var js, traefik, clf, logfmt int
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if obj, ok := decodeJSONLine(l); ok {
|
||||
js++
|
||||
if _, ok := obj["RequestMethod"]; ok {
|
||||
traefik++
|
||||
} else if _, ok := obj["DownstreamStatus"]; ok {
|
||||
traefik++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if clfRe.MatchString(l) {
|
||||
clf++
|
||||
continue
|
||||
}
|
||||
if kv := parseLogfmt(l); len(kv) >= 3 && logfmtKeys(kv) {
|
||||
logfmt++
|
||||
}
|
||||
}
|
||||
most := func(n int) bool { return n > 0 && n*5 >= len(lines)*4 } // 80 %
|
||||
switch {
|
||||
case most(js) && traefik*2 >= js:
|
||||
return fmtTraefik
|
||||
case most(js):
|
||||
return fmtJSON
|
||||
case most(clf):
|
||||
return fmtCLF
|
||||
case most(logfmt):
|
||||
return fmtLogfmt
|
||||
}
|
||||
return fmtText
|
||||
}
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fileSourceDef is one log file declared in Settings > Sources.
|
||||
type fileSourceDef struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // app of the entries
|
||||
Path string `json:"path"` // the file, as mounted in the container
|
||||
Format string `json:"format"` // auto (detected), or text, json, logfmt, regex, clf, traefik
|
||||
Regex string `json:"regex,omitempty"` // format regex: named groups time, level, msg, host, app…
|
||||
Host string `json:"host,omitempty"` // host of the entries (empty: the host name of the machine)
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// filesConfig is saved in /data/files.json.
|
||||
type filesConfig struct {
|
||||
Sources []fileSourceDef `json:"sources"`
|
||||
}
|
||||
|
||||
// filePos is where reading resumes in a file: its inode and an offset before
|
||||
// which every entry was sent. Until: end of the history found when the
|
||||
// source was turned on; entries before it are kept only when dated within
|
||||
// FILES_BACKFILL.
|
||||
type filePos struct {
|
||||
Ino uint64 `json:"ino"`
|
||||
Off int64 `json:"off"`
|
||||
Until int64 `json:"until,omitempty"`
|
||||
}
|
||||
|
||||
type filesState struct {
|
||||
Pos map[string]filePos `json:"pos"` // by source id
|
||||
Started map[string]time.Time `json:"started"` // when each source was turned on
|
||||
}
|
||||
|
||||
type fileSourceStatus struct {
|
||||
Open bool
|
||||
Read uint64
|
||||
Detected string // format found when the source is in auto
|
||||
Behind int64 // bytes left to read (history being read)
|
||||
Code string
|
||||
Error string
|
||||
}
|
||||
|
||||
// fileSource is a definition with its parser (nil in auto: detected from
|
||||
// the file).
|
||||
type fileSource struct {
|
||||
fileSourceDef
|
||||
parser *fileParser
|
||||
}
|
||||
|
||||
// pendingEntry is an entry waiting for its continuation lines.
|
||||
type pendingEntry struct {
|
||||
rec *fileRec
|
||||
off int64 // offset of its first line
|
||||
seen time.Time // when its last line was read
|
||||
history bool // read from the history found when the source was turned on
|
||||
lines int
|
||||
}
|
||||
|
||||
// fileTail is the reading state of one file, owned by the Run goroutine.
|
||||
type fileTail struct {
|
||||
readOff int64 // where the next read starts (after the lines of the pending entry)
|
||||
known bool // readOff is set
|
||||
pending *pendingEntry
|
||||
auto *fileParser // format detected from the file, in auto
|
||||
behind int64 // bytes left to read after the last poll
|
||||
}
|
||||
|
||||
// Files follows log files of other programs (Traefik, nginx, applications…)
|
||||
// mounted in the container, one entry per line in the format detected or
|
||||
// chosen for each file. Source type "file".
|
||||
type Files struct {
|
||||
sink func(*Entry)
|
||||
hostName string
|
||||
backfill time.Duration
|
||||
cfgPath string
|
||||
statePath string
|
||||
wake chan struct{}
|
||||
|
||||
// Owned by the Run goroutine.
|
||||
st filesState
|
||||
tails map[string]*fileTail
|
||||
dirty bool
|
||||
|
||||
mu sync.Mutex
|
||||
cfg []*fileSource
|
||||
resets map[string]bool // sources to read again from FILES_BACKFILL ago
|
||||
redetect map[string]bool // sources whose format changed
|
||||
status map[string]*fileSourceStatus
|
||||
}
|
||||
|
||||
func NewFiles(hostRoot, dataDir string, backfill time.Duration, sink func(*Entry)) *Files {
|
||||
f := &Files{
|
||||
sink: sink,
|
||||
hostName: readTrim(filepath.Join(hostRoot, "etc/hostname")),
|
||||
backfill: backfill,
|
||||
cfgPath: filepath.Join(dataDir, "files.json"),
|
||||
statePath: filepath.Join(dataDir, "files-state.json"),
|
||||
wake: make(chan struct{}, 1),
|
||||
tails: map[string]*fileTail{},
|
||||
resets: map[string]bool{},
|
||||
redetect: map[string]bool{},
|
||||
status: map[string]*fileSourceStatus{},
|
||||
}
|
||||
if f.hostName == "" {
|
||||
f.hostName = "localhost"
|
||||
}
|
||||
var cfg filesConfig
|
||||
if b, err := os.ReadFile(f.cfgPath); err == nil {
|
||||
_ = json.Unmarshal(b, &cfg)
|
||||
}
|
||||
for _, d := range cfg.Sources {
|
||||
src, err := compileFileSource(d)
|
||||
if err != nil {
|
||||
logEvent("files", 4, "file %q ignored: %v", d.Name, err)
|
||||
continue
|
||||
}
|
||||
f.cfg = append(f.cfg, src)
|
||||
}
|
||||
if b, err := os.ReadFile(f.statePath); err == nil {
|
||||
_ = json.Unmarshal(b, &f.st)
|
||||
}
|
||||
if f.st.Pos == nil {
|
||||
f.st.Pos = map[string]filePos{}
|
||||
}
|
||||
if f.st.Started == nil {
|
||||
f.st.Started = map[string]time.Time{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
const maxFileSources = 50
|
||||
|
||||
var fileIDRe = regexp.MustCompile(`^[0-9a-f]{1,32}$`)
|
||||
|
||||
// checkFilePath: one file, absolute, without wildcards.
|
||||
func checkFilePath(p string) (string, error) {
|
||||
p = strings.TrimSpace(p)
|
||||
switch {
|
||||
case !filepath.IsAbs(p):
|
||||
return "", errors.New("the path must be absolute (as mounted in the container)")
|
||||
case strings.ContainsAny(p, "*?["):
|
||||
return "", errors.New("give the path of one file (no wildcards)")
|
||||
}
|
||||
return filepath.Clean(p), nil
|
||||
}
|
||||
|
||||
// compileFileSource checks a definition and builds its parser.
|
||||
func compileFileSource(d fileSourceDef) (*fileSource, error) {
|
||||
d.Name = strings.TrimSpace(d.Name)
|
||||
d.Host = strings.TrimSpace(d.Host)
|
||||
if d.Format == "" {
|
||||
d.Format = fmtAuto
|
||||
}
|
||||
if d.Format != fmtRegex {
|
||||
d.Regex = ""
|
||||
}
|
||||
switch {
|
||||
case !fileIDRe.MatchString(d.ID):
|
||||
return nil, errors.New("invalid id")
|
||||
case d.Name == "" || len(d.Name) > 64:
|
||||
return nil, errors.New("the name is required (64 characters at most)")
|
||||
case len(d.Host) > 253:
|
||||
return nil, fmt.Errorf("%s: host name too long", d.Name)
|
||||
}
|
||||
p, err := checkFilePath(d.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %v", d.Name, err)
|
||||
}
|
||||
d.Path = p
|
||||
src := &fileSource{fileSourceDef: d}
|
||||
if d.Format != fmtAuto {
|
||||
if src.parser, err = newFileParser(d.Format, d.Regex); err != nil {
|
||||
return nil, fmt.Errorf("%s: %v", d.Name, err)
|
||||
}
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func newFileID() string {
|
||||
b := make([]byte, 6)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Configure checks, saves and applies the list of files. Files turned off,
|
||||
// removed or given another path are read again from FILES_BACKFILL ago the
|
||||
// next time they are on.
|
||||
func (f *Files) Configure(cfg filesConfig) error {
|
||||
if len(cfg.Sources) > maxFileSources {
|
||||
return fmt.Errorf("%d files at most", maxFileSources)
|
||||
}
|
||||
var srcs []*fileSource
|
||||
ids := map[string]bool{}
|
||||
for _, d := range cfg.Sources {
|
||||
if d.ID == "" {
|
||||
d.ID = newFileID()
|
||||
}
|
||||
if ids[d.ID] {
|
||||
return fmt.Errorf("duplicate id %s", d.ID)
|
||||
}
|
||||
ids[d.ID] = true
|
||||
src, err := compileFileSource(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
saved := filesConfig{Sources: []fileSourceDef{}}
|
||||
for _, s := range srcs {
|
||||
saved.Sources = append(saved.Sources, s.fileSourceDef)
|
||||
}
|
||||
if err := writeJSONFile(f.cfgPath, saved); err != nil {
|
||||
return err
|
||||
}
|
||||
f.mu.Lock()
|
||||
old := map[string]*fileSource{}
|
||||
for _, s := range f.cfg {
|
||||
old[s.ID] = s
|
||||
}
|
||||
for _, s := range srcs {
|
||||
o := old[s.ID]
|
||||
if o != nil && ((o.Enabled && !s.Enabled) || o.Path != s.Path) {
|
||||
f.resets[s.ID] = true
|
||||
delete(f.status, s.ID)
|
||||
}
|
||||
if o != nil && (o.Format != s.Format || o.Regex != s.Regex) {
|
||||
f.redetect[s.ID] = true
|
||||
}
|
||||
delete(old, s.ID)
|
||||
}
|
||||
for id := range old { // removed
|
||||
f.resets[id] = true
|
||||
delete(f.status, id)
|
||||
}
|
||||
f.cfg = srcs
|
||||
f.mu.Unlock()
|
||||
select {
|
||||
case f.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status is the state shown in Settings > Sources.
|
||||
func (f *Files) Status() map[string]any {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
list := make([]map[string]any, 0, len(f.cfg))
|
||||
for _, s := range f.cfg {
|
||||
m := map[string]any{
|
||||
"id": s.ID, "name": s.Name, "path": s.Path, "format": s.Format, "regex": s.Regex,
|
||||
"host": s.Host, "enabled": s.Enabled, "open": false, "read": 0, "detected": "", "behind": 0, "code": "", "error": "",
|
||||
}
|
||||
if st := f.status[s.ID]; st != nil && s.Enabled {
|
||||
m["open"], m["read"], m["detected"], m["code"], m["error"] = st.Open, st.Read, st.Detected, st.Code, st.Error
|
||||
m["behind"] = st.Behind
|
||||
}
|
||||
list = append(list, m)
|
||||
}
|
||||
return map[string]any{"sources": list, "defaultHost": f.hostName}
|
||||
}
|
||||
|
||||
// Run polls the files every second.
|
||||
func (f *Files) Run(ctx context.Context) {
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
n := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
now := time.Now()
|
||||
for id := range f.tails {
|
||||
f.flushPending(id, now)
|
||||
}
|
||||
f.checkpointAll()
|
||||
f.saveState()
|
||||
return
|
||||
case <-tick.C:
|
||||
case <-f.wake:
|
||||
}
|
||||
f.poll(time.Now())
|
||||
if n++; n%5 == 0 {
|
||||
f.saveState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Files) saveState() {
|
||||
if !f.dirty {
|
||||
return
|
||||
}
|
||||
f.dirty = false
|
||||
if err := writeJSONFile(f.statePath, f.st); err != nil {
|
||||
logEvent("files", 4, "saving positions: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// poll reads what was added to every file that is on.
|
||||
func (f *Files) poll(now time.Time) {
|
||||
f.mu.Lock()
|
||||
srcs := f.cfg
|
||||
resets, redetect := f.resets, f.redetect
|
||||
f.resets, f.redetect = map[string]bool{}, map[string]bool{}
|
||||
f.mu.Unlock()
|
||||
for id := range resets {
|
||||
delete(f.st.Pos, id)
|
||||
delete(f.st.Started, id)
|
||||
delete(f.tails, id)
|
||||
f.dirty = true
|
||||
}
|
||||
for id := range redetect {
|
||||
if tl := f.tails[id]; tl != nil {
|
||||
tl.auto = nil
|
||||
}
|
||||
}
|
||||
active := map[string]bool{}
|
||||
for _, s := range srcs {
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
active[s.ID] = true
|
||||
f.setStatus(s.ID, f.pollFile(s, now))
|
||||
}
|
||||
// Files turned off: their position is kept until the next poll forgets it.
|
||||
for id := range f.tails {
|
||||
if !active[id] {
|
||||
delete(f.tails, id)
|
||||
}
|
||||
}
|
||||
f.checkpointAll()
|
||||
}
|
||||
|
||||
const (
|
||||
maxFileRead = 4 << 20 // per read
|
||||
maxFileReads = 16 // reads per file and per poll (64 MB)
|
||||
fileContinueDelay = 2 * time.Second // an entry waits this long for continuation lines
|
||||
)
|
||||
|
||||
func (f *Files) setStatus(id string, err error) {
|
||||
code, detail := "", ""
|
||||
if err != nil {
|
||||
code, detail = errCode(err), err.Error()
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
code = "not_found"
|
||||
}
|
||||
}
|
||||
detected := ""
|
||||
if tl := f.tails[id]; tl != nil && tl.auto != nil {
|
||||
detected = tl.auto.format
|
||||
}
|
||||
f.mu.Lock()
|
||||
st := f.status[id]
|
||||
if st == nil {
|
||||
st = &fileSourceStatus{}
|
||||
f.status[id] = st
|
||||
}
|
||||
changed := code != st.Code
|
||||
st.Open, st.Detected, st.Code, st.Error = err == nil, detected, code, detail
|
||||
st.Behind = 0
|
||||
if tl := f.tails[id]; tl != nil && err == nil {
|
||||
st.Behind = tl.behind
|
||||
}
|
||||
name := id
|
||||
for _, s := range f.cfg {
|
||||
if s.ID == id {
|
||||
name = s.Name
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if changed && code != "" && code != "not_found" {
|
||||
logEvent("files", 4, "cannot read the file %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Files) count(id string) {
|
||||
f.mu.Lock()
|
||||
st := f.status[id]
|
||||
if st == nil { // first poll of the file: its status comes after
|
||||
st = &fileSourceStatus{}
|
||||
f.status[id] = st
|
||||
}
|
||||
st.Read++
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// parserOf is the parser of a file: the chosen format, or the one detected
|
||||
// from its last lines (nil while the file has none).
|
||||
func (f *Files) parserOf(s *fileSource, tl *fileTail, path string) *fileParser {
|
||||
if s.parser != nil {
|
||||
return s.parser
|
||||
}
|
||||
if tl.auto == nil {
|
||||
lines, err := sampleLines(path)
|
||||
if err != nil || len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
tl.auto, _ = newFileParser(detectFormat(lines), "")
|
||||
}
|
||||
return tl.auto
|
||||
}
|
||||
|
||||
func (f *Files) pollFile(s *fileSource, now time.Time) error {
|
||||
path := s.Path
|
||||
first := false
|
||||
if _, ok := f.st.Started[s.ID]; !ok {
|
||||
f.st.Started[s.ID], f.dirty, first = now, true, true
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
tl := f.tails[s.ID]
|
||||
if tl == nil {
|
||||
tl = &fileTail{}
|
||||
f.tails[s.ID] = tl
|
||||
}
|
||||
ino := inodeOf(fi)
|
||||
pos, known := f.st.Pos[s.ID]
|
||||
if !known {
|
||||
pos = filePos{Ino: ino}
|
||||
if first {
|
||||
// Turned on: the last FILES_BACKFILL of what is already there
|
||||
// (0: only new lines), found by date rather than read from the
|
||||
// start of a file that can hold months of logs.
|
||||
pos.Off = fi.Size()
|
||||
if p := f.parserOf(s, tl, path); f.backfill > 0 && p != nil {
|
||||
pos.Off = backfillStart(path, p, fi.Size(), f.st.Started[s.ID].Add(-f.backfill), now)
|
||||
pos.Until = fi.Size()
|
||||
}
|
||||
}
|
||||
f.st.Pos[s.ID], f.dirty = pos, true
|
||||
}
|
||||
if !tl.known {
|
||||
tl.readOff, tl.known = pos.Off, true
|
||||
}
|
||||
if pos.Ino != ino {
|
||||
// Rotated (access.log -> access.log.1): finish the old file, then
|
||||
// read the new one from its start.
|
||||
if old, err := os.Stat(path + ".1"); err == nil && inodeOf(old) == pos.Ino {
|
||||
if p := f.parserOf(s, tl, path+".1"); p != nil {
|
||||
for {
|
||||
next, more, err := f.readFrom(s, tl, p, path+".1", tl.readOff, pos.Until, now)
|
||||
if err != nil || !more {
|
||||
break
|
||||
}
|
||||
tl.readOff = next
|
||||
}
|
||||
}
|
||||
}
|
||||
f.flushPending(s.ID, now)
|
||||
pos, tl.readOff = filePos{Ino: ino}, 0
|
||||
f.st.Pos[s.ID], f.dirty = pos, true
|
||||
} else if fi.Size() < tl.readOff {
|
||||
f.flushPending(s.ID, now) // truncated (copytruncate)
|
||||
pos.Until, tl.readOff = 0, 0
|
||||
f.st.Pos[s.ID], f.dirty = pos, true
|
||||
}
|
||||
if fi.Size() > tl.readOff {
|
||||
p := f.parserOf(s, tl, path)
|
||||
if p == nil {
|
||||
return nil // nothing complete to detect the format from yet
|
||||
}
|
||||
for i := 0; i < maxFileReads; i++ {
|
||||
next, more, err := f.readFrom(s, tl, p, path, tl.readOff, pos.Until, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tl.readOff = next
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
tl.behind = max(fi.Size()-tl.readOff, 0)
|
||||
if pe := tl.pending; pe != nil && now.Sub(pe.seen) >= fileContinueDelay {
|
||||
f.flushPending(s.ID, now)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillStart finds where the lines dated notBefore or later start, by
|
||||
// bisection on the dates of the lines (logs are written in order). Undated
|
||||
// lines are left out of the history anyway: then only new lines are read.
|
||||
func backfillStart(path string, p *fileParser, size int64, notBefore, now time.Time) int64 {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return size
|
||||
}
|
||||
defer fh.Close()
|
||||
buf := make([]byte, 64<<10)
|
||||
// lineAt reads the first line starting at or after off (off itself when
|
||||
// it is 0): its start and its date.
|
||||
lineAt := func(off int64) (int64, time.Time, bool) {
|
||||
n, _ := fh.ReadAt(buf, off)
|
||||
b := buf[:n]
|
||||
start := off
|
||||
if off > 0 {
|
||||
nl := bytes.IndexByte(b, '\n')
|
||||
if nl < 0 {
|
||||
return size, time.Time{}, false
|
||||
}
|
||||
b, start = b[nl+1:], off+int64(nl)+1
|
||||
}
|
||||
end := bytes.IndexByte(b, '\n')
|
||||
if end < 0 {
|
||||
return size, time.Time{}, false
|
||||
}
|
||||
r := p.parse(string(b[:end]), now)
|
||||
return start, r.t, r.dated
|
||||
}
|
||||
if _, t, ok := lineAt(0); !ok || !t.Before(notBefore) {
|
||||
if !ok {
|
||||
// No date on the first line: try the last ones.
|
||||
if _, _, ok := lineAt(max(size-int64(len(buf)), 0)); !ok {
|
||||
return size
|
||||
}
|
||||
} else {
|
||||
return 0 // all of it is recent
|
||||
}
|
||||
}
|
||||
lo, hi := int64(0), size // line at lo: before notBefore; at hi: not
|
||||
for hi-lo > int64(len(buf)) {
|
||||
mid := lo + (hi-lo)/2
|
||||
_, t, ok := lineAt(mid)
|
||||
if ok && t.Before(notBefore) {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
start, _, _ := lineAt(lo)
|
||||
return min(start, size)
|
||||
}
|
||||
|
||||
// readFrom parses the complete lines written after off and returns the new
|
||||
// offset; more tells whether the read window was full.
|
||||
func (f *Files) readFrom(s *fileSource, tl *fileTail, p *fileParser, path string, off, until int64, now time.Time) (int64, bool, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return off, false, err
|
||||
}
|
||||
defer fh.Close()
|
||||
buf, err := io.ReadAll(io.NewSectionReader(fh, off, maxFileRead))
|
||||
if err != nil {
|
||||
return off, false, err
|
||||
}
|
||||
full := len(buf) == maxFileRead
|
||||
end := bytes.LastIndexByte(buf, '\n')
|
||||
if end < 0 {
|
||||
if !full {
|
||||
return off, false, nil // partial line: wait for its end
|
||||
}
|
||||
end = len(buf) - 1 // line longer than the read window: cut it
|
||||
}
|
||||
start := off
|
||||
for _, b := range bytes.Split(buf[:end], []byte{'\n'}) {
|
||||
f.line(s, tl, p, string(b), start, start < until, now)
|
||||
start += int64(len(b)) + 1
|
||||
}
|
||||
return off + int64(end) + 1, full, nil
|
||||
}
|
||||
|
||||
// line handles one line: a continuation of the pending entry, or a new one.
|
||||
func (f *Files) line(s *fileSource, tl *fileTail, p *fileParser, line string, off int64, history bool, now time.Time) {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return
|
||||
}
|
||||
if pe := tl.pending; p.multiline() && pe != nil && p.continuation(line) {
|
||||
if pe.lines < maxContinuation && len(pe.rec.msg) < maxFileMsg {
|
||||
pe.rec.msg += "\n" + strings.TrimRight(cleanLine(line), " \t")
|
||||
pe.lines++
|
||||
}
|
||||
pe.seen = now
|
||||
return
|
||||
}
|
||||
f.flushPending(s.ID, now)
|
||||
pe := &pendingEntry{rec: p.parse(line, now), off: off, seen: now, history: history}
|
||||
if p.multiline() {
|
||||
tl.pending = pe
|
||||
return
|
||||
}
|
||||
f.emit(s, pe, now)
|
||||
}
|
||||
|
||||
func (f *Files) flushPending(id string, now time.Time) {
|
||||
tl := f.tails[id]
|
||||
if tl == nil || tl.pending == nil {
|
||||
return
|
||||
}
|
||||
pe := tl.pending
|
||||
tl.pending = nil
|
||||
f.mu.Lock()
|
||||
var src *fileSource
|
||||
for _, s := range f.cfg {
|
||||
if s.ID == id {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if src != nil {
|
||||
f.emit(src, pe, now)
|
||||
}
|
||||
}
|
||||
|
||||
// emit sends an entry; history entries are kept only when dated within
|
||||
// FILES_BACKFILL of when the file was turned on.
|
||||
func (f *Files) emit(s *fileSource, pe *pendingEntry, now time.Time) {
|
||||
r := pe.rec
|
||||
if pe.history {
|
||||
notBefore := f.st.Started[s.ID].Add(-f.backfill)
|
||||
if !r.dated || r.t.Before(notBefore) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(r.msg) > maxFileMsg {
|
||||
r.msg = strings.ToValidUTF8(r.msg[:maxFileMsg], "") + "…"
|
||||
}
|
||||
extra := map[string]string{"log_file": s.Path}
|
||||
for k, v := range r.fields {
|
||||
extra[k] = v
|
||||
}
|
||||
f.sink(&Entry{
|
||||
Time: r.t,
|
||||
Received: now,
|
||||
Host: firstNonEmpty(r.host, s.Host, f.hostName),
|
||||
App: firstNonEmpty(r.app, s.Name),
|
||||
SevNum: r.sev,
|
||||
Severity: severityNames[r.sev],
|
||||
Message: r.msg,
|
||||
Proto: "file",
|
||||
SourceType: "file",
|
||||
Extra: extra,
|
||||
Wait: true, // the file keeps the lines: wait for room in the queue
|
||||
})
|
||||
f.count(s.ID)
|
||||
}
|
||||
|
||||
// checkpointAll saves, for each file, an offset before which every entry is
|
||||
// sent: the start of its pending entry, or the read offset.
|
||||
func (f *Files) checkpointAll() {
|
||||
for id, tl := range f.tails {
|
||||
pos, ok := f.st.Pos[id]
|
||||
if !ok || !tl.known {
|
||||
continue
|
||||
}
|
||||
off := tl.readOff
|
||||
if pe := tl.pending; pe != nil && pe.off < off {
|
||||
off = pe.off
|
||||
}
|
||||
if off != pos.Off {
|
||||
pos.Off, f.dirty = off, true
|
||||
f.st.Pos[id] = pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DetectFile tells, for the settings, whether a file can be read and its
|
||||
// format, with a line as example.
|
||||
func DetectFile(path string) map[string]any {
|
||||
res := map[string]any{"format": "", "sample": "", "code": "", "error": ""}
|
||||
p, err := checkFilePath(path)
|
||||
if err != nil {
|
||||
res["code"], res["error"] = "invalid", err.Error()
|
||||
return res
|
||||
}
|
||||
var lines []string
|
||||
if fi, err2 := os.Stat(p); err2 != nil {
|
||||
err = err2
|
||||
} else if !fi.Mode().IsRegular() {
|
||||
err = fmt.Errorf("%s is not a regular file", p)
|
||||
} else {
|
||||
lines, err = sampleLines(p)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
res["code"], res["error"] = "not_found", err.Error()
|
||||
case err != nil:
|
||||
res["code"], res["error"] = errCode(err), err.Error()
|
||||
case len(lines) == 0:
|
||||
res["code"] = "empty"
|
||||
default:
|
||||
res["format"] = detectFormat(lines)
|
||||
sample := lines[len(lines)-1]
|
||||
if len(sample) > 300 {
|
||||
sample = strings.ToValidUTF8(sample[:300], "") + "…"
|
||||
}
|
||||
res["sample"] = cleanLine(sample)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mustParser(t *testing.T, format, expr string) *fileParser {
|
||||
t.Helper()
|
||||
p, err := newFileParser(format, expr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestParseLogTime(t *testing.T) {
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
utc := func(s string) time.Time { t, _ := time.Parse(time.RFC3339Nano, s); return t }
|
||||
tests := []struct {
|
||||
in string
|
||||
want time.Time
|
||||
}{
|
||||
{"2026-10-05T10:00:00.123Z", utc("2026-10-05T10:00:00.123Z")},
|
||||
{"2026-10-05T10:00:00+02:00", utc("2026-10-05T08:00:00Z")},
|
||||
{"2026-10-05 10:00:00,250 +0200", utc("2026-10-05T08:00:00.25Z")},
|
||||
{"2026/10/05 10:00:00Z", utc("2026-10-05T10:00:00Z")},
|
||||
{"05/Oct/2026:10:00:00 +0200", utc("2026-10-05T08:00:00Z")},
|
||||
{"1759572000", time.Unix(1759572000, 0)},
|
||||
{"1759572000123", time.UnixMilli(1759572000123)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := parseLogTime(tt.in, now)
|
||||
if !ok || !got.Equal(tt.want) {
|
||||
t.Errorf("%q: got %v %v, want %v", tt.in, got, ok, tt.want)
|
||||
}
|
||||
}
|
||||
// Syslog timestamps: no year, the last one before tomorrow.
|
||||
if got, ok := parseLogTime("Dec 31 23:00:00", now); !ok || got.Year() != 2025 {
|
||||
t.Errorf("Dec 31: %v %v", got, ok)
|
||||
}
|
||||
if got, ok := parseLogTime("Oct 5 11:00:00", now); !ok || got.Year() != 2026 || got.Day() != 5 {
|
||||
t.Errorf("Oct 5: %v %v", got, ok)
|
||||
}
|
||||
for _, bad := range []string{"", "hello", "200", "12345"} {
|
||||
if _, ok := parseLogTime(bad, now); ok {
|
||||
t.Errorf("%q parsed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseText(t *testing.T) {
|
||||
now := time.Now()
|
||||
p := mustParser(t, fmtText, "")
|
||||
r := p.parse("2026-10-05 10:00:00.5 ERROR db: connection refused", now)
|
||||
if !r.dated || r.msg != "ERROR db: connection refused" || r.sev != 3 {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
r = p.parse("[2026-10-05T10:00:00Z] - started", now)
|
||||
if !r.dated || r.msg != "started" || r.sev != 6 {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
r = p.parse("\x1b[33mWARN\x1b[0m no timestamp here", now)
|
||||
if r.dated || r.msg != "WARN no timestamp here" || r.sev != 4 || !r.t.Equal(now) {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSON(t *testing.T) {
|
||||
now := time.Now()
|
||||
p := mustParser(t, fmtJSON, "")
|
||||
r := p.parse(`{"time":"2026-10-05T10:00:00Z","level":"warn","msg":"slow query","host":"db.example","req":{"id":7,"tags":["a"]},"ok":true}`, now)
|
||||
if !r.dated || r.sev != 4 || r.msg != "slow query" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
for k, want := range map[string]string{"field_host": "db.example", "req.id": "7", "req.tags": `["a"]`, "ok": "true"} {
|
||||
if r.fields[k] != want {
|
||||
t.Errorf("%s = %q, want %q", k, r.fields[k], want)
|
||||
}
|
||||
}
|
||||
// pino: numeric level, epoch in ms.
|
||||
r = p.parse(`{"level":50,"time":1759572000123,"msg":"boom"}`, now)
|
||||
if r.sev != 3 || !r.t.Equal(time.UnixMilli(1759572000123)) {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
// No message key: the whole line; not JSON: text.
|
||||
line := `{"event":"login","user":"bob"}`
|
||||
if r = p.parse(line, now); r.msg != line || r.fields["user"] != "bob" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
if r = p.parse("plain ERROR line", now); r.msg != "plain ERROR line" || r.sev != 3 {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogfmt(t *testing.T) {
|
||||
p := mustParser(t, fmtLogfmt, "")
|
||||
r := p.parse(`time="2026-10-05T10:00:00Z" level=error msg="Cannot connect: \"docker\" down" providerName=docker flag`, time.Now())
|
||||
if !r.dated || r.sev != 3 || r.msg != `Cannot connect: "docker" down` || r.fields["providerName"] != "docker" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegex(t *testing.T) {
|
||||
if _, err := newFileParser(fmtRegex, "("); err == nil {
|
||||
t.Error("invalid regex accepted")
|
||||
}
|
||||
if _, err := newFileParser(fmtRegex, ""); err == nil {
|
||||
t.Error("empty regex accepted")
|
||||
}
|
||||
p := mustParser(t, fmtRegex, `^(?P<time>\S+ \S+) \[(?P<level>\w+)\] (?P<module>[\w.]+): (?P<msg>.*)$`)
|
||||
r := p.parse("2026-10-05 10:00:00 [warning] app.db: pool exhausted", time.Now())
|
||||
if !r.dated || r.sev != 4 || r.msg != "pool exhausted" || r.fields["module"] != "app.db" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
if !p.continuation("Traceback (most recent call last):") || p.continuation("2026-10-05 10:00:01 [info] a: b") {
|
||||
t.Error("continuation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCLF(t *testing.T) {
|
||||
p := mustParser(t, fmtCLF, "")
|
||||
r := p.parse(`203.0.113.9 - - [05/Oct/2026:10:00:00 +0200] "GET /api/x?y=1 HTTP/1.1" 502 157 "-" "curl/8.0" 4211 "api@docker" "http://172.18.0.5:8080" 31ms`, time.Now())
|
||||
if !r.dated || r.sev != 3 || !strings.HasPrefix(r.msg, "203.0.113.9 - - [") {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
for k, want := range map[string]string{"client_ip": "203.0.113.9", "http_method": "GET", "http_path": "/api/x?y=1", "http_status": "502",
|
||||
"user_agent": "curl/8.0", "router": "api@docker", "backend": "http://172.18.0.5:8080", "duration_ms": "31"} {
|
||||
if r.fields[k] != want {
|
||||
t.Errorf("%s = %q, want %q", k, r.fields[k], want)
|
||||
}
|
||||
}
|
||||
if _, ok := r.fields["http_referer"]; ok {
|
||||
t.Error("empty referer kept")
|
||||
}
|
||||
// nginx combined, 404.
|
||||
r = p.parse(`10.0.0.1 - alice [05/Oct/2026:10:00:00 +0000] "POST /login HTTP/2.0" 404 0 "https://example.com/" "Mozilla/5.0"`, time.Now())
|
||||
if r.sev != 4 || r.fields["http_user"] != "alice" || r.fields["http_referer"] != "https://example.com/" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTraefikJSON(t *testing.T) {
|
||||
p := mustParser(t, fmtTraefik, "")
|
||||
r := p.parse(`{"ClientHost":"203.0.113.9","DownstreamStatus":200,"Duration":12500000,"RequestHost":"app.example.com","RequestMethod":"GET","RequestPath":"/","RequestProtocol":"HTTP/2.0","RouterName":"app@docker","StartUTC":"2026-10-05T10:00:00.1Z","entryPointName":"websecure","level":"info","msg":"","time":"2026-10-05T10:00:00Z","TLSVersion":"1.3"}`, time.Now())
|
||||
if !r.dated || r.sev != 6 || r.msg != "GET app.example.com/ 200 · 12.5 ms · app@docker · 203.0.113.9" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
for k, want := range map[string]string{"entrypoint": "websecure", "http_status": "200", "TLSVersion": "1.3", "duration_ms": "12.5"} {
|
||||
if r.fields[k] != want {
|
||||
t.Errorf("%s = %q, want %q", k, r.fields[k], want)
|
||||
}
|
||||
}
|
||||
// Traefik's own logs in the same file: generic JSON.
|
||||
r = p.parse(`{"level":"error","msg":"Provider error","time":"2026-10-05T10:00:00Z"}`, time.Now())
|
||||
if r.sev != 3 || r.msg != "Provider error" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFormat(t *testing.T) {
|
||||
tests := map[string][]string{
|
||||
fmtJSON: {`{"level":"info","msg":"a"}`, `{"level":"warn","msg":"b"}`},
|
||||
fmtTraefik: {`{"RequestMethod":"GET","DownstreamStatus":200}`, `{"level":"info","msg":"Configuration loaded"}`},
|
||||
fmtCLF: {`10.0.0.1 - - [05/Oct/2026:10:00:00 +0000] "GET / HTTP/1.1" 200 12 "-" "curl"`},
|
||||
fmtLogfmt: {`time="2026-10-05T10:00:00Z" level=info msg="Starting provider" providerName=docker`},
|
||||
fmtText: {`2026-10-05 10:00:00 INFO started`, `user=bob logged in`},
|
||||
}
|
||||
for want, lines := range tests {
|
||||
if got := detectFormat(lines); got != want {
|
||||
t.Errorf("%q: got %s, want %s", lines, got, want)
|
||||
}
|
||||
}
|
||||
// A few lines of another format do not change the answer (80 %).
|
||||
lines := []string{`{"a":1}`, `{"a":2}`, `{"a":3}`, `{"a":4}`, `not json`}
|
||||
if got := detectFormat(lines); got != fmtJSON {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
lines = append(lines, `still not json`)
|
||||
if got := detectFormat(lines); got != fmtText {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "access.log")
|
||||
appendFile(t, path, `10.0.0.1 - - [05/Oct/2026:10:00:00 +0000] "GET / HTTP/1.1" 200 12 "-" "curl"`+"\n"+`partial`)
|
||||
res := DetectFile(path)
|
||||
if res["format"] != fmtCLF || !strings.HasPrefix(res["sample"].(string), "10.0.0.1 - -") || res["code"] != "" {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
for p, code := range map[string]string{
|
||||
filepath.Join(dir, "nope.log"): "not_found", "relative.log": "invalid", filepath.Join(dir, "*.log"): "invalid", dir: "read",
|
||||
} {
|
||||
if res := DetectFile(p); res["code"] != code {
|
||||
t.Errorf("%s: got %+v, want %s", p, res, code)
|
||||
}
|
||||
}
|
||||
empty := filepath.Join(dir, "empty.log")
|
||||
appendFile(t, empty, "")
|
||||
if res := DetectFile(empty); res["code"] != "empty" {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSourceValidation(t *testing.T) {
|
||||
f := NewFiles(t.TempDir(), t.TempDir(), time.Hour, func(*Entry) {})
|
||||
bad := []fileSourceDef{
|
||||
{Name: "", Path: "/logs/a.log"},
|
||||
{Name: "a", Path: "relative.log"},
|
||||
{Name: "a", Path: "/logs/*.log"},
|
||||
{Name: "a", Path: "/logs/a.log", Format: "xml"},
|
||||
{Name: "a", Path: "/logs/a.log", Format: fmtRegex, Regex: "(?P<x"},
|
||||
}
|
||||
for _, d := range bad {
|
||||
if err := f.Configure(filesConfig{Sources: []fileSourceDef{d}}); err == nil {
|
||||
t.Errorf("%+v accepted", d)
|
||||
}
|
||||
}
|
||||
if err := f.Configure(filesConfig{Sources: []fileSourceDef{{Name: " traefik ", Path: "/logs/traefik/access.log"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := f.Status()["sources"].([]map[string]any)
|
||||
if len(src) != 1 || src[0]["name"] != "traefik" || src[0]["id"] == "" || src[0]["format"] != fmtAuto {
|
||||
t.Errorf("got %+v", src)
|
||||
}
|
||||
}
|
||||
|
||||
type fileSink struct {
|
||||
mu sync.Mutex
|
||||
e []*Entry
|
||||
}
|
||||
|
||||
func (s *fileSink) add(e *Entry) { s.mu.Lock(); s.e = append(s.e, e); s.mu.Unlock() }
|
||||
func (s *fileSink) take() []*Entry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e := s.e
|
||||
s.e = nil
|
||||
return e
|
||||
}
|
||||
|
||||
func appendFile(t *testing.T, path, s string) {
|
||||
t.Helper()
|
||||
fh, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fh.WriteString(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fh.Close()
|
||||
}
|
||||
|
||||
func msgs(es []*Entry) []string {
|
||||
var out []string
|
||||
for _, e := range es {
|
||||
out = append(out, e.Message)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFilesFollow(t *testing.T) {
|
||||
dir, data := t.TempDir(), t.TempDir()
|
||||
now := time.Now()
|
||||
old := now.Add(-3 * time.Hour).UTC().Format(time.RFC3339)
|
||||
recent := now.Add(-10 * time.Minute).UTC().Format(time.RFC3339)
|
||||
app := filepath.Join(dir, "app.log")
|
||||
appendFile(t, app, old+" INFO too old\n"+recent+" ERROR recent\n at line 1\nundated history line\n")
|
||||
|
||||
sink := &fileSink{}
|
||||
f := NewFiles(dir, data, time.Hour, sink.add)
|
||||
if err := f.Configure(filesConfig{Sources: []fileSourceDef{{Name: "myapp", Path: app, Host: "web1", Enabled: true}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.poll(now)
|
||||
f.poll(now.Add(3 * time.Second)) // the pending entry waited for continuation lines
|
||||
got := sink.take()
|
||||
if len(got) != 1 || got[0].Message != "ERROR recent\n at line 1" || got[0].SevNum != 3 || got[0].App != "myapp" ||
|
||||
got[0].Host != "web1" || got[0].SourceType != "file" || got[0].Extra["log_file"] != app {
|
||||
t.Fatalf("history: got %q %+v", msgs(got), got)
|
||||
}
|
||||
if st := f.Status()["sources"].([]map[string]any)[0]; st["detected"] != fmtText || st["open"] != true {
|
||||
t.Errorf("status %+v", st)
|
||||
}
|
||||
|
||||
appendFile(t, app, "plain new line\n\tcontinued\n")
|
||||
f.poll(now.Add(4 * time.Second))
|
||||
appendFile(t, app, "after\n")
|
||||
f.poll(now.Add(5 * time.Second))
|
||||
f.poll(now.Add(8 * time.Second))
|
||||
if got = sink.take(); strings.Join(msgs(got), "|") != "plain new line\n\tcontinued|after" {
|
||||
t.Fatalf("new lines: got %q", msgs(got))
|
||||
}
|
||||
|
||||
appendFile(t, app, "before rotation\n")
|
||||
if err := os.Rename(app, app+".1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, app, "after rotation\n")
|
||||
f.poll(now.Add(9 * time.Second))
|
||||
f.poll(now.Add(12 * time.Second))
|
||||
if got = sink.take(); strings.Join(msgs(got), "|") != "before rotation|after rotation" {
|
||||
t.Fatalf("rotation: got %q", msgs(got))
|
||||
}
|
||||
|
||||
// Restart: resumes where it stopped, nothing sent twice.
|
||||
f.checkpointAll()
|
||||
f.dirty = true
|
||||
f.saveState()
|
||||
appendFile(t, app, "while stopped\n")
|
||||
f2 := NewFiles(dir, data, time.Hour, sink.add)
|
||||
f2.poll(now.Add(13 * time.Second))
|
||||
f2.poll(now.Add(16 * time.Second))
|
||||
if got = sink.take(); strings.Join(msgs(got), "|") != "while stopped" {
|
||||
t.Fatalf("restart: got %q", msgs(got))
|
||||
}
|
||||
|
||||
// Truncated in place (copytruncate).
|
||||
os.Truncate(app, 0)
|
||||
appendFile(t, app, "x\n")
|
||||
f2.poll(now.Add(17 * time.Second))
|
||||
f2.poll(now.Add(20 * time.Second))
|
||||
if got = sink.take(); strings.Join(msgs(got), "|") != "x" {
|
||||
t.Fatalf("truncate: got %q", msgs(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesAutoAndErrors(t *testing.T) {
|
||||
dir, data := t.TempDir(), t.TempDir()
|
||||
path := filepath.Join(dir, "access.json")
|
||||
sink := &fileSink{}
|
||||
f := NewFiles(dir, data, 0, sink.add) // FILES_BACKFILL=0: only new lines
|
||||
cfg := filesConfig{Sources: []fileSourceDef{
|
||||
{ID: "a1", Name: "api", Path: path, Enabled: true}, // auto
|
||||
{ID: "b2", Name: "missing", Path: filepath.Join(dir, "nope.log"), Format: fmtText, Enabled: true},
|
||||
}}
|
||||
if err := f.Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
appendFile(t, path, "")
|
||||
f.poll(now) // empty file: format not known yet
|
||||
appendFile(t, path, `{"msg":"one","level":"error"}`+"\n"+`{"msg":"two"}`+"\n"+`{"msg":"partial`)
|
||||
f.poll(now.Add(time.Second))
|
||||
got := sink.take()
|
||||
if strings.Join(msgs(got), "|") != "one|two" || got[0].SevNum != 3 {
|
||||
t.Fatalf("got %q", msgs(got))
|
||||
}
|
||||
src := f.Status()["sources"].([]map[string]any)
|
||||
if src[0]["detected"] != fmtJSON || src[0]["open"] != true || src[1]["code"] != "not_found" {
|
||||
t.Errorf("status %+v", src)
|
||||
}
|
||||
|
||||
// Forced format: the JSON lines are read as text.
|
||||
cfg.Sources[0].Format = fmtText
|
||||
f.Configure(cfg)
|
||||
appendFile(t, path, `"}`+"\n")
|
||||
f.poll(now.Add(2 * time.Second))
|
||||
f.poll(now.Add(5 * time.Second))
|
||||
if got = sink.take(); strings.Join(msgs(got), "|") != `{"msg":"partial"}` {
|
||||
t.Fatalf("forced: got %q", msgs(got))
|
||||
}
|
||||
|
||||
// Turned off then on: starts over (only new lines here).
|
||||
cfg.Sources[0].Enabled = false
|
||||
f.Configure(cfg)
|
||||
f.poll(now.Add(6 * time.Second))
|
||||
appendFile(t, path, "while off\n")
|
||||
cfg.Sources[0].Enabled = true
|
||||
f.Configure(cfg)
|
||||
f.poll(now.Add(7 * time.Second))
|
||||
appendFile(t, path, "three\n")
|
||||
f.poll(now.Add(8 * time.Second))
|
||||
f.poll(now.Add(11 * time.Second))
|
||||
if got = sink.take(); strings.Join(msgs(got), "|") != "three" {
|
||||
t.Fatalf("re-enabled: got %q", msgs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A large access log: the last hour is found by date, not read from the
|
||||
// start, and every entry is counted from the first poll.
|
||||
func TestFilesBackfillLargeFile(t *testing.T) {
|
||||
dir, data := t.TempDir(), t.TempDir()
|
||||
path := filepath.Join(dir, "access.log")
|
||||
now := time.Now()
|
||||
line := func(at time.Time, n int) string {
|
||||
return fmt.Sprintf(`86.246.223.62 - - [%s] "GET /wp-content/x.png HTTP/2.0" 200 640365 "-" "-" %d "vblog-io-router@file" "http://wordpress" 3ms`+"\n",
|
||||
at.UTC().Format("02/Jan/2006:15:04:05 -0700"), n)
|
||||
}
|
||||
var b strings.Builder
|
||||
const n = 200000 // one line every 3 s for a week: ~32 MB
|
||||
for i := 0; i < n; i++ {
|
||||
b.WriteString(line(now.Add(-time.Duration(n-i)*3*time.Second), i))
|
||||
}
|
||||
appendFile(t, path, b.String())
|
||||
sink := &fileSink{}
|
||||
f := NewFiles(dir, data, time.Hour, sink.add)
|
||||
f.Configure(filesConfig{Sources: []fileSourceDef{{ID: "a1", Name: "traefik-log", Path: path, Enabled: true}}})
|
||||
f.poll(now)
|
||||
if pos := f.st.Pos["a1"]; pos.Off < int64(b.Len())*99/100 {
|
||||
t.Errorf("history read from offset %d of %d", pos.Off, b.Len())
|
||||
}
|
||||
appendFile(t, path, line(now, n))
|
||||
f.poll(now.Add(time.Second))
|
||||
got := sink.take()
|
||||
// The last hour (1200 lines, the oldest one on the edge) and the new line.
|
||||
if len(got) < 1200 || len(got) > 1201 || got[0].App != "traefik-log" || got[len(got)-1].Extra["http_status"] != "200" {
|
||||
t.Fatalf("got %d entries", len(got))
|
||||
}
|
||||
if st := f.Status()["sources"].([]map[string]any)[0]; st["read"] != uint64(len(got)) || st["behind"] != int64(0) || st["detected"] != fmtCLF {
|
||||
t.Errorf("status %+v", st)
|
||||
}
|
||||
// Undated plain text: only new lines.
|
||||
txt := filepath.Join(dir, "app.log")
|
||||
appendFile(t, txt, strings.Repeat("no date here\n", 1000))
|
||||
p := mustParser(t, fmtText, "")
|
||||
if off := backfillStart(txt, p, 13000, now.Add(-time.Hour), now); off != 13000 {
|
||||
t.Errorf("undated: %d", off)
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GET /api/v1/logs: incremental reading of the stored logs for automation
|
||||
// tools (n8n, scripts). Each answer gives the rows that follow a cursor,
|
||||
// oldest first, and the cursor to send next time; following the cursors
|
||||
// returns every row once, without gaps or duplicates.
|
||||
//
|
||||
// The cursor is a position on _time, which is the reception time given by
|
||||
// LogStream (see Entry.Record), so new rows always come after it. Rows only
|
||||
// count once they are older than the lag (30 s by default): a message can
|
||||
// wait a little in the queue or for its DNS name before VictoriaLogs makes it
|
||||
// visible. Several rows can share the same _time to the nanosecond; inside
|
||||
// such a group the rows are ordered by a hash of their content, and the
|
||||
// cursor remembers how many of them were already returned.
|
||||
|
||||
const (
|
||||
logsAPIDefaultLimit = 1000
|
||||
logsAPIMaxLimit = 10000
|
||||
logsAPIDefaultLag = 30 * time.Second
|
||||
logsAPIMaxLag = time.Hour
|
||||
logsAPIDefaultSince = time.Hour
|
||||
logsAPIMaxGroup = 100000 // rows with the very same _time
|
||||
)
|
||||
|
||||
// logsCursor: every row with _time < T has been returned, plus the first K
|
||||
// rows (in hash order) of those with _time == T. Q is a hash of the query:
|
||||
// K only means something for the same query.
|
||||
type logsCursor struct {
|
||||
T time.Time `json:"t"`
|
||||
K int `json:"k,omitempty"`
|
||||
Q string `json:"q,omitempty"`
|
||||
}
|
||||
|
||||
func (c logsCursor) encode() string {
|
||||
b, _ := json.Marshal(c)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func decodeCursor(s string) (logsCursor, bool) {
|
||||
var c logsCursor
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil || json.Unmarshal(b, &c) != nil || c.T.IsZero() || c.K < 0 {
|
||||
return logsCursor{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// parseSince reads the since parameter: a cursor from a previous answer, an
|
||||
// RFC 3339 time, or a duration back from now (90s, 15m, 6h, 7d). Empty: the
|
||||
// last hour.
|
||||
func parseSince(s string, now time.Time) (logsCursor, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return logsCursor{T: now.Add(-logsAPIDefaultSince)}, nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return logsCursor{T: t}, nil
|
||||
}
|
||||
if d, ok := parseAge(s); ok {
|
||||
return logsCursor{T: now.Add(-d)}, nil
|
||||
}
|
||||
if c, ok := decodeCursor(s); ok {
|
||||
return c, nil
|
||||
}
|
||||
return logsCursor{}, &codedError{code: "bad_since", msg: "since: expected a next_cursor, an RFC 3339 time or a duration such as 15m, 6h or 7d"}
|
||||
}
|
||||
|
||||
// parseAge reads a Go duration, plus "Nd" for days.
|
||||
func parseAge(s string) (time.Duration, bool) {
|
||||
if n, ok := strings.CutSuffix(s, "d"); ok {
|
||||
days, err := strconv.Atoi(n)
|
||||
if err != nil || days < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return time.Duration(days) * 24 * time.Hour, true
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
return d, err == nil && d >= 0
|
||||
}
|
||||
|
||||
type logsPage struct {
|
||||
Rows []map[string]any `json:"rows"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
HasMore bool `json:"has_more"` // call again right away: more rows are ready
|
||||
Waiting bool `json:"waiting,omitempty"` // the disk buffer is still being sent to VictoriaLogs
|
||||
Until string `json:"until"` // rows are returned up to this time (now - lag)
|
||||
}
|
||||
|
||||
// GET /api/v1/logs?query=<LogsQL filter>&since=<cursor|time|duration>&limit=<n>&lag=<duration>
|
||||
func (a *API) logsV1(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
filter := strings.TrimSpace(q.Get("query"))
|
||||
if f, pipes := splitPipes(filter); strings.TrimSpace(pipes) != "" {
|
||||
writeErr(w, http.StatusBadRequest, &codedError{code: "api_pipes", msg: "query: LogsQL pipes (| ...) are not supported, keep only the filter part"})
|
||||
return
|
||||
} else {
|
||||
filter = strings.TrimSpace(f)
|
||||
}
|
||||
if filter == "" {
|
||||
filter = "*"
|
||||
}
|
||||
limit := logsAPIDefaultLimit
|
||||
if v := q.Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 || n > logsAPIMaxLimit {
|
||||
writeErr(w, http.StatusBadRequest, &codedError{code: "bad_limit", msg: fmt.Sprintf("limit: expected 1 to %d", logsAPIMaxLimit)})
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
lag := logsAPIDefaultLag
|
||||
if v := q.Get("lag"); v != "" {
|
||||
d, ok := parseAge(v)
|
||||
if !ok || d > logsAPIMaxLag {
|
||||
writeErr(w, http.StatusBadRequest, &codedError{code: "bad_lag", msg: "lag: expected a duration from 0s to 1h"})
|
||||
return
|
||||
}
|
||||
lag = d
|
||||
}
|
||||
now := time.Now()
|
||||
cur, err := parseSince(q.Get("since"), now)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
qh := queryHash(filter)
|
||||
if cur.Q != qh {
|
||||
cur.K = 0 // the query changed: the rank inside the group no longer applies
|
||||
}
|
||||
cur.Q = qh
|
||||
|
||||
// Logs waiting in the disk buffer were received earlier: returning newer
|
||||
// rows now would move the cursor past them.
|
||||
if a.store.PendingSpool() > 0 {
|
||||
writeJSON(w, http.StatusOK, logsPage{Rows: []map[string]any{}, NextCursor: cur.encode(), Waiting: true, Until: logsqlTime(cur.T)})
|
||||
return
|
||||
}
|
||||
until := now.Add(-lag)
|
||||
page, err := readLogsPage(r.Context(), a.store.Query, filter, cur, until, limit)
|
||||
if err != nil {
|
||||
writeErr(w, queryStatus(err), err)
|
||||
return
|
||||
}
|
||||
a.annotateHosts(page.Rows)
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
func queryHash(filter string) string {
|
||||
sum := sha256.Sum256([]byte(filter))
|
||||
return hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
type queryFunc func(ctx context.Context, query string) ([]map[string]any, error)
|
||||
|
||||
// readLogsPage returns at most limit rows after cur, up to until included.
|
||||
func readLogsPage(ctx context.Context, run queryFunc, filter string, cur logsCursor, until time.Time, limit int) (logsPage, error) {
|
||||
page := logsPage{Rows: []map[string]any{}, Until: logsqlTime(until)}
|
||||
next := cur
|
||||
start := cur.T
|
||||
if cur.K > 0 {
|
||||
// Rest of the group the previous page stopped in.
|
||||
group, err := readGroup(ctx, run, filter, cur.T)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
rest := group[min(cur.K, len(group)):]
|
||||
if len(rest) >= limit {
|
||||
page.Rows = rest[:limit]
|
||||
next.K = cur.K + limit
|
||||
page.HasMore = true
|
||||
page.NextCursor = next.encode()
|
||||
return page, nil
|
||||
}
|
||||
page.Rows = append(page.Rows, rest...)
|
||||
start = cur.T.Add(time.Nanosecond)
|
||||
}
|
||||
|
||||
left := limit - len(page.Rows)
|
||||
var rows []map[string]any
|
||||
if !start.After(until) {
|
||||
q := fmt.Sprintf("_time:[%s, %s] (%s) | sort by (_time) | limit %d", logsqlTime(start), logsqlTime(until), filter, left+1)
|
||||
var err error
|
||||
if rows, err = run(ctx, q); err != nil {
|
||||
return page, err
|
||||
}
|
||||
}
|
||||
if len(rows) <= left {
|
||||
// Everything up to until has been read.
|
||||
page.Rows = append(page.Rows, rows...)
|
||||
next = logsCursor{T: until.Add(time.Nanosecond), Q: cur.Q}
|
||||
if next.T.Before(start) {
|
||||
next.T = start
|
||||
}
|
||||
page.NextCursor = next.encode()
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// One row too many: the group of the last time may be cut. Whole groups
|
||||
// before it are returned; that last group starts the next page.
|
||||
page.HasMore = true
|
||||
last, err := rowTime(rows[left])
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
n := 0
|
||||
for n < left {
|
||||
t, err := rowTime(rows[n])
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
if !t.Before(last) {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
if n > 0 || len(page.Rows) > 0 {
|
||||
page.Rows = append(page.Rows, rows[:n]...)
|
||||
page.NextCursor = logsCursor{T: last, Q: cur.Q}.encode()
|
||||
return page, nil
|
||||
}
|
||||
// The whole page is a single group: read it all to cut it in a stable order.
|
||||
group, err := readGroup(ctx, run, filter, last)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
page.Rows = group[:min(left, len(group))]
|
||||
page.NextCursor = logsCursor{T: last, K: len(page.Rows), Q: cur.Q}.encode()
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// readGroup returns the rows whose _time is exactly t, in a stable order.
|
||||
func readGroup(ctx context.Context, run queryFunc, filter string, t time.Time) ([]map[string]any, error) {
|
||||
ts := logsqlTime(t)
|
||||
rows, err := run(ctx, fmt.Sprintf("_time:[%s, %s] (%s) | limit %d", ts, ts, filter, logsAPIMaxGroup+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) > logsAPIMaxGroup {
|
||||
return nil, fmt.Errorf("more than %d logs share the time %s", logsAPIMaxGroup, ts)
|
||||
}
|
||||
keys := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
b, _ := json.Marshal(row) // map keys are sorted: same row, same bytes
|
||||
sum := sha256.Sum256(b)
|
||||
keys[i] = string(sum[:])
|
||||
}
|
||||
idx := make([]int, len(rows))
|
||||
for i := range idx {
|
||||
idx[i] = i
|
||||
}
|
||||
sort.SliceStable(idx, func(i, j int) bool { return keys[idx[i]] < keys[idx[j]] })
|
||||
out := make([]map[string]any, len(rows))
|
||||
for i, k := range idx {
|
||||
out[i] = rows[k]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rowTime(row map[string]any) (time.Time, error) {
|
||||
s, _ := row["_time"].(string)
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}, errors.New("VictoriaLogs returned a row without a valid _time")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cursorVL plays VictoriaLogs for the two queries of /api/v1/logs: a time
|
||||
// range sorted by _time, and an exact time. Like VictoriaLogs, it gives no
|
||||
// order among rows of the same _time: they are shuffled on every query.
|
||||
type cursorVL struct {
|
||||
mu sync.Mutex
|
||||
rows []map[string]any
|
||||
rnd *rand.Rand
|
||||
queries []string
|
||||
}
|
||||
|
||||
var cursorQuery = regexp.MustCompile(`^_time:\[([^,]+), ([^\]]+)] \((.*?)\)( \| sort by \(_time\))? \| limit (\d+)$`)
|
||||
|
||||
func (v *cursorVL) add(t time.Time, msg string) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.rows = append(v.rows, map[string]any{"_time": t.UTC().Format(time.RFC3339Nano), "_msg": msg, "host": "h1"})
|
||||
}
|
||||
|
||||
func (v *cursorVL) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
_ = r.ParseForm()
|
||||
q := r.PostForm.Get("query")
|
||||
m := cursorQuery.FindStringSubmatch(q)
|
||||
if m == nil {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader("unexpected query " + q)), Header: http.Header{}}, nil
|
||||
}
|
||||
from, _ := time.Parse(time.RFC3339Nano, m[1])
|
||||
to, _ := time.Parse(time.RFC3339Nano, m[2])
|
||||
limit, _ := strconv.Atoi(m[5])
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.queries = append(v.queries, q)
|
||||
var out []map[string]any
|
||||
for _, row := range v.rows {
|
||||
t, _ := time.Parse(time.RFC3339Nano, row["_time"].(string))
|
||||
if !t.Before(from) && !t.After(to) {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
v.rnd.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
|
||||
if m[4] != "" {
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, _ := time.Parse(time.RFC3339Nano, out[i]["_time"].(string))
|
||||
b, _ := time.Parse(time.RFC3339Nano, out[j]["_time"].(string))
|
||||
return a.Before(b)
|
||||
})
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, row := range out {
|
||||
line, _ := json.Marshal(row)
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(b.String())), Header: http.Header{}}, nil
|
||||
}
|
||||
|
||||
func newCursorVL(seed int64) (*cursorVL, *Store) {
|
||||
vl := &cursorVL{rnd: rand.New(rand.NewSource(seed))}
|
||||
store := NewStore("http://vl", 10, 10, time.Second, nil)
|
||||
store.client.Transport = vl
|
||||
store.streamClient.Transport = vl
|
||||
return vl, store
|
||||
}
|
||||
|
||||
// fill adds rows by groups of the same _time, some groups larger than a
|
||||
// page, some with identical rows, and returns the multiset of the rows.
|
||||
func fill(vl *cursorVL, start time.Time, groups []int, tag string) map[string]int {
|
||||
want := map[string]int{}
|
||||
t := start
|
||||
for g, size := range groups {
|
||||
for i := 0; i < size; i++ {
|
||||
msg := fmt.Sprintf("%s g%d r%d", tag, g, i%4) // r0..r3 repeat: identical rows
|
||||
vl.add(t, msg)
|
||||
want[rowKey(t, msg)]++
|
||||
}
|
||||
t = t.Add(time.Duration(1+g%3) * time.Microsecond)
|
||||
}
|
||||
return want
|
||||
}
|
||||
|
||||
func rowKey(t time.Time, msg string) string { return t.UTC().Format(time.RFC3339Nano) + " " + msg }
|
||||
|
||||
func TestLogsCursorNoLossNoDuplicate(t *testing.T) {
|
||||
base := time.Date(2026, 11, 1, 10, 0, 0, 0, time.UTC)
|
||||
groups := []int{1, 3, 1, 1, 7, 2, 1, 12, 1, 4, 4, 1, 25, 1, 2}
|
||||
for _, limit := range []int{1, 2, 3, 5, 8, 1000} {
|
||||
for seed := int64(1); seed <= 5; seed++ {
|
||||
vl, store := newCursorVL(seed)
|
||||
want := fill(vl, base, groups, "a")
|
||||
got := map[string]int{}
|
||||
cur := logsCursor{T: base.Add(-time.Minute)}
|
||||
until := base.Add(time.Second)
|
||||
var last time.Time
|
||||
for calls := 0; ; calls++ {
|
||||
if calls > 500 {
|
||||
t.Fatalf("limit %d: no end", limit)
|
||||
}
|
||||
page, err := readLogsPage(context.Background(), store.Query, "*", cur, until, limit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Rows) > limit {
|
||||
t.Fatalf("limit %d: page of %d rows", limit, len(page.Rows))
|
||||
}
|
||||
for _, row := range page.Rows {
|
||||
rt, _ := rowTime(row)
|
||||
if rt.Before(last) {
|
||||
t.Fatalf("limit %d: rows out of order (%s after %s)", limit, rt, last)
|
||||
}
|
||||
last = rt
|
||||
got[rowKey(rt, row["_msg"].(string))]++
|
||||
}
|
||||
var ok bool
|
||||
if cur, ok = decodeCursor(page.NextCursor); !ok {
|
||||
t.Fatalf("bad cursor %q", page.NextCursor)
|
||||
}
|
||||
if !page.HasMore {
|
||||
break
|
||||
}
|
||||
if len(page.Rows) == 0 {
|
||||
t.Fatalf("limit %d: has_more with an empty page", limit)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("limit %d seed %d:\n got %v\nwant %v", limit, seed, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New logs arrive between the calls, as with a scheduled n8n workflow.
|
||||
func TestLogsCursorFollowsNewLogs(t *testing.T) {
|
||||
vl, store := newCursorVL(7)
|
||||
base := time.Date(2026, 11, 1, 10, 0, 0, 0, time.UTC)
|
||||
want := map[string]int{}
|
||||
got := map[string]int{}
|
||||
cur := logsCursor{T: base}
|
||||
for round := 0; round < 6; round++ {
|
||||
start := base.Add(time.Duration(round) * time.Second)
|
||||
for k, n := range fill(vl, start, []int{2, 5, 1, 3}, fmt.Sprintf("round%d", round)) {
|
||||
want[k] += n
|
||||
}
|
||||
until := start.Add(500 * time.Millisecond) // after this round's rows
|
||||
for {
|
||||
page, err := readLogsPage(context.Background(), store.Query, "*", cur, until, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range page.Rows {
|
||||
rt, _ := rowTime(row)
|
||||
got[rowKey(rt, row["_msg"].(string))]++
|
||||
}
|
||||
cur, _ = decodeCursor(page.NextCursor)
|
||||
if !page.HasMore {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Polling again with nothing new returns nothing.
|
||||
page, err := readLogsPage(context.Background(), store.Query, "*", cur, until, 4)
|
||||
if err != nil || len(page.Rows) != 0 || page.HasMore {
|
||||
t.Fatalf("round %d: second poll returned %d rows (has_more %v, err %v)", round, len(page.Rows), page.HasMore, err)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSince(t *testing.T) {
|
||||
now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC)
|
||||
c := logsCursor{T: now.Add(-time.Minute), K: 3, Q: "abcd"}
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want logsCursor
|
||||
}{
|
||||
{"", logsCursor{T: now.Add(-time.Hour)}},
|
||||
{"15m", logsCursor{T: now.Add(-15 * time.Minute)}},
|
||||
{"7d", logsCursor{T: now.Add(-7 * 24 * time.Hour)}},
|
||||
{"2026-11-01T08:30:00.5Z", logsCursor{T: time.Date(2026, 11, 1, 8, 30, 0, 5e8, time.UTC)}},
|
||||
{c.encode(), c},
|
||||
} {
|
||||
got, err := parseSince(tc.in, now)
|
||||
if err != nil || !got.T.Equal(tc.want.T) || got.K != tc.want.K || got.Q != tc.want.Q {
|
||||
t.Errorf("parseSince(%q) = %+v, %v; want %+v", tc.in, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"yesterday", "-5m", "e30"} {
|
||||
if _, err := parseSince(bad, now); err == nil {
|
||||
t.Errorf("parseSince(%q): expected an error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsV1Handler(t *testing.T) {
|
||||
vl, store := newCursorVL(3)
|
||||
a := &API{store: store, rdns: NewReverseDNS(false, "")}
|
||||
old := time.Now().Add(-10 * time.Minute)
|
||||
fill(vl, old, []int{2, 1}, "x")
|
||||
vl.add(time.Now().Add(time.Hour), "in the future: after until")
|
||||
|
||||
get := func(url string) (int, logsPage, map[string]string) {
|
||||
rec := httptest.NewRecorder()
|
||||
a.logsV1(rec, httptest.NewRequest("GET", url, nil))
|
||||
var page logsPage
|
||||
var e map[string]string
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &e)
|
||||
return rec.Code, page, e
|
||||
}
|
||||
code, page, _ := get("/api/v1/logs?query=host:h1&since=1h")
|
||||
if code != http.StatusOK || len(page.Rows) != 3 || page.HasMore || page.NextCursor == "" {
|
||||
t.Fatalf("status %d, %d rows, has_more %v", code, len(page.Rows), page.HasMore)
|
||||
}
|
||||
if q := vl.queries[len(vl.queries)-1]; !strings.Contains(q, "(host:h1)") {
|
||||
t.Fatalf("query filter not applied: %s", q)
|
||||
}
|
||||
code, page, _ = get("/api/v1/logs?query=host:h1&since=" + page.NextCursor)
|
||||
if code != http.StatusOK || len(page.Rows) != 0 {
|
||||
t.Fatalf("second call: status %d, %d rows", code, len(page.Rows))
|
||||
}
|
||||
for url, wantCode := range map[string]string{
|
||||
"/api/v1/logs?query=" + "*%20|%20stats%20count()": "api_pipes",
|
||||
"/api/v1/logs?limit=0": "bad_limit",
|
||||
"/api/v1/logs?limit=20000": "bad_limit",
|
||||
"/api/v1/logs?lag=2h": "bad_lag",
|
||||
"/api/v1/logs?since=nope": "bad_since",
|
||||
} {
|
||||
if code, _, e := get(url); code != http.StatusBadRequest || e["code"] != wantCode {
|
||||
t.Errorf("%s: status %d, %v; want 400 %s", url, code, e, wantCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsV1WaitsForSpool(t *testing.T) {
|
||||
spool, err := OpenSpool(t.TempDir(), 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := spool.Write([]byte(`{"_msg":"x"}`+"\n"), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vl, _ := newCursorVL(1)
|
||||
store := NewStore("http://vl", 10, 10, time.Second, spool)
|
||||
store.streamClient.Transport = vl
|
||||
a := &API{store: store}
|
||||
rec := httptest.NewRecorder()
|
||||
a.logsV1(rec, httptest.NewRequest("GET", "/api/v1/logs?since=2026-11-01T10:00:00Z", nil))
|
||||
var page logsPage
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
c, _ := decodeCursor(page.NextCursor)
|
||||
if rec.Code != http.StatusOK || !page.Waiting || len(page.Rows) != 0 || !c.T.Equal(time.Date(2026, 11, 1, 10, 0, 0, 0, time.UTC)) || len(vl.queries) != 0 {
|
||||
t.Fatalf("status %d, waiting %v, %d rows, cursor %v, %d queries", rec.Code, page.Waiting, len(page.Rows), c.T, len(vl.queries))
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type config struct {
|
||||
auditLog string
|
||||
auditHost string
|
||||
auditFill time.Duration
|
||||
filesFill time.Duration
|
||||
batchSize int
|
||||
queueSize int
|
||||
flushEvery time.Duration
|
||||
@@ -125,6 +126,7 @@ func main() {
|
||||
auditLog: getenv("AUDIT_LOG", "/var/log/audit/audit.log"),
|
||||
auditHost: os.Getenv("AUDIT_HOST"),
|
||||
auditFill: getenvDuration("AUDIT_BACKFILL", time.Hour),
|
||||
filesFill: getenvDuration("FILES_BACKFILL", time.Hour),
|
||||
batchSize: getenvInt("BATCH_SIZE", 1000),
|
||||
queueSize: getenvInt("QUEUE_SIZE", 100000),
|
||||
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
|
||||
@@ -140,6 +142,10 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("tags: %v", err)
|
||||
}
|
||||
tokens, err := LoadTokenStore(filepath.Join(cfg.dataDir, "api_tokens.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("api tokens: %v", err)
|
||||
}
|
||||
|
||||
var spool *Spool
|
||||
if cfg.spoolMax > 0 {
|
||||
@@ -198,7 +204,7 @@ func main() {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
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}
|
||||
api := &API{store: store, hub: hub, tags: tags, presets: presets, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv, tokens: tokens}
|
||||
if cfg.dockerLogs {
|
||||
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
|
||||
if err != nil {
|
||||
@@ -218,11 +224,16 @@ func main() {
|
||||
// Linux audit log of the host (auditd), off until enabled in Settings > Sources.
|
||||
api.audit = NewAudit(cfg.auditLog, cfg.hostRoot, cfg.auditHost, cfg.dataDir, cfg.auditFill, sink)
|
||||
go api.audit.Run(ctx)
|
||||
// Log files of other programs (Traefik, nginx…) mounted in the container,
|
||||
// declared in Settings > Sources.
|
||||
api.files = NewFiles(cfg.hostRoot, cfg.dataDir, cfg.filesFill, sink)
|
||||
go api.files.Run(ctx)
|
||||
api.selfLog = selfLog
|
||||
api.Routes(mux)
|
||||
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
||||
mux.Handle("GET /", staticFiles(static))
|
||||
|
||||
handler, err := newAuth(cfg.auth, readOnly(mux))
|
||||
app := readOnly(mux)
|
||||
handler, err := newAuth(cfg.auth, app)
|
||||
if err != nil {
|
||||
log.Fatalf("auth: %v", err)
|
||||
}
|
||||
@@ -235,7 +246,8 @@ func main() {
|
||||
if !authOn {
|
||||
log.Printf("warning: no authentication (AUTH_USER is empty): anyone who can reach %s can read the logs and change the settings", cfg.httpAddr)
|
||||
}
|
||||
handler = secure(handler, contentSecurityPolicy(static), authOn)
|
||||
// API tokens (Settings > API) are checked before the login, in every auth mode.
|
||||
handler = secure(tokenAuth(tokens, handler, app), contentSecurityPolicy(static), authOn)
|
||||
srv := &http.Server{
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: handler,
|
||||
|
||||
@@ -119,10 +119,10 @@ func (f Filter) filterExpr() string {
|
||||
parts = append(parts, "app:="+strconv.Quote(f.App))
|
||||
}
|
||||
switch f.Source {
|
||||
case "docker", "host", "kernel", "audit", "logstream":
|
||||
case "docker", "host", "kernel", "audit", "logstream", "file":
|
||||
parts = append(parts, `source_type:=`+strconv.Quote(f.Source))
|
||||
case "syslog": // also matches logs stored before source_type existed
|
||||
parts = append(parts, `!(source_type:in("docker","host","kernel","audit","logstream"))`)
|
||||
parts = append(parts, `!(source_type:in("docker","host","kernel","audit","logstream","file"))`)
|
||||
}
|
||||
if f.Severity >= 0 && f.Severity < 7 {
|
||||
names := make([]string, 0, 8)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// staticFiles serves the embedded UI with an ETag per file (a hash of its
|
||||
// content, computed at startup). Pages, scripts and styles are revalidated on
|
||||
// every load ("no-cache"): an unchanged file costs a 304 without body, and the
|
||||
// files of a new version are always taken after an update. Fonts are kept for
|
||||
// 30 days.
|
||||
func staticFiles(static fs.FS) http.Handler {
|
||||
etags := map[string]string{}
|
||||
_ = fs.WalkDir(static, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := fs.ReadFile(static, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
etags["/"+path] = `"` + hex.EncodeToString(sum[:8]) + `"`
|
||||
return nil
|
||||
})
|
||||
etags["/"] = etags["/index.html"]
|
||||
files := http.FileServer(http.FS(static))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tag, ok := etags[r.URL.Path]; ok {
|
||||
// http.FileServer answers If-None-Match with 304 from this header.
|
||||
w.Header().Set("ETag", tag)
|
||||
if strings.HasPrefix(r.URL.Path, "/fonts/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestStaticFilesCache(t *testing.T) {
|
||||
h := staticFiles(fstest.MapFS{
|
||||
"index.html": {Data: []byte("<p>v1</p>")},
|
||||
"app.js": {Data: []byte("v1")},
|
||||
"fonts/mono.woff2": {Data: []byte("font")},
|
||||
})
|
||||
get := func(path, etag string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
rec := get("/app.js", "")
|
||||
tag := rec.Header().Get("ETag")
|
||||
if rec.Code != http.StatusOK || tag == "" || rec.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("app.js: %d, ETag %q, Cache-Control %q", rec.Code, tag, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
if rec := get("/app.js", tag); rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
|
||||
t.Fatalf("revalidation: %d with %d bytes, want 304 without body", rec.Code, rec.Body.Len())
|
||||
}
|
||||
if rec := get("/app.js", `"old"`); rec.Code != http.StatusOK || rec.Body.String() != "v1" {
|
||||
t.Fatalf("stale ETag: %d %q", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := get("/", ""); rec.Code != http.StatusOK || rec.Header().Get("ETag") == "" || rec.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("index: %d %v", rec.Code, rec.Header())
|
||||
}
|
||||
if rec := get("/fonts/mono.woff2", ""); rec.Header().Get("Cache-Control") != "public, max-age=2592000" {
|
||||
t.Fatalf("font Cache-Control %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,15 @@ func queryStatus(err error) int {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
// PendingSpool is the number of logs waiting in the disk spool.
|
||||
func (s *Store) PendingSpool() int64 {
|
||||
if s.spool == nil {
|
||||
return 0
|
||||
}
|
||||
lines, _ := s.spool.Pending()
|
||||
return lines
|
||||
}
|
||||
|
||||
func (s *Store) Stats() map[string]any {
|
||||
st := map[string]any{
|
||||
"received": s.received.Load(),
|
||||
|
||||
@@ -36,7 +36,7 @@ type Entry struct {
|
||||
Proto string // udp or tcp
|
||||
HostIP string // original host value when it was an IP resolved through DNS
|
||||
|
||||
SourceType string // "syslog", "docker", "host", "kernel", "audit" or "logstream"
|
||||
SourceType string // "syslog", "docker", "host", "kernel", "audit", "file" or "logstream"
|
||||
Extra map[string]string // additional fields (docker: container, image, …)
|
||||
|
||||
// Not stored. Wait: the producer can be slowed down when the queue is full
|
||||
|
||||
@@ -217,6 +217,51 @@ func (s *TagStore) Delete(id string) error {
|
||||
return errTagNotFound
|
||||
}
|
||||
|
||||
// tagListError tells which tag of a list (PUT /api/tags) is invalid.
|
||||
type tagListError struct {
|
||||
index int
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *tagListError) Error() string { return e.err.Error() }
|
||||
func (e *tagListError) Unwrap() error { return e.err }
|
||||
|
||||
// Replace saves a whole list at once (the Save button of the settings). A tag
|
||||
// whose id is known keeps its code; the others get a new id and a free code.
|
||||
// Nothing is saved when a tag is invalid: the error gives its position.
|
||||
func (s *TagStore) Replace(in []Tag) ([]Tag, error) {
|
||||
if len(in) > 99 {
|
||||
return nil, errTooManyTags
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
codes := map[string]string{}
|
||||
for _, t := range s.tags {
|
||||
codes[t.ID] = t.Code
|
||||
}
|
||||
out := make([]Tag, len(in))
|
||||
seen := map[string]bool{}
|
||||
for i, t := range in {
|
||||
if err := t.validate(); err != nil {
|
||||
return nil, &tagListError{index: i, err: err}
|
||||
}
|
||||
if c, ok := codes[t.ID]; ok && !seen[t.ID] {
|
||||
t.Code = c
|
||||
} else {
|
||||
t.ID, t.Code = newID(), ""
|
||||
}
|
||||
seen[t.ID] = true
|
||||
out[i] = t
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].Code == "" {
|
||||
out[i].Code = freeCode(out)
|
||||
}
|
||||
}
|
||||
s.tags = out
|
||||
return append([]Tag(nil), out...), s.save()
|
||||
}
|
||||
|
||||
func (s *TagStore) Reset() ([]Tag, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -52,3 +53,37 @@ func TestTagCodes(t *testing.T) {
|
||||
t.Fatalf("reloaded code = %q, want 07", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagReplace(t *testing.T) {
|
||||
s, err := LoadTagStore(filepath.Join(t.TempDir(), "tags.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Defaults: warning 01, error 02. Keep error, drop warning, add two tags
|
||||
// (one with an unknown id, one repeating a known id).
|
||||
got, err := s.Replace([]Tag{
|
||||
{ID: "new1", Pattern: "a", Color: "#000000"},
|
||||
{ID: "error", Code: "42", Pattern: "error", Color: "#111111", Enabled: true},
|
||||
{ID: "error", Pattern: "b", Color: "#000000"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes := []string{got[0].Code, got[1].Code, got[2].Code}
|
||||
if want := []string{"01", "02", "03"}; !slices.Equal(codes, want) {
|
||||
t.Fatalf("codes = %v, want %v", codes, want)
|
||||
}
|
||||
if got[1].ID != "error" || got[0].ID == "new1" || got[2].ID == "error" || got[1].Color != "#111111" {
|
||||
t.Fatalf("ids/fields = %+v", got)
|
||||
}
|
||||
|
||||
// An invalid tag saves nothing and gives its position.
|
||||
_, err = s.Replace([]Tag{{Pattern: "ok", Color: "#000000"}, {Pattern: "(", Regex: true, Color: "#000000"}})
|
||||
var le *tagListError
|
||||
if !errors.As(err, &le) || le.index != 1 {
|
||||
t.Fatalf("err = %v, want tagListError at 1", err)
|
||||
}
|
||||
if n := len(s.List()); n != 3 {
|
||||
t.Fatalf("list after a failed Replace has %d tags, want 3", n)
|
||||
}
|
||||
}
|
||||
+676
-118
File diff suppressed because it is too large.
Load diff
+57
-6
@@ -41,6 +41,7 @@
|
||||
<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>
|
||||
<span id="whoami" class="whoami" hidden><span class="who-name"></span><span class="who-badge"></span></span>
|
||||
<a id="logoutBtn" class="icon-btn" href="/auth/logout" hidden data-i18n-title="logout" data-i18n-aria="logout">
|
||||
<!-- Log out icon (Lucide "log-out", ISC license) -->
|
||||
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
||||
@@ -104,6 +105,7 @@
|
||||
<option value="host" data-i18n="srcHost">Host system</option>
|
||||
<option value="kernel" data-i18n="srcKernel">Kernel (dmesg)</option>
|
||||
<option value="audit">Audit</option>
|
||||
<option value="file" data-i18n="srcFile">Files</option>
|
||||
<option value="logstream">LogStream</option>
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
@@ -138,12 +140,13 @@
|
||||
|
||||
<div id="table" class="table" hidden>
|
||||
<div id="listHead" class="list-head" role="presentation">
|
||||
<span data-col="src"><span class="lbl" data-i18n="colSrc">Source</span></span>
|
||||
<span data-col="rcv"><span class="lbl" data-i18n="colRcv">Received</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="mt"><span class="lbl" data-i18n="colMt">Message time</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="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="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Pattern</span></span>
|
||||
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
|
||||
</div>
|
||||
<main id="list" class="list"></main>
|
||||
@@ -172,6 +175,7 @@
|
||||
</header>
|
||||
|
||||
<div class="set-body">
|
||||
<div class="set-side">
|
||||
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
||||
<button type="button" role="tab" data-tab="locale">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
|
||||
@@ -179,21 +183,34 @@
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="filters">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></svg>
|
||||
<span data-i18n="tabFilters">Filters</span>
|
||||
<span data-i18n="tabFilters">Patterns</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="sources">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5M12 13v8"/></svg>
|
||||
<span data-i18n="tabSources">Sources</span>
|
||||
<span data-i18n="tabSources">Log sources</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="interface">
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
|
||||
<span data-i18n="tabInterface">Interface</span>
|
||||
<span data-i18n="tabInterface">Appearance</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="data">
|
||||
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
|
||||
<span data-i18n="tabData">Data</span>
|
||||
<span data-i18n="tabData">Database</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="api">
|
||||
<svg viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 9.3-9.3M16 7l3 3M18 5l2 2"/></svg>
|
||||
<span data-i18n="tabApi">API</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div id="setActions" class="set-actions">
|
||||
<button id="setSave" class="btn save" type="button" disabled>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17l9-10"/></svg>
|
||||
<span data-i18n="saveSettings">Save</span>
|
||||
</button>
|
||||
<button id="setCancel" class="btn ghost" type="button" disabled data-i18n="cancelSettings">Cancel</button>
|
||||
<p id="setState" class="set-state" role="status"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-panels">
|
||||
<!-- Localization -->
|
||||
@@ -218,7 +235,7 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<!-- Pattern (color tags; the "filters" tab id is kept for saved settings) -->
|
||||
<div class="set-panel wide" role="tabpanel" data-panel="filters" hidden>
|
||||
<section class="set-section tags-section">
|
||||
<div class="sec-head">
|
||||
@@ -311,6 +328,15 @@
|
||||
<p class="muted small src-help" data-i18n="selfHelp"></p>
|
||||
</section>
|
||||
|
||||
<section class="set-section span">
|
||||
<h3 data-i18n="filesTitle">Log files</h3>
|
||||
<div id="fileList" class="file-list"></div>
|
||||
<div class="file-toolbar">
|
||||
<button id="addFile" class="btn primary" type="button" data-i18n="addFile">+ Add a file</button>
|
||||
</div>
|
||||
<p class="muted small src-help" data-i18n="filesHelp"></p>
|
||||
</section>
|
||||
|
||||
<section class="set-section span">
|
||||
<h3 data-i18n="dockerTitle">Docker containers</h3>
|
||||
<p id="dockerStatus" class="docker-status"></p>
|
||||
@@ -424,6 +450,31 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- API tokens -->
|
||||
<div class="set-panel wide" role="tabpanel" data-panel="api" hidden>
|
||||
<section class="set-section">
|
||||
<div class="sec-head">
|
||||
<h3 data-i18n="tokensTitle">API tokens</h3>
|
||||
<span id="tokenCount" class="muted small"></span>
|
||||
</div>
|
||||
<p class="muted small" data-i18n="tokensHelp"></p>
|
||||
<div id="tokenList" class="token-list"></div>
|
||||
<form id="tokenForm" class="token-form">
|
||||
<input id="tokenName" type="text" class="dk-filter" maxlength="80" autocomplete="off" spellcheck="false" data-i18n-ph="tokenNamePh" data-i18n-aria="tokenNamePh">
|
||||
<button id="tokenCreate" class="btn" type="submit" data-i18n="tokenCreate">Add a token</button>
|
||||
</form>
|
||||
<div id="tokenNew" class="token-new" hidden></div>
|
||||
</section>
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="tokensUse">Usage</h3>
|
||||
<p class="muted small" data-i18n="tokensUseHelp"></p>
|
||||
<div class="token-example-box">
|
||||
<pre id="tokenExample" class="token-example"></pre>
|
||||
<button id="tokenExampleCopy" class="btn" type="button" data-i18n="tokenExampleCopy">Copy the command</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+136
-31
@@ -22,8 +22,13 @@
|
||||
--hit: rgba(99, 102, 241, .18);
|
||||
--shadow: 0 10px 30px rgba(15, 23, 42, .12);
|
||||
--chip-l: 38%; /* lightness of project-colored text (labels, Docker apps) */
|
||||
--src-syslog: #64748b; /* source badges: syslog received over the network */
|
||||
--src-docker: #1d6fd6; /* Docker containers */
|
||||
--src-kernel: #b4540a; /* kernel ring buffer (dmesg) */
|
||||
--src-host: #b0407a; /* /var/log files of the host system */
|
||||
--src-audit: #7c4dbd; /* audit events (auditd) */
|
||||
--src-ls: #1f9aa3; /* LogStream internal logs */
|
||||
--src-file: #5f8a1f; /* log files (Settings > Sources > Log files) */
|
||||
--src-tint: 6%; /* background tint of their rows */
|
||||
|
||||
--sev-crit: #be123c;
|
||||
@@ -73,8 +78,13 @@
|
||||
--hit: rgba(129, 140, 248, .28);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
||||
--chip-l: 70%;
|
||||
--src-syslog: #94a3b8;
|
||||
--src-docker: #5aa2f5;
|
||||
--src-kernel: #e7a25a;
|
||||
--src-host: #e58bbb;
|
||||
--src-audit: #a983e6;
|
||||
--src-ls: #3cc3cc;
|
||||
--src-file: #9ccc54;
|
||||
--src-tint: 9%;
|
||||
--sev-crit: #fb7185;
|
||||
--sev-err: #f87171;
|
||||
@@ -108,8 +118,13 @@
|
||||
--hit: rgba(129, 140, 248, .28);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
||||
--chip-l: 70%;
|
||||
--src-syslog: #94a3b8;
|
||||
--src-docker: #5aa2f5;
|
||||
--src-kernel: #e7a25a;
|
||||
--src-host: #e58bbb;
|
||||
--src-audit: #a983e6;
|
||||
--src-ls: #3cc3cc;
|
||||
--src-file: #9ccc54;
|
||||
--src-tint: 9%;
|
||||
--sev-crit: #fb7185;
|
||||
--sev-err: #f87171;
|
||||
@@ -189,6 +204,16 @@ svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;
|
||||
.chip.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
/* Logged-in user, with how they logged in (oidc / local) */
|
||||
.whoami { display: flex; align-items: center; gap: 6px; min-width: 0; margin-left: 6px; font-size: 13px; color: var(--muted); }
|
||||
.whoami .who-name { font-weight: 600; color: var(--text); max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.whoami .who-badge {
|
||||
flex: none; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 1px 6px; font-size: 10.5px; font-weight: 650; letter-spacing: .02em;
|
||||
}
|
||||
.whoami .who-badge.oidc { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.whoami .who-badge:empty { display: none; }
|
||||
.whoami.none .who-name { font-weight: 400; color: var(--muted); }
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px; display: grid; place-items: center;
|
||||
border: 1px solid transparent; background: transparent; border-radius: 9px; color: var(--muted);
|
||||
@@ -330,12 +355,12 @@ 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, tag codes, message. Widths come from
|
||||
/* Columns: source, 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)
|
||||
grid-template-columns: minmax(3.9rem, max-content) 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)) 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);
|
||||
@@ -365,20 +390,19 @@ 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) var(--codes-w) 1fr;
|
||||
grid-template-columns: max-content 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;
|
||||
padding: 3px 14px 3px 11px;
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-family: var(--log-font, var(--mono)); font-size: var(--log-size);
|
||||
font-family: var(--log-font, var(--mono)); font-size: var(--log-size); line-height: 1.35;
|
||||
font-variant-ligatures: none; /* keep -> != etc. as typed */
|
||||
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; }
|
||||
:root[data-density="compact"] .row { padding-top: 1px; padding-bottom: 1px; line-height: 1.2; }
|
||||
:root[data-density="compact"] .row :is(.src-badge, .sev, .codes b) { line-height: 1.15; }
|
||||
.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; }
|
||||
@@ -396,7 +420,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
.sev {
|
||||
justify-self: start; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
|
||||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
||||
padding: 1px 6px; border-radius: 5px;
|
||||
padding: 0 6px; border-radius: 5px; line-height: 1.3;
|
||||
color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent);
|
||||
}
|
||||
.sev-crit { --sev: var(--tag-error); --sev-text: var(--tag-error-text); }
|
||||
@@ -410,15 +434,21 @@ 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; }
|
||||
/* Audit events and LogStream internal logs: colored edge (over the severity
|
||||
one), light tint, badge before the app name. */
|
||||
/* Source badge at the start of every row, fixed width, one color per source.
|
||||
Audit events, log files and LogStream internal logs also get a colored edge
|
||||
(over the severity one) and a light tint. */
|
||||
.row.src-syslog { --src: var(--src-syslog); }
|
||||
.row.src-docker { --src: var(--src-docker); }
|
||||
.row.src-kernel { --src: var(--src-kernel); }
|
||||
.row.src-host { --src: var(--src-host); }
|
||||
.row.src-audit { --src: var(--src-audit); }
|
||||
.row.src-logstream { --src: var(--src-ls); }
|
||||
.row.src-audit, .row.src-logstream { border-left-color: var(--src); background: color-mix(in srgb, var(--src) var(--src-tint), transparent); }
|
||||
.row.src-audit:hover, .row.src-logstream:hover { background: color-mix(in srgb, var(--src) 13%, var(--row-hover)); }
|
||||
.row.src-file { --src: var(--src-file); }
|
||||
.row.src-audit, .row.src-logstream, .row.src-file { border-left-color: var(--src); background: color-mix(in srgb, var(--src) var(--src-tint), transparent); }
|
||||
.row.src-audit:hover, .row.src-logstream:hover, .row.src-file:hover { background: color-mix(in srgb, var(--src) 13%, var(--row-hover)); }
|
||||
.src-badge {
|
||||
display: inline-block; margin-right: 5px; padding: 0 4px; border-radius: 4px; vertical-align: 1px;
|
||||
font: 700 9.5px/1.45 var(--sans); letter-spacing: .04em;
|
||||
justify-self: start; box-sizing: content-box; width: 6ch; padding: 0 4px; border-radius: 4px; text-align: center;
|
||||
font-family: var(--log-font, var(--mono)); font-size: 10px; font-weight: 700; letter-spacing: .02em; line-height: 1.3;
|
||||
color: var(--src); background: color-mix(in srgb, var(--src) 16%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--src) 40%, transparent);
|
||||
}
|
||||
@@ -433,10 +463,10 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
.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;
|
||||
min-width: 2.2ch; padding: 0 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;
|
||||
line-height: 1.3; 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); }
|
||||
@@ -528,12 +558,25 @@ dialog.settings {
|
||||
}
|
||||
dialog.settings .dlg { display: flex; flex-direction: column; height: 100%; padding: 0; }
|
||||
dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px solid var(--border); }
|
||||
.set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 176px minmax(0, 1fr); }
|
||||
.set-nav {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 12px 10px; border-right: 1px solid var(--border);
|
||||
.set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(176px, max-content) minmax(0, 1fr); }
|
||||
.set-side {
|
||||
display: flex; flex-direction: column; min-height: 0; overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel-2) 50%, transparent);
|
||||
}
|
||||
.set-nav { display: flex; flex-direction: column; gap: 2px; padding: 12px 10px; }
|
||||
/* Save / Cancel, under the section labels: Save takes the accent color while changes wait. */
|
||||
.set-actions {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 10px 12px; padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.set-actions .btn { flex: 1; justify-content: center; }
|
||||
.set-actions .btn.save { color: var(--muted); }
|
||||
.set-actions .btn svg { width: 16px; height: 16px; }
|
||||
.set-actions.dirty .btn.save { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.set-actions.dirty .btn.save:hover { filter: brightness(1.08); }
|
||||
.set-state { flex-basis: 100%; margin: 2px 2px 0; font-size: 12px; color: var(--muted); }
|
||||
.set-actions.dirty .set-state { color: var(--accent); font-weight: 550; }
|
||||
.set-nav button {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
height: 38px; padding: 0 12px; border: 0; border-radius: 9px;
|
||||
@@ -561,10 +604,11 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
|
||||
.set-panel .preview-list { margin: 14px 0 10px; pointer-events: none; }
|
||||
.preview-list .row {
|
||||
cursor: default;
|
||||
grid-template-columns: max-content max-content minmax(0, max-content) minmax(0, 1fr);
|
||||
grid-template-areas: "rcv sev host app" "msg msg msg msg";
|
||||
grid-template-columns: max-content max-content max-content minmax(0, max-content) minmax(0, 1fr);
|
||||
grid-template-areas: "src rcv sev host app" "msg msg msg msg msg";
|
||||
gap: 3px 10px;
|
||||
}
|
||||
.preview-list .row .src-badge { grid-area: src; }
|
||||
.preview-list .row time.rcv { grid-area: rcv; }
|
||||
.preview-list .row time.mt { display: none; }
|
||||
.preview-list .row .sev { grid-area: sev; }
|
||||
@@ -685,7 +729,7 @@ input.switch:checked::after { transform: translateX(14px); }
|
||||
input.switch:disabled { cursor: not-allowed; }
|
||||
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* Settings > Filters: toolbar kept in view while the list scrolls, dense rows. */
|
||||
/* Settings > Pattern: toolbar kept in view while the list scrolls, dense rows. */
|
||||
.tags-section { padding-top: 12px !important; }
|
||||
.tag-toolbar {
|
||||
position: sticky; top: -16px; z-index: 2;
|
||||
@@ -741,15 +785,45 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
.tag-row .icon-btn svg { width: 15px; height: 15px; }
|
||||
.opt input { display: none; }
|
||||
.opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
/* Log files (Settings > Sources) */
|
||||
.file-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.file-row {
|
||||
display: grid; align-items: center; gap: 6px;
|
||||
grid-template-columns: auto minmax(7rem, 1fr) minmax(10rem, 2fr) minmax(9rem, 1.4fr) minmax(6rem, .8fr) 26px;
|
||||
padding: 6px 6px 6px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
|
||||
}
|
||||
.file-row.off > input[type="text"], .file-row.off > select { opacity: .6; }
|
||||
.file-row input[type="text"] {
|
||||
height: 28px; padding: 0 8px; min-width: 0;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); font-size: 12.5px; outline: 0;
|
||||
}
|
||||
.file-row input.mono { font-family: var(--mono); }
|
||||
.file-row input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.file-row select.field { height: 28px; }
|
||||
.file-row .icon-btn { width: 26px; height: 26px; border-radius: 6px; }
|
||||
.file-row .icon-btn svg { width: 15px; height: 15px; }
|
||||
.file-row .fr-regex, .file-row .fr-status, .file-row .fr-sample { grid-column: 2 / -1; }
|
||||
.file-row .fr-sample {
|
||||
margin: 0; font: 11.5px var(--mono); color: var(--muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.file-row .fr-sample[hidden] { display: none; }
|
||||
.file-row .fr-regex[hidden] { display: none; }
|
||||
.file-row .fr-status { margin: 0 !important; font-size: 12px; }
|
||||
.file-toolbar { margin-top: 10px; }
|
||||
.tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; }
|
||||
.tag-row .err:empty { display: none; }
|
||||
|
||||
/* ---------- Mobile ---------- */
|
||||
@media (max-width: 760px) {
|
||||
.file-row { grid-template-columns: auto minmax(0, 1fr) 26px; }
|
||||
.file-row [data-del] { grid-column: 3; grid-row: 1; }
|
||||
.file-row > [data-f="path"], .file-row > [data-f="format"], .file-row > [data-f="host"] { grid-column: 2 / -1; }
|
||||
body { font-size: 13.5px; }
|
||||
.topbar { flex-wrap: wrap; padding: 8px 16px; gap: 8px 10px; }
|
||||
.search { order: 3; flex-basis: 100%; max-width: none; }
|
||||
.live .lbl { display: none; }
|
||||
.whoami .who-name { max-width: 12ch; }
|
||||
.filters { padding: 10px 16px 6px; }
|
||||
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; }
|
||||
.view-mode { flex: 1 1 100%; }
|
||||
@@ -769,10 +843,11 @@ 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 auto;
|
||||
grid-template-areas: "rcv sev host codes" "msg msg msg msg";
|
||||
grid-template-columns: auto auto auto 1fr auto;
|
||||
grid-template-areas: "src rcv sev host codes" "msg msg msg msg msg";
|
||||
gap: 3px 8px; padding: 8px 12px 8px 10px;
|
||||
}
|
||||
.row .src-badge { grid-area: src; }
|
||||
.row time.rcv { grid-area: rcv; }
|
||||
.row time.mt { display: none; } /* still shown in the details panel */
|
||||
.row .sev { grid-area: sev; }
|
||||
@@ -786,15 +861,16 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
.field-grid { grid-template-columns: 1fr; gap: 4px; }
|
||||
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
|
||||
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.set-nav {
|
||||
display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px;
|
||||
border-right: 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.set-side { border-right: 0; border-bottom: 1px solid var(--border); overflow: visible; }
|
||||
.set-nav { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; }
|
||||
.set-actions { margin: 0 6px 6px; padding-top: 6px; align-items: center; }
|
||||
.set-actions .btn { flex: 0 0 auto; height: 30px; }
|
||||
.set-state { flex: 1; flex-basis: auto; margin: 0; text-align: right; }
|
||||
.set-nav button {
|
||||
flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px;
|
||||
font-size: 11.5px; text-align: center;
|
||||
}
|
||||
.set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.set-nav button span { max-width: 100%; white-space: normal; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; }
|
||||
.set-panels { padding: 12px 12px 18px; }
|
||||
.set-panel { grid-template-columns: minmax(0, 1fr); gap: 10px; }
|
||||
.set-panel > .set-section, .set-panel > .set-section + .set-section { padding: 12px; }
|
||||
@@ -836,3 +912,32 @@ body.login-page {
|
||||
.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; }
|
||||
|
||||
/* API tokens (Settings > API) */
|
||||
.token-list { display: flex; flex-direction: column; gap: 4px; margin: 12px 0 10px; }
|
||||
.token-row {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; align-items: center;
|
||||
padding: 8px 10px; border-radius: 8px; background: var(--panel-2); font-size: 12.5px;
|
||||
}
|
||||
.token-row b { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.token-row code { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
|
||||
.token-row .muted { grid-column: 1; font-size: 11.5px; }
|
||||
.token-row .btn { grid-row: 1 / span 2; grid-column: 2; height: 28px; font-size: 12px; }
|
||||
.token-empty { color: var(--muted); font-size: 12.5px; }
|
||||
.token-form { display: flex; gap: 10px; align-items: center; }
|
||||
.token-form .btn { height: 28px; font-size: 12px; }
|
||||
.token-new { margin-top: 12px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent-soft); }
|
||||
.token-secret { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
|
||||
.token-label { font-size: 12.5px; font-weight: 600; white-space: nowrap; }
|
||||
.token-row.revoked b, .token-row.revoked code { text-decoration: line-through; opacity: .6; }
|
||||
.token-row.pending { border: 1px dashed var(--accent); }
|
||||
.token-example-box { display: flex; gap: 10px; align-items: flex-start; margin-top: 8px; }
|
||||
.token-example-box .token-example { flex: 1; min-width: 0; margin: 0; }
|
||||
.token-example-box .btn { height: 28px; font-size: 12px; }
|
||||
.token-example mark { background: var(--accent-soft); color: var(--accent); border-radius: 3px; padding: 0 2px; font-weight: 600; }
|
||||
.token-secret code { flex: 1; min-width: 0; font: 12px var(--mono); overflow-wrap: anywhere; user-select: all; }
|
||||
.token-secret .btn { height: 28px; font-size: 12px; }
|
||||
.token-example {
|
||||
margin: 8px 0 0; padding: 8px 12px; border-radius: 8px; background: var(--panel-2);
|
||||
font: 12px var(--mono); white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
Reference in new issue
Block a user