From c664f1eaaf876af08c8aebf52ad89b56f6a06716 Mon Sep 17 00:00:00 2001 From: Cedric Date: Sat, 3 Oct 2026 15:35:20 +0200 Subject: [PATCH] Two-digit code per color tag, shown as badges on matching log lines Each tag gets a stable code (01-99) assigned by the server and stored in tags.json; existing tags get one when the file is loaded. A fixed-width Filters column shows the codes of the active tags found in each message (room for 3, then 2 + "+N", all listed in the tooltip). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.fr.md | 9 +++++++-- README.md | 7 ++++++- tags.go | 49 +++++++++++++++++++++++++++++++++++++++++---- tags_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ web/app.js | 43 ++++++++++++++++++++++++++++++++++++---- web/index.html | 1 + web/style.css | 31 ++++++++++++++++++++--------- 7 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 tags_test.go diff --git a/README.fr.md b/README.fr.md index 0cc5f3f..bd300f9 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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 @@ -235,6 +235,11 @@ couleur, est mémorisé par navigateur. ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression régulière, un groupe nommé `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. diff --git a/README.md b/README.md index 7343e0f..edcf628 100644 --- a/README.md +++ b/README.md @@ -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 @@ -214,6 +214,11 @@ remembered per browser. added tags can be edited like any other. In a regular expression, a group named `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. diff --git a/tags.go b/tags.go index 5dbaaa2..b688c08 100644 --- a/tags.go +++ b/tags.go @@ -16,6 +16,7 @@ import ( // Tag highlights a keyword in displayed messages. type Tag struct { ID string `json:"id"` + Code string `json:"code"` // two digits, shown on matching log lines Pattern string `json:"pattern"` Label string `json:"label,omitempty"` // shown instead of the pattern (presets) Color string `json:"color"` @@ -27,12 +28,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"} @@ -68,6 +104,7 @@ func (e *codedError) Error() string { var ( errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"} + errTooManyTags = &codedError{code: "too_many_tags", msg: "too many tags (99 at most)"} colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) ) @@ -108,7 +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 } @@ -145,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() } @@ -158,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() } diff --git a/tags_test.go b/tags_test.go new file mode 100644 index 0000000..57a62f7 --- /dev/null +++ b/tags_test.go @@ -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) + } +} diff --git a/web/app.js b/web/app.js index f20ca92..45314a1 100644 --- a/web/app.js +++ b/web/app.js @@ -59,6 +59,7 @@ const I18N = { 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', @@ -148,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).', @@ -209,6 +212,7 @@ const I18N = { 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', @@ -298,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).', @@ -696,7 +702,8 @@ function compileMatchers() { // A regex may name a group "hl" to color only that part of the match. const hl = tag.regex && /\(\?P?/.test(tag.pattern); if (hl) src = src.replace(/\(\?P/g, '(?'); - out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `` }); + out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: ``, + code: tag.code, name: tag.label || tag.pattern }); } catch (e) { console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message); } @@ -738,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 `` + + shown.map((m) => `${esc(m.code)}`).join('') + + (found.length > 3 ? `+${found.length - 2}` : '') + + ''; +} + /* ================= List rendering ================= */ const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' }; @@ -763,6 +794,7 @@ function rowHTML(r, isNew) { + (r.app ? `${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}` : '') + + `${codesHTML(r._msg)}` + `
${highlight(r._msg)}
` + ''; } @@ -829,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); } } @@ -2026,6 +2060,7 @@ function tagRowHTML(tag) { const opt = (field, label, title) => ``; return `
+ ${esc(tag.code || '··')} ${esc(tag.label || tag.pattern || t('preview'))} diff --git a/web/index.html b/web/index.html index 9946332..89116ed 100644 --- a/web/index.html +++ b/web/index.html @@ -107,6 +107,7 @@ Severity Host App + Filters Message
diff --git a/web/style.css b/web/style.css index 34411d3..3035f8f 100644 --- a/web/style.css +++ b/web/style.css @@ -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); @@ -592,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; @@ -639,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; } @@ -648,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; } @@ -669,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) ---------- */