Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c664f1eaaf | ||
|
|
1a21656546 | ||
|
|
be58284916 | ||
|
|
7b139e8931 | ||
|
|
19ed16ac12 |
No files matched your search
+14
-2
@@ -78,8 +78,8 @@ par exemple `error AND host:web-01`, `app:~"ssh|nginx"` ou `* | stats by (host)
|
||||
Le direct est désactivé dans ce mode.
|
||||
|
||||
Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur),
|
||||
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application
|
||||
et le message. Un clic sur un hôte ou une application filtre dessus.
|
||||
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application,
|
||||
les codes des tags trouvés et le message. Un clic sur un hôte ou une application filtre dessus.
|
||||
|
||||
Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont
|
||||
l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même
|
||||
@@ -228,6 +228,18 @@ couleur, est mémorisé par navigateur.
|
||||
Tags par défaut (pastel) : `warning` (orange), `error` (rouge), `ok` (vert). Les tags par
|
||||
défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux
|
||||
couleurs pastel.
|
||||
Le menu *+ Préréglage…* ajoute des tags tout faits pour les logs d'accès HTTP/HTTPS (nginx et
|
||||
Apache common/combined, Traefik CLF et JSON, Caddy JSON, HAProxy httplog) : codes de statut
|
||||
(2xx vert, 3xx bleu, 4xx orange, 5xx rouge), méthodes, sondes et attaques (`wp-login.php`,
|
||||
`/.env`, `../`…), robots et scripts, erreurs TLS et proxy. Les tags déjà présents ne sont pas
|
||||
ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression
|
||||
régulière, un groupe nommé `hl` (`(?<hl>…)`) ne colore que cette partie de la correspondance :
|
||||
les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour.
|
||||
Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste
|
||||
attaché au tag jusqu'à sa suppression (les tags créés par les versions précédentes en reçoivent
|
||||
un aussi). La colonne *Filtres* de la liste affiche, en badges gris, les codes des tags actifs
|
||||
trouvés dans chaque message ; elle a la place pour 3, au-delà elle en affiche 2 et `+N`, et
|
||||
l'infobulle les liste tous.
|
||||
- **Interface**
|
||||
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
|
||||
bouton soleil/lune de l'en-tête bascule entre clair et sombre.
|
||||
|
||||
@@ -72,7 +72,7 @@ message, host and app. Words are combined with AND.
|
||||
The live view is disabled in this mode.
|
||||
|
||||
Each row shows, from left to right: the **reception time** (server clock), the timestamp
|
||||
found in the message itself (`msg_time`), severity, host, app and message. Click a host or an
|
||||
found in the message itself (`msg_time`), severity, host, app, codes of the tags found and message. Click a host or an
|
||||
app to filter on it.
|
||||
|
||||
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
|
||||
@@ -207,6 +207,18 @@ remembered per browser.
|
||||
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
|
||||
`warning` (orange), `error` (red), `ok` (green). Default tags still using the colors of
|
||||
earlier versions are switched to the pastel ones automatically.
|
||||
The *+ Preset…* menu adds ready-made tags for HTTP/HTTPS access logs (nginx and Apache
|
||||
common/combined, Traefik CLF and JSON, Caddy JSON, HAProxy httplog): status codes (2xx green,
|
||||
3xx blue, 4xx orange, 5xx red), methods, probes and attacks (`wp-login.php`, `/.env`,
|
||||
`../`…), bots and scripts, TLS and proxy errors. Tags already in the list are skipped, and the
|
||||
added tags can be edited like any other. In a regular expression, a group named `hl`
|
||||
(`(?<hl>…)`) colors only that part of the match: the presets use it to color the status code
|
||||
or the method, not the text around it.
|
||||
Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag
|
||||
until the tag is deleted (codes are also given to tags created by earlier versions). The
|
||||
*Filters* column of the log list shows, as grey badges, the codes of the active tags found in
|
||||
each message; it has room for 3, beyond that it shows 2 and `+N`, and the tooltip lists them
|
||||
all.
|
||||
- **Interface**
|
||||
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
|
||||
button in the header switches between light and dark.
|
||||
|
||||
@@ -252,7 +252,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
var ls loginState
|
||||
c, err := r.Cookie(loginCookie + state)
|
||||
if state == "" || err != nil || !verifyCookie(o.key, c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
||||
http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest)
|
||||
http.Error(w, "login expired or started in another browser: open LogStream again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
|
||||
@@ -260,7 +260,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := o.exchange(r, q.Get("code"), ls)
|
||||
if err != nil {
|
||||
log.Printf("oidc: login failed: %v", err)
|
||||
http.Error(w, "login failed, see the logstream logs", http.StatusForbidden)
|
||||
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Printf("oidc: %s logged in", user)
|
||||
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
// Tag highlights a keyword in displayed messages.
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"` // two digits, shown on matching log lines
|
||||
Pattern string `json:"pattern"`
|
||||
Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
|
||||
Color string `json:"color"`
|
||||
WholeWord bool `json:"wholeWord"`
|
||||
CaseSensitive bool `json:"caseSensitive"`
|
||||
@@ -26,12 +28,47 @@ type Tag struct {
|
||||
|
||||
func defaultTags() []Tag {
|
||||
return []Tag{
|
||||
{ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
|
||||
{ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
|
||||
{ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
|
||||
{ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
|
||||
{ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
|
||||
{ID: "ok", Code: "03", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
|
||||
}
|
||||
}
|
||||
|
||||
var codeRe = regexp.MustCompile(`^[0-9]{2}$`)
|
||||
|
||||
// freeCode returns the lowest code from 01 to 99 not used by tags, or "" when
|
||||
// all are taken. A code stays with its tag until the tag is deleted.
|
||||
func freeCode(tags []Tag) string {
|
||||
used := map[string]bool{}
|
||||
for _, t := range tags {
|
||||
used[t.Code] = true
|
||||
}
|
||||
for n := 1; n <= 99; n++ {
|
||||
if c := fmt.Sprintf("%02d", n); !used[c] {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// assignCodes gives a code to the tags that have none (files written before
|
||||
// codes existed) or share one with an earlier tag.
|
||||
func assignCodes(tags []Tag) bool {
|
||||
changed := false
|
||||
seen := map[string]bool{}
|
||||
for i := range tags {
|
||||
if codeRe.MatchString(tags[i].Code) && !seen[tags[i].Code] {
|
||||
seen[tags[i].Code] = true
|
||||
continue
|
||||
}
|
||||
tags[i].Code = ""
|
||||
tags[i].Code = freeCode(tags)
|
||||
seen[tags[i].Code] = true
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Colors of the default tags in earlier versions: still unchanged, they are
|
||||
// switched to the new pastel defaults when the file is loaded.
|
||||
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
|
||||
@@ -67,11 +104,13 @@ func (e *codedError) Error() string {
|
||||
|
||||
var (
|
||||
errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"}
|
||||
errTooManyTags = &codedError{code: "too_many_tags", msg: "too many tags (99 at most)"}
|
||||
colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
)
|
||||
|
||||
func (t *Tag) validate() error {
|
||||
t.Pattern = strings.TrimSpace(t.Pattern)
|
||||
t.Label = strings.TrimSpace(t.Label)
|
||||
if t.Pattern == "" {
|
||||
return &codedError{code: "pattern_required", msg: "the keyword is required"}
|
||||
}
|
||||
@@ -106,7 +145,7 @@ func LoadTagStore(path string) (*TagStore, error) {
|
||||
if err := json.Unmarshal(b, &s.tags); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if migrateDefaultColors(s.tags) {
|
||||
if c1, c2 := migrateDefaultColors(s.tags), assignCodes(s.tags); c1 || c2 {
|
||||
if err := s.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,6 +182,9 @@ func (s *TagStore) Create(t Tag) (Tag, error) {
|
||||
t.ID = newID()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if t.Code = freeCode(s.tags); t.Code == "" {
|
||||
return t, errTooManyTags
|
||||
}
|
||||
s.tags = append(s.tags, t)
|
||||
return t, s.save()
|
||||
}
|
||||
@@ -156,6 +198,7 @@ func (s *TagStore) Update(id string, t Tag) (Tag, error) {
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.tags {
|
||||
if s.tags[i].ID == id {
|
||||
t.Code = s.tags[i].Code // assigned by the server, never changed
|
||||
s.tags[i] = t
|
||||
return t, s.save()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTagCodes(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tags.json")
|
||||
// File written before codes existed, with a duplicate code.
|
||||
old := `[{"id":"a","pattern":"x","color":"#000000"},{"id":"b","code":"07","pattern":"y","color":"#000000"},{"id":"c","code":"07","pattern":"z","color":"#000000"}]`
|
||||
if err := os.WriteFile(path, []byte(old), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := LoadTagStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := []string{}
|
||||
for _, tg := range s.List() {
|
||||
got = append(got, tg.Code)
|
||||
}
|
||||
if want := []string{"01", "07", "02"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("migrated codes = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
n, err := s.Create(Tag{Pattern: "w", Color: "#000000"})
|
||||
if err != nil || n.Code != "03" {
|
||||
t.Fatalf("Create code = %q, %v; want 03", n.Code, err)
|
||||
}
|
||||
// The client cannot change a code.
|
||||
u, err := s.Update("b", Tag{Code: "42", Pattern: "y2", Color: "#000000"})
|
||||
if err != nil || u.Code != "07" {
|
||||
t.Fatalf("Update code = %q, %v; want 07", u.Code, err)
|
||||
}
|
||||
// A deleted tag frees its code; the others keep theirs.
|
||||
if err := s.Delete("a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.Create(Tag{Pattern: "v", Color: "#000000"}); n.Code != "01" {
|
||||
t.Fatalf("code after delete = %q, want 01", n.Code)
|
||||
}
|
||||
|
||||
// Codes are saved in the file.
|
||||
s2, err := LoadTagStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s2.List()[0].Code; got != "07" {
|
||||
t.Fatalf("reloaded code = %q, want 07", got)
|
||||
}
|
||||
}
|
||||
+104
-12
@@ -48,11 +48,18 @@ const I18N = {
|
||||
newTag: 'new',
|
||||
confirmDelete: (p) => `Delete tag "${p}"?`,
|
||||
confirmReset: 'Replace all tags with the defaults (warning, error, ok)?',
|
||||
presetAria: 'Add a preset', presetPick: '+ Preset…',
|
||||
preset_http_status: 'HTTP status codes', preset_http_methods: 'HTTP methods',
|
||||
preset_http_probes: 'Probes and attacks', preset_http_bots: 'Bots and scripts',
|
||||
preset_http_errors: 'TLS/HTTPS and proxy errors',
|
||||
pl_probes: 'probes / attacks', pl_bots: 'bots / scripts', pl_tls: 'TLS errors', pl_proxy: 'proxy errors',
|
||||
presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'),
|
||||
tagsLoadErr: 'Tags: ',
|
||||
err_pattern_required: 'The keyword is required',
|
||||
err_invalid_color: 'Invalid color (expected #rrggbb)',
|
||||
err_invalid_regex: 'Invalid regular expression',
|
||||
err_tag_not_found: 'Tag not found',
|
||||
err_too_many_tags: 'Too many tags (99 at most)',
|
||||
err_live_logsql: 'Live view is not available in LogsQL mode',
|
||||
dateTime: 'Date & time',
|
||||
tzLabel: 'Time zone',
|
||||
@@ -71,7 +78,7 @@ const I18N = {
|
||||
srcHost: 'Host system',
|
||||
hostTitle: 'Host system logs',
|
||||
hostEnabled: 'Collect the system logs of this machine',
|
||||
hostOff: 'Off: the system logs of the machine hosting Logstream are not collected.',
|
||||
hostOff: 'Off: the system logs of the machine hosting LogStream are not collected.',
|
||||
hostWaiting: 'Starting…',
|
||||
hostJournal: ({ n, r }) => `Reading the systemd journal (${n} files): ${r} entries since startup.`,
|
||||
hostFiles: ({ n, r }) => `Following ${n} files of /var/log: ${r} lines since startup.`,
|
||||
@@ -98,8 +105,8 @@ const I18N = {
|
||||
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
|
||||
dockerNoMatch: 'No container',
|
||||
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
|
||||
stLocked: 'excluded', stLockedTitle: 'Logstream itself, or label logstream.exclude=true',
|
||||
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: Logstream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
|
||||
stLocked: 'excluded', stLockedTitle: 'LogStream itself, or label logstream.exclude=true',
|
||||
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: LogStream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
|
||||
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
|
||||
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
|
||||
fUnit: 'systemd unit', fLogFile: 'log file',
|
||||
@@ -142,7 +149,9 @@ const I18N = {
|
||||
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
|
||||
hUnzoom: '× Reset zoom', unitDay: 'd',
|
||||
toTop: 'Back to top',
|
||||
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colMsg: 'Message',
|
||||
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
|
||||
codesTitle: 'Codes of the color tags found in the message',
|
||||
tagCodeTitle: 'Code shown on the log lines this tag matches',
|
||||
colGrip: 'Drag to resize, double-click for the automatic width',
|
||||
resetCols: 'Reset column widths', colsReset: 'Column widths reset',
|
||||
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
|
||||
@@ -192,11 +201,18 @@ const I18N = {
|
||||
newTag: 'nouveau',
|
||||
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
|
||||
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?',
|
||||
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
|
||||
preset_http_status: 'Codes HTTP', preset_http_methods: 'Méthodes HTTP',
|
||||
preset_http_probes: 'Sondes et attaques', preset_http_bots: 'Robots et scripts',
|
||||
preset_http_errors: 'Erreurs TLS/HTTPS et proxy',
|
||||
pl_probes: 'sondes / attaques', pl_bots: 'robots / scripts', pl_tls: 'erreurs TLS', pl_proxy: 'erreurs proxy',
|
||||
presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'),
|
||||
tagsLoadErr: 'Tags : ',
|
||||
err_pattern_required: 'Le mot-clé est obligatoire',
|
||||
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
|
||||
err_invalid_regex: 'Expression régulière invalide',
|
||||
err_tag_not_found: 'Tag introuvable',
|
||||
err_too_many_tags: 'Trop de tags (99 au maximum)',
|
||||
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
|
||||
dateTime: 'Date et heure',
|
||||
tzLabel: 'Fuseau horaire',
|
||||
@@ -215,7 +231,7 @@ const I18N = {
|
||||
srcHost: 'Système hôte',
|
||||
hostTitle: 'Logs système de l\'hôte',
|
||||
hostEnabled: 'Collecter les logs système de cette machine',
|
||||
hostOff: 'Désactivé : les logs système de la machine qui héberge Logstream ne sont pas collectés.',
|
||||
hostOff: 'Désactivé : les logs système de la machine qui héberge LogStream ne sont pas collectés.',
|
||||
hostWaiting: 'Démarrage…',
|
||||
hostJournal: ({ n, r }) => `Lecture du journal systemd (${n} fichiers) : ${r} entrées depuis le démarrage.`,
|
||||
hostFiles: ({ n, r }) => `Suivi de ${n} fichiers de /var/log : ${r} lignes depuis le démarrage.`,
|
||||
@@ -242,8 +258,8 @@ const I18N = {
|
||||
dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.',
|
||||
dockerNoMatch: 'Aucun conteneur',
|
||||
stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté',
|
||||
stLocked: 'exclu', stLockedTitle: 'Logstream lui-même, ou étiquette logstream.exclude=true',
|
||||
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : Logstream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
|
||||
stLocked: 'exclu', stLockedTitle: 'LogStream lui-même, ou étiquette logstream.exclude=true',
|
||||
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : LogStream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
|
||||
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
|
||||
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
|
||||
fUnit: 'unité systemd', fLogFile: 'fichier de log',
|
||||
@@ -286,7 +302,9 @@ const I18N = {
|
||||
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
|
||||
hUnzoom: '× Annuler le zoom', unitDay: 'j',
|
||||
toTop: 'Revenir en haut',
|
||||
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colMsg: 'Message',
|
||||
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
|
||||
codesTitle: 'Codes des tags de couleur trouvés dans le message',
|
||||
tagCodeTitle: 'Code affiché sur les lignes de log où ce tag est trouvé',
|
||||
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
|
||||
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
|
||||
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
|
||||
@@ -681,7 +699,11 @@ function compileMatchers() {
|
||||
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
|
||||
if (tag.wholeWord) src = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
|
||||
try {
|
||||
out.push({ re: new RegExp(src, 'gu' + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` });
|
||||
// A regex may name a group "hl" to color only that part of the match.
|
||||
const hl = tag.regex && /\(\?P?<hl>/.test(tag.pattern);
|
||||
if (hl) src = src.replace(/\(\?P<hl>/g, '(?<hl>');
|
||||
out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">`,
|
||||
code: tag.code, name: tag.label || tag.pattern });
|
||||
} catch (e) {
|
||||
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
|
||||
}
|
||||
@@ -706,6 +728,8 @@ function highlight(text) {
|
||||
let x;
|
||||
while ((x = m.re.exec(text)) !== null) {
|
||||
if (x[0] === '') { m.re.lastIndex++; continue; }
|
||||
const g = x.indices?.groups?.hl;
|
||||
if (g) { if (g[1] > g[0]) spans.push({ s: g[0], e: g[1], prio, html: m.html }); continue; }
|
||||
spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html });
|
||||
}
|
||||
});
|
||||
@@ -721,6 +745,30 @@ function highlight(text) {
|
||||
return out + esc(text.slice(pos));
|
||||
}
|
||||
|
||||
// Badges with the codes of the tags found in a message, in list order. The
|
||||
// column has room for 3: beyond that, 2 badges and "+N" (all in the tooltip).
|
||||
function codesHTML(text) {
|
||||
text = String(text ?? '');
|
||||
const found = [];
|
||||
for (const m of state.matchers) {
|
||||
if (!m.code || !text) continue;
|
||||
m.re.lastIndex = 0;
|
||||
let x;
|
||||
while ((x = m.re.exec(text)) !== null) {
|
||||
if (x[0] === '') { m.re.lastIndex++; continue; }
|
||||
const g = x.indices?.groups?.hl;
|
||||
if (!x.indices?.groups || g) { found.push(m); break; }
|
||||
}
|
||||
}
|
||||
if (!found.length) return '';
|
||||
const shown = found.length > 3 ? found.slice(0, 2) : found;
|
||||
const title = found.map((m) => `${m.code} ${m.name}`).join('\n');
|
||||
return `<span title="${esc(title)}">`
|
||||
+ shown.map((m) => `<b>${esc(m.code)}</b>`).join('')
|
||||
+ (found.length > 3 ? `<b class="more">+${found.length - 2}</b>` : '')
|
||||
+ '</span>';
|
||||
}
|
||||
|
||||
/* ================= List rendering ================= */
|
||||
|
||||
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
|
||||
@@ -746,6 +794,7 @@ function rowHTML(r, isNew) {
|
||||
+ (r.app
|
||||
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
|
||||
: '<span class="app"></span>')
|
||||
+ `<span class="codes">${codesHTML(r._msg)}</span>`
|
||||
+ `<div class="msg">${highlight(r._msg)}</div>`
|
||||
+ '</article>';
|
||||
}
|
||||
@@ -812,7 +861,9 @@ function renderList() {
|
||||
function rehighlight() {
|
||||
for (const row of list.children) {
|
||||
const r = recOf.get(row);
|
||||
if (r) row.querySelector('.msg').innerHTML = highlight(r._msg);
|
||||
if (!r) continue;
|
||||
row.querySelector('.msg').innerHTML = highlight(r._msg);
|
||||
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1978,6 +2029,28 @@ $('#purgeBtn').addEventListener('click', async () => {
|
||||
|
||||
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
|
||||
|
||||
// Ready-made tags for HTTP/HTTPS access logs. The patterns are valid in both
|
||||
// JavaScript and Go (RE2) and cover nginx/Apache (common, combined), Traefik
|
||||
// (CLF, JSON), Caddy (JSON) and HAProxy (httplog). For status codes and
|
||||
// methods only the "hl" group is colored, not the context around it.
|
||||
const HTTP_STATUS_CTX = '(?:" |"(?:status|DownstreamStatus|OriginStatus|status_code)": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )';
|
||||
const httpStatus = (d, color) => ({ label: `HTTP ${d}xx`, pattern: `${HTTP_STATUS_CTX}(?<hl>${d}\\d\\d)\\b`, color });
|
||||
const httpMethod = (m, color) => ({ label: m.replace(/\|/g, '/'), pattern: `"(?<hl>${m})[ "]`, color, caseSensitive: true });
|
||||
const PRESETS = {
|
||||
http_status: () => [httpStatus(2, '#86efac'), httpStatus(3, '#93c5fd'), httpStatus(4, '#fdba74'), httpStatus(5, '#f87171')],
|
||||
http_methods: () => [httpMethod('GET|HEAD|OPTIONS', '#cbd5e1'), httpMethod('POST|PUT|PATCH', '#c4b5fd'), httpMethod('DELETE', '#f9a8d4')],
|
||||
http_probes: () => [{ label: t('pl_probes'), color: '#fda4af',
|
||||
pattern: '(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|<script|union(?:\\s|%20|\\+)+select)' }],
|
||||
http_bots: () => [{ label: t('pl_bots'), color: '#fde68a',
|
||||
pattern: '\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b' }],
|
||||
http_errors: () => [
|
||||
{ label: t('pl_tls'), color: '#f0abfc',
|
||||
pattern: '(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)' },
|
||||
{ label: t('pl_proxy'), color: '#fdba74',
|
||||
pattern: '(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)' },
|
||||
],
|
||||
};
|
||||
|
||||
async function loadTags() {
|
||||
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
|
||||
compileMatchers();
|
||||
@@ -1987,9 +2060,10 @@ function tagRowHTML(tag) {
|
||||
const opt = (field, label, title) =>
|
||||
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`;
|
||||
return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
|
||||
<span class="tag-code" title="${esc(t('tagCodeTitle'))}">${esc(tag.code || '··')}</span>
|
||||
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}">
|
||||
<input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
|
||||
<span class="preview"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.pattern || t('preview'))}</mark></span>
|
||||
<span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span>
|
||||
<div class="opts">
|
||||
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))}
|
||||
${opt('caseSensitive', 'Aa', t('optCaseTitle'))}
|
||||
@@ -2032,7 +2106,7 @@ $('#tagList').addEventListener('input', (ev) => {
|
||||
tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
|
||||
rowEl.classList.toggle('off', !tag.enabled);
|
||||
const mark = rowEl.querySelector('.preview mark');
|
||||
mark.textContent = tag.pattern || t('preview');
|
||||
mark.textContent = tag.label || tag.pattern || t('preview');
|
||||
mark.setAttribute('style', tagStyle(tag.color));
|
||||
scheduleSave(tag, rowEl);
|
||||
applyTags();
|
||||
@@ -2065,6 +2139,24 @@ $('#addTag').addEventListener('click', async () => {
|
||||
} catch (e) { toast(e.message); }
|
||||
});
|
||||
|
||||
// Adds a preset group, skipping tags whose pattern is already in the list.
|
||||
$('#presetTags').addEventListener('change', async (ev) => {
|
||||
const make = PRESETS[ev.target.value];
|
||||
ev.target.value = '';
|
||||
if (!make) return;
|
||||
let added = 0;
|
||||
try {
|
||||
for (const p of make()) {
|
||||
if (state.tags.some((x) => x.pattern === p.pattern)) continue;
|
||||
state.tags.push(await api('/api/tags', { method: 'POST', body: { regex: true, enabled: true, ...p } }));
|
||||
added++;
|
||||
}
|
||||
} catch (e) { toast(e.message); }
|
||||
renderTagList();
|
||||
applyTags();
|
||||
toast(t('presetAdded', added));
|
||||
});
|
||||
|
||||
$('#resetTags').addEventListener('click', async () => {
|
||||
if (!confirm(t('confirmReset'))) return;
|
||||
try {
|
||||
|
||||
+13
-2
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Logstream</title>
|
||||
<title>LogStream</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script>
|
||||
@@ -20,7 +20,7 @@
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
||||
<span>Logstream</span>
|
||||
<span>LogStream</span>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
@@ -107,6 +107,7 @@
|
||||
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||
<span data-col="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Filters</span></span>
|
||||
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
|
||||
</div>
|
||||
<main id="list" class="list"></main>
|
||||
@@ -188,7 +189,17 @@
|
||||
<p class="muted small" data-i18n="tagsHelp"></p>
|
||||
<div id="tagList" class="tag-list"></div>
|
||||
<footer>
|
||||
<span class="tag-add">
|
||||
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
|
||||
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
|
||||
<option value="" data-i18n="presetPick">+ Preset…</option>
|
||||
<option value="http_status" data-i18n="preset_http_status">HTTP status codes</option>
|
||||
<option value="http_methods" data-i18n="preset_http_methods">HTTP methods</option>
|
||||
<option value="http_probes" data-i18n="preset_http_probes">Probes and attacks</option>
|
||||
<option value="http_bots" data-i18n="preset_http_bots">Bots and scripts</option>
|
||||
<option value="http_errors" data-i18n="preset_http_errors">TLS/HTTPS and proxy errors</option>
|
||||
</select>
|
||||
</span>
|
||||
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Logstream</title>
|
||||
<title>LogStream</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script>
|
||||
@@ -32,7 +32,7 @@
|
||||
<img id="logo" class="login-logo" src="auth/logo" alt="" hidden>
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
||||
<span>Logstream</span>
|
||||
<span>LogStream</span>
|
||||
</div>
|
||||
<p class="muted" data-i18n="intro">Sign in to view the logs.</p>
|
||||
|
||||
|
||||
+24
-9
@@ -37,7 +37,8 @@
|
||||
--tag-warning: #fdba74;
|
||||
--tag-warning-text: #111827;
|
||||
|
||||
--log-size: 12.5px; /* Settings > Interface > Font size */
|
||||
--log-size: 12.5px;
|
||||
--codes-w: 4.9rem; /* tag codes column: room for 3 badges */ /* Settings > Interface > Font size */
|
||||
/* --log-font is set by Settings > Interface > Font (defaults to --mono) */
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
@@ -296,13 +297,13 @@ body.busy .progress::after {
|
||||
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.list:empty { display: none; }
|
||||
|
||||
/* Columns: received, message time, severity, host, app, message. Widths come from
|
||||
/* Columns: received, message time, severity, host, app, tag codes, message. Widths come from
|
||||
--col-* (set on #table when a column has been resized, see app.js), shared by
|
||||
the header and every row through subgrid so that the columns line up. */
|
||||
.table {
|
||||
display: grid;
|
||||
grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
|
||||
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) minmax(0, 1fr);
|
||||
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) var(--codes-w) minmax(0, 1fr);
|
||||
column-gap: 12px;
|
||||
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */
|
||||
@@ -331,7 +332,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr;
|
||||
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) var(--codes-w) 1fr;
|
||||
gap: 0 12px; align-items: baseline;
|
||||
padding: 5px 14px 5px 11px;
|
||||
border-left: 3px solid transparent;
|
||||
@@ -372,6 +373,15 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
|
||||
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
|
||||
.row .host[data-act], .row .app[data-act] { cursor: pointer; }
|
||||
/* Codes of the tags found in the message: grey badges, fixed size */
|
||||
.row .codes { white-space: nowrap; overflow: hidden; }
|
||||
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
|
||||
.row .codes b, .tag-code {
|
||||
min-width: 2.2ch; padding: 1px 4px; border-radius: 4px; text-align: center;
|
||||
font-family: var(--mono); font-size: 10.5px; font-weight: 700; line-height: 1.35; font-variant-numeric: tabular-nums;
|
||||
background: #b4bac3; color: #111827;
|
||||
}
|
||||
.row .codes b.more { background: none; box-shadow: inset 0 0 0 1px #b4bac3; color: var(--muted); }
|
||||
.row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
mark.tag {
|
||||
@@ -475,6 +485,7 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
|
||||
.preview-list .row .host { grid-area: host; }
|
||||
.preview-list .row .app { grid-area: app; display: block; }
|
||||
.preview-list .row .msg { grid-area: msg; }
|
||||
.preview-list .row .codes { display: none; }
|
||||
|
||||
dialog {
|
||||
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
|
||||
@@ -578,6 +589,8 @@ input.switch:disabled { cursor: not-allowed; }
|
||||
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
|
||||
.tag-add { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.tag-add select.field { width: auto; }
|
||||
|
||||
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.seg button {
|
||||
@@ -590,11 +603,12 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
.tag-row {
|
||||
display: grid; align-items: center; gap: 8px 10px;
|
||||
grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px;
|
||||
grid-template-columns: auto 38px minmax(8rem, 1fr) 7.5rem auto 36px;
|
||||
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.tag-row.off { opacity: .55; }
|
||||
.tag-row .tag-code { font-size: 12px; padding: 3px 6px; cursor: default; }
|
||||
.tag-row input[type="color"] {
|
||||
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: none; cursor: pointer;
|
||||
@@ -637,8 +651,8 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
.table .list { display: block; margin: 0; }
|
||||
.list-head { display: none; }
|
||||
.row, .table .row {
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-template-areas: "rcv sev host" "msg msg msg";
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
grid-template-areas: "rcv sev host codes" "msg msg msg msg";
|
||||
gap: 3px 8px; padding: 8px 12px 8px 10px;
|
||||
}
|
||||
.row time.rcv { grid-area: rcv; }
|
||||
@@ -646,6 +660,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
.row .sev { grid-area: sev; }
|
||||
.row .host { grid-area: host; justify-self: end; max-width: 100%; }
|
||||
.row .app { display: none; }
|
||||
.row .codes { grid-area: codes; }
|
||||
.row .msg { grid-area: msg; }
|
||||
.details { grid-column: 1 / -1; }
|
||||
.details dl { grid-template-columns: 1fr; }
|
||||
@@ -667,10 +682,10 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
#sizeSwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
|
||||
.field-grid select { margin-bottom: 6px; }
|
||||
.status { padding: 6px 16px; }
|
||||
.tag-row { grid-template-columns: 38px 1fr 36px; }
|
||||
.tag-row { grid-template-columns: auto 38px 1fr 36px; }
|
||||
.tag-row .preview { display: none; }
|
||||
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
||||
.tag-row [data-del] { grid-column: 3; grid-row: 1; }
|
||||
.tag-row [data-del] { grid-column: 4; grid-row: 1; }
|
||||
}
|
||||
|
||||
/* ---------- Login page (AUTH_MODE=local) ---------- */
|
||||
|
||||
Reference in new issue
Block a user