Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8ca74cad | ||
|
|
2ca89b1c6d | ||
|
|
1a3b3ee23a | ||
|
|
194edee6de |
No files matched your search
+16
-5
@@ -92,6 +92,10 @@ 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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -338,11 +342,13 @@ conteneur, un **format** (*Automatique* par défaut), un **hôte** facultatif (p
|
||||
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 un badge **FILE** avant l'app ; le filtre **Source**
|
||||
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) ; un fichier créé ensuite est lu depuis son début. L'inode et une
|
||||
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
|
||||
@@ -386,8 +392,13 @@ En ligne de commande : `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&
|
||||
|
||||
## 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 et langue**
|
||||
- *Langue* : anglais ou français.
|
||||
|
||||
@@ -83,6 +83,9 @@ 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.
|
||||
|
||||
@@ -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
|
||||
@@ -310,10 +313,12 @@ optional **host** (default: `/host/etc/hostname`, else `localhost`) and a switch
|
||||
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 a **FILE** badge before the app; the **Source** filter shows them alone.
|
||||
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); a file
|
||||
created later is read from its start. The inode and an offset are saved per file in
|
||||
(`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.
|
||||
@@ -351,8 +356,11 @@ From the command line: `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -40,6 +40,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)
|
||||
@@ -531,6 +533,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 {
|
||||
|
||||
+81
-9
@@ -53,6 +53,7 @@ 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
|
||||
}
|
||||
@@ -79,6 +80,7 @@ type fileTail struct {
|
||||
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…)
|
||||
@@ -265,10 +267,11 @@ func (f *Files) Status() map[string]any {
|
||||
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": "", "code": "", "error": "",
|
||||
"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)
|
||||
}
|
||||
@@ -346,7 +349,8 @@ func (f *Files) poll(now time.Time) {
|
||||
}
|
||||
|
||||
const (
|
||||
maxFileRead = 4 << 20 // per file and per poll
|
||||
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
|
||||
)
|
||||
|
||||
@@ -370,6 +374,10 @@ func (f *Files) setStatus(id string, err error) {
|
||||
}
|
||||
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 {
|
||||
@@ -384,9 +392,12 @@ func (f *Files) setStatus(id string, err error) {
|
||||
|
||||
func (f *Files) count(id string) {
|
||||
f.mu.Lock()
|
||||
if st := f.status[id]; st != nil {
|
||||
st.Read++
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -430,11 +441,12 @@ func (f *Files) pollFile(s *fileSource, now time.Time) error {
|
||||
pos = filePos{Ino: ino}
|
||||
if first {
|
||||
// Turned on: the last FILES_BACKFILL of what is already there
|
||||
// (0: only new lines).
|
||||
if f.backfill > 0 {
|
||||
pos.Until = fi.Size()
|
||||
} else {
|
||||
// (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
|
||||
@@ -469,18 +481,78 @@ func (f *Files) pollFile(s *fileSource, now time.Time) error {
|
||||
if p == nil {
|
||||
return nil // nothing complete to detect the format from yet
|
||||
}
|
||||
next, _, err := f.readFrom(s, tl, p, path, tl.readOff, pos.Until, now)
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -382,3 +383,45 @@ func TestFilesAutoAndErrors(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+285
-114
@@ -40,13 +40,16 @@ const I18N = {
|
||||
copyMsg: 'Copy message', copyJson: 'Copy as JSON', copied: 'Copied',
|
||||
language: 'Language',
|
||||
tagsTitle: 'Color tags',
|
||||
tagsHelp: 'Keywords are highlighted in messages. Changes are saved automatically. When tags overlap, the one highest in the list wins.',
|
||||
tagsHelp: 'Keywords are highlighted in messages. Changes apply with the Save button. When tags overlap, the one highest in the list wins.',
|
||||
addTag: '+ Add tag', resetTags: 'Restore default tags',
|
||||
optWhole: 'whole word', optWholeTitle: 'Only color whole words (avoids "ok" in "token")',
|
||||
optCaseTitle: 'Match case', optRegexTitle: 'Regular expression',
|
||||
optActive: 'active', optActiveTitle: 'Enable this tag',
|
||||
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
|
||||
newTag: 'new',
|
||||
saveSettings: 'Save', cancelSettings: 'Cancel',
|
||||
settingsClean: 'No pending changes', settingsDirty: 'Unsaved changes', settingsSaved: 'Settings saved',
|
||||
unsavedConfirm: 'Some changes have not been saved. Discard them?',
|
||||
confirmDelete: (p) => `Delete tag "${p}"?`,
|
||||
confirmReset: 'Replace all tags with the defaults (warning, error)?',
|
||||
presetAria: 'Add a preset', presetPick: '+ Preset…',
|
||||
@@ -98,6 +101,7 @@ const I18N = {
|
||||
fileDraft: 'Fill in the name and the path to save this file.',
|
||||
fileOk: (r) => `Followed: ${r} entries since startup.`,
|
||||
fileDetected: (f) => ` Format detected: ${f}.`,
|
||||
fileBehind: (mb) => ` Reading the history: ${mb} MB left.`,
|
||||
probeFound: (f) => `File found, format detected: ${f}.`,
|
||||
probeSample: 'Last line: ',
|
||||
file_empty: 'The file is empty for now: its format will be detected from its first lines.',
|
||||
@@ -105,7 +109,7 @@ const I18N = {
|
||||
file_not_found: 'No file at this path: mount it in the container (see docker-compose.yml) and check the path.',
|
||||
file_permission: 'Permission denied: the file must be readable by UID 10001 or the adm group (see the README). ',
|
||||
file_read: 'Cannot read the file: ',
|
||||
filesHelp: 'Follows the log file of another program, mounted read-only in the container (e.g. /var/log/traefik:/logs/traefik:ro, then /logs/traefik/access.log here). Once the path is filled in, the file is checked and its format detected from its last lines (JSON lines, logfmt, access logs of nginx, Apache or Traefik, else plain text); choose a format to force it, or a regular expression whose named groups time, level, msg, host and app fill these fields, the other groups giving extra fields. Each line gives an entry (badge FILE, app = the name); indented lines are added to the previous entry; for access logs 5xx are errors and 4xx warnings. When turned on, the last hour of dated lines is read first (FILES_BACKFILL); rotations are followed and reading resumes where it stopped.',
|
||||
filesHelp: 'Follows the log file of another program, mounted read-only in the container (e.g. /var/log/traefik:/logs/traefik:ro, then /logs/traefik/access.log here). Once the path is filled in, the file is checked and its format detected from its last lines (JSON lines, logfmt, access logs of nginx, Apache or Traefik, else plain text); choose a format to force it, or a regular expression whose named groups time, level, msg, host and app fill these fields, the other groups giving extra fields. Each line gives an entry (badge FILE at the start of the line, app = the name); indented lines are added to the previous entry; for access logs 5xx are errors and 4xx warnings. When turned on, the last hour of dated lines is read first (FILES_BACKFILL); rotations are followed and reading resumes where it stopped.',
|
||||
auditTitle: 'Linux audit (auditd)',
|
||||
auditEnabled: 'Collect the audit events of this machine',
|
||||
auditPath: 'File',
|
||||
@@ -224,7 +228,7 @@ const I18N = {
|
||||
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
|
||||
hUnzoom: '× Back to stream', unitDay: 'd',
|
||||
toTop: 'Back to top',
|
||||
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Pattern', colMsg: 'Message',
|
||||
colSrc: 'Source', colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Pattern', 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',
|
||||
@@ -268,13 +272,16 @@ const I18N = {
|
||||
copyMsg: 'Copier le message', copyJson: 'Copier en JSON', copied: 'Copié',
|
||||
language: 'Langue',
|
||||
tagsTitle: 'Tags de couleur',
|
||||
tagsHelp: 'Les mots-clés sont surlignés dans les messages. Les modifications sont enregistrées automatiquement. En cas de chevauchement, le tag le plus haut dans la liste l\'emporte.',
|
||||
tagsHelp: 'Les mots-clés sont surlignés dans les messages. Les modifications s\'appliquent avec le bouton Valider. En cas de chevauchement, le tag le plus haut dans la liste l\'emporte.',
|
||||
addTag: '+ Ajouter un tag', resetTags: 'Rétablir les tags par défaut',
|
||||
optWhole: 'mot entier', optWholeTitle: 'Ne colorer que les mots entiers (évite « ok » dans « token »)',
|
||||
optCaseTitle: 'Respecter la casse', optRegexTitle: 'Expression régulière',
|
||||
optActive: 'actif', optActiveTitle: 'Activer ce tag',
|
||||
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
|
||||
newTag: 'nouveau',
|
||||
saveSettings: 'Valider', cancelSettings: 'Annuler',
|
||||
settingsClean: 'Aucune modification en attente', settingsDirty: 'Modifications non validées', settingsSaved: 'Paramètres enregistrés',
|
||||
unsavedConfirm: 'Des modifications n\'ont pas été validées. Les abandonner ?',
|
||||
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
|
||||
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?',
|
||||
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
|
||||
@@ -326,6 +333,7 @@ const I18N = {
|
||||
fileDraft: 'Renseignez le nom et le chemin pour enregistrer ce fichier.',
|
||||
fileOk: (r) => `Suivi : ${r} entrées depuis le démarrage.`,
|
||||
fileDetected: (f) => ` Format détecté : ${f}.`,
|
||||
fileBehind: (mb) => ` Lecture de l\'historique : ${mb} Mo restants.`,
|
||||
probeFound: (f) => `Fichier trouvé, format détecté : ${f}.`,
|
||||
probeSample: 'Dernière ligne : ',
|
||||
file_empty: 'Le fichier est vide pour l\'instant : son format sera détecté à partir de ses premières lignes.',
|
||||
@@ -333,7 +341,7 @@ const I18N = {
|
||||
file_not_found: 'Aucun fichier à ce chemin : montez-le dans le conteneur (voir docker-compose.yml) et vérifiez le chemin.',
|
||||
file_permission: 'Accès refusé : le fichier doit être lisible par l\'UID 10001 ou le groupe adm (voir le README). ',
|
||||
file_read: 'Lecture du fichier impossible : ',
|
||||
filesHelp: 'Suit le fichier de logs d\'un autre programme, monté en lecture seule dans le conteneur (par ex. /var/log/traefik:/logs/traefik:ro, puis /logs/traefik/access.log ici). Une fois le chemin saisi, le fichier est vérifié et son format détecté à partir de ses dernières lignes (JSON lines, logfmt, journaux d\'accès de nginx, Apache ou Traefik, sinon texte brut) ; choisissez un format pour le forcer, ou une expression régulière dont les groupes nommés time, level, msg, host et app remplissent ces champs, les autres groupes donnant des champs supplémentaires. Chaque ligne donne une entrée (badge FILE, app = le nom) ; les lignes indentées sont rattachées à l\'entrée précédente ; pour les journaux d\'accès, 5xx en erreur et 4xx en warning. À l\'activation, la dernière heure de lignes datées est lue d\'abord (FILES_BACKFILL) ; les rotations sont suivies et la lecture reprend où elle s\'était arrêtée.',
|
||||
filesHelp: 'Suit le fichier de logs d\'un autre programme, monté en lecture seule dans le conteneur (par ex. /var/log/traefik:/logs/traefik:ro, puis /logs/traefik/access.log ici). Une fois le chemin saisi, le fichier est vérifié et son format détecté à partir de ses dernières lignes (JSON lines, logfmt, journaux d\'accès de nginx, Apache ou Traefik, sinon texte brut) ; choisissez un format pour le forcer, ou une expression régulière dont les groupes nommés time, level, msg, host et app remplissent ces champs, les autres groupes donnant des champs supplémentaires. Chaque ligne donne une entrée (badge FILE en début de ligne, app = le nom) ; les lignes indentées sont rattachées à l\'entrée précédente ; pour les journaux d\'accès, 5xx en erreur et 4xx en warning. À l\'activation, la dernière heure de lignes datées est lue d\'abord (FILES_BACKFILL) ; les rotations sont suivies et la lecture reprend où elle s\'était arrêtée.',
|
||||
auditTitle: 'Audit Linux (auditd)',
|
||||
auditEnabled: 'Collecter les événements d\'audit de cette machine',
|
||||
auditPath: 'Fichier',
|
||||
@@ -452,7 +460,7 @@ const I18N = {
|
||||
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
|
||||
hUnzoom: '× Revenir au flux', unitDay: 'j',
|
||||
toTop: 'Revenir en haut',
|
||||
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Motif de recherche', colMsg: 'Message',
|
||||
colSrc: 'Source', colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Motif de recherche', 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',
|
||||
@@ -585,7 +593,7 @@ function apiError(res, text, data) {
|
||||
if (data && data.code && I18N[lang]['err_' + data.code]) {
|
||||
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
|
||||
}
|
||||
return new Error(msg);
|
||||
return Object.assign(new Error(msg), { data });
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
@@ -618,6 +626,26 @@ const state = {
|
||||
};
|
||||
const recOf = new WeakMap(); // .row element -> record
|
||||
|
||||
// Settings window: changes are shown at once but only saved by the Save button.
|
||||
const settings = {
|
||||
saved: null, // browser preferences as saved (see localPrefs)
|
||||
tags: null, // tags as saved on the server
|
||||
pending: {}, // source changes waiting for Save, by source
|
||||
orig: {}, // server values of the pending fields, to drop a change set back
|
||||
saving: false,
|
||||
};
|
||||
|
||||
// Records a source change for Save; a field set back to its saved value is dropped.
|
||||
function pend(key, change, current) {
|
||||
const orig = settings.orig[key] ??= { ...current };
|
||||
const p = { ...settings.pending[key], ...change };
|
||||
for (const k of Object.keys(p)) if (p[k] === orig[k]) delete p[k];
|
||||
if (Object.keys(p).length) settings.pending[key] = p;
|
||||
else { delete settings.pending[key]; delete settings.orig[key]; }
|
||||
}
|
||||
// A source state refreshed from the server, with the unsaved changes over it.
|
||||
const withPending = (key, s) => (s && settings.pending[key] ? { ...s, ...settings.pending[key] } : s);
|
||||
|
||||
const list = $('#list');
|
||||
|
||||
/* ================= Language ================= */
|
||||
@@ -634,7 +662,6 @@ function applyLang() {
|
||||
function setLang(next) {
|
||||
if (next === lang || !I18N[next]) return;
|
||||
lang = next;
|
||||
store.set('lang', lang);
|
||||
applyLang();
|
||||
syncControls();
|
||||
setConn(...state.conn);
|
||||
@@ -670,11 +697,12 @@ function effectiveTheme() {
|
||||
if (th) return th;
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
// mode: 'system' (follow the OS), 'light' or 'dark'.
|
||||
function setTheme(mode) {
|
||||
// mode: 'system' (follow the OS), 'light' or 'dark'. In Settings the theme is
|
||||
// only stored by the Save button (persist = false).
|
||||
function setTheme(mode, persist = true) {
|
||||
if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode;
|
||||
else { delete document.documentElement.dataset.theme; mode = 'system'; }
|
||||
store.set('theme', mode);
|
||||
if (persist) store.set('theme', mode);
|
||||
renderThemeSwitch();
|
||||
}
|
||||
|
||||
@@ -689,7 +717,7 @@ function renderThemeSwitch() {
|
||||
$('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark'));
|
||||
$('#themeSwitch').addEventListener('click', (ev) => {
|
||||
const b = ev.target.closest('[data-theme-opt]');
|
||||
if (b) setTheme(b.dataset.themeOpt);
|
||||
if (b) setTheme(b.dataset.themeOpt, false);
|
||||
});
|
||||
|
||||
/* ================= Log font and size ================= */
|
||||
@@ -780,20 +808,15 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
|
||||
const b = ev.target.closest('[data-size]');
|
||||
if (!b) return;
|
||||
applyLogSize(b.dataset.size);
|
||||
store.set('logSize', ui.size);
|
||||
renderInterface();
|
||||
});
|
||||
$('#densitySwitch').addEventListener('click', (ev) => {
|
||||
const b = ev.target.closest('[data-density]');
|
||||
if (!b) return;
|
||||
applyLogDensity(b.dataset.density);
|
||||
store.set('logDensity', ui.density);
|
||||
renderInterface();
|
||||
});
|
||||
$('#fontSelect').addEventListener('change', (ev) => {
|
||||
applyLogFont(ev.target.value);
|
||||
store.set('logFont', ui.font);
|
||||
});
|
||||
$('#fontSelect').addEventListener('change', (ev) => applyLogFont(ev.target.value));
|
||||
|
||||
/* ================= Settings tabs ================= */
|
||||
|
||||
@@ -962,15 +985,18 @@ function fmtTime(s) {
|
||||
return `${day} ${p.h}:${p.m}:${p.s}<span class="ms">.${p.ms}</span>`;
|
||||
}
|
||||
|
||||
// Sources with their own look in the list: a colored edge and a badge
|
||||
// before the app name (the severity badge stays the same).
|
||||
const SRC_BADGE = { audit: 'AUDIT', logstream: 'LS', file: 'FILE' };
|
||||
// Every row starts with a badge naming its source, one color per source
|
||||
// (--src-<type> in style.css). Audit events, log files and LogStream's own
|
||||
// logs also get a colored edge and a light tint (the severity badge stays the same).
|
||||
const SRC_BADGE = { syslog: 'SYSLOG', docker: 'DOCKER', kernel: 'KERNEL', host: 'HOST', audit: 'AUDIT', file: 'FILE', logstream: 'LS' };
|
||||
const srcOf = (r) => (SRC_BADGE[r.source_type] ? r.source_type : 'syslog'); // older entries have no source_type
|
||||
|
||||
function rowHTML(r, isNew) {
|
||||
const sev = r.severity || '';
|
||||
const cls = SEV_CLASS[sev] || 'info';
|
||||
const badge = SRC_BADGE[r.source_type];
|
||||
return `<article class="row sev-${cls}${badge ? ' src-' + r.source_type : ''}${isNew ? ' new' : ''}" tabindex="0">`
|
||||
const src = srcOf(r);
|
||||
return `<article class="row sev-${cls} src-${src}${isNew ? ' new' : ''}" tabindex="0">`
|
||||
+ `<b class="src-badge">${SRC_BADGE[src]}</b>`
|
||||
+ `<time class="rcv" title="${esc(t('rcvTitle'))}">${r.received ? esc(fmtFull(r.received)) : '—'}</time>`
|
||||
+ `<time class="mt" title="${esc(t('msgTimeTitle'))}">${fmtTime(r.msg_time || r._time)}</time>`
|
||||
+ `<span class="sev">${esc(sev || '—')}</span>`
|
||||
@@ -978,7 +1004,7 @@ function rowHTML(r, isNew) {
|
||||
? `<span class="host" data-act="host" title="${esc(hostTitle(r) + ' · ' + t('clickHost'))}">${esc(r.host_name || r.host)}</span>`
|
||||
: '<span class="host"></span>')
|
||||
+ (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 : ''}${badge ? `<b class="src-badge">${badge}</b>` : ''}${esc(r.app)}</span>`
|
||||
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
|
||||
: '<span class="app"></span>')
|
||||
+ `<span class="codes">${codesHTML(r._msg)}</span>`
|
||||
+ `<div class="msg">${msgHTML(r)}</div>`
|
||||
@@ -1747,7 +1773,6 @@ function renderHistoSettings() {
|
||||
function setHistoPref(k, v) {
|
||||
if (!HISTO_OPTS[k] || !HISTO_OPTS[k].includes(v)) return;
|
||||
histoPrefs[k] = v;
|
||||
store.set('histo.' + k, v);
|
||||
renderHistoSettings();
|
||||
if (k === 'step') refreshHisto();
|
||||
else if (k === 'refresh') { scheduleHisto(); if (v !== 'off') refreshHisto(); }
|
||||
@@ -1958,8 +1983,6 @@ function updateTimePreview() {
|
||||
function onTimePrefsChange() {
|
||||
timePrefs.tz = $('#tzSelect').value;
|
||||
timePrefs.fmt = $('#fmtSelect').value;
|
||||
store.set('tz', timePrefs.tz);
|
||||
store.set('timefmt', timePrefs.fmt);
|
||||
updateTimePreview();
|
||||
renderList();
|
||||
renderPeriod();
|
||||
@@ -2027,7 +2050,7 @@ document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExpor
|
||||
let syslogState = null;
|
||||
|
||||
async function loadSyslog() {
|
||||
try { syslogState = await api('/api/syslog'); } catch { syslogState = null; }
|
||||
try { syslogState = withPending('syslog', await api('/api/syslog')); } catch { syslogState = null; }
|
||||
renderSyslog();
|
||||
}
|
||||
|
||||
@@ -2066,12 +2089,10 @@ function renderSyslog() {
|
||||
}
|
||||
}
|
||||
|
||||
async function configureSyslog(change) {
|
||||
function configureSyslog(change) {
|
||||
const s = syslogState || { enabled: true, udp: true, tcp: true };
|
||||
const body = { enabled: s.enabled, udp: s.udp, tcp: s.tcp, ...change };
|
||||
syslogState = { ...s, ...body };
|
||||
renderSyslog();
|
||||
try { syslogState = await api('/api/syslog', { method: 'PUT', body }); } catch (e) { toast(e.message); }
|
||||
pend('syslog', change, s);
|
||||
syslogState = { ...s, ...change };
|
||||
renderSyslog();
|
||||
}
|
||||
|
||||
@@ -2087,7 +2108,7 @@ $('#syslogProtos').addEventListener('click', (ev) => {
|
||||
let hostState = null;
|
||||
|
||||
async function loadHost() {
|
||||
try { hostState = await api('/api/hostlogs'); } catch { hostState = null; }
|
||||
try { hostState = withPending('host', await api('/api/hostlogs')); } catch { hostState = null; }
|
||||
renderHost();
|
||||
}
|
||||
|
||||
@@ -2114,12 +2135,10 @@ function renderHost() {
|
||||
status.className = 'docker-status ' + cls;
|
||||
}
|
||||
|
||||
async function configureHost(enabled) {
|
||||
function configureHost(enabled) {
|
||||
pend('host', { enabled }, hostState || {});
|
||||
hostState = { ...(hostState || {}), enabled };
|
||||
renderHost();
|
||||
try { hostState = await api('/api/hostlogs', { method: 'PUT', body: { enabled } }); } catch (e) { toast(e.message); }
|
||||
renderHost();
|
||||
setTimeout(loadHost, 1500); // the first scan runs in the background
|
||||
}
|
||||
|
||||
$('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.checked));
|
||||
@@ -2129,7 +2148,7 @@ $('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.che
|
||||
let dmesgState = null;
|
||||
|
||||
async function loadDmesg() {
|
||||
try { dmesgState = await api('/api/dmesg'); } catch { dmesgState = null; }
|
||||
try { dmesgState = withPending('dmesg', await api('/api/dmesg')); } catch { dmesgState = null; }
|
||||
renderDmesg();
|
||||
}
|
||||
|
||||
@@ -2153,12 +2172,10 @@ function renderDmesg() {
|
||||
status.className = 'docker-status ' + cls;
|
||||
}
|
||||
|
||||
async function configureDmesg(enabled) {
|
||||
function configureDmesg(enabled) {
|
||||
pend('dmesg', { enabled }, dmesgState || {});
|
||||
dmesgState = { ...(dmesgState || {}), enabled };
|
||||
renderDmesg();
|
||||
try { dmesgState = await api('/api/dmesg', { method: 'PUT', body: { enabled } }); } catch (e) { toast(e.message); }
|
||||
renderDmesg();
|
||||
setTimeout(loadDmesg, 1500); // the device is opened in the background
|
||||
}
|
||||
|
||||
$('#dmesgEnabled').addEventListener('change', (ev) => configureDmesg(ev.target.checked));
|
||||
@@ -2168,7 +2185,7 @@ $('#dmesgEnabled').addEventListener('change', (ev) => configureDmesg(ev.target.c
|
||||
let auditState = null;
|
||||
|
||||
async function loadAudit() {
|
||||
try { auditState = await api('/api/audit'); } catch { auditState = null; }
|
||||
try { auditState = withPending('audit', await api('/api/audit')); } catch { auditState = null; }
|
||||
renderAudit();
|
||||
}
|
||||
|
||||
@@ -2195,13 +2212,10 @@ function renderAudit() {
|
||||
status.className = 'docker-status ' + cls;
|
||||
}
|
||||
|
||||
async function configureAudit(change) {
|
||||
const body = { enabled: auditState?.enabled || false, path: auditState?.path || '', ...change };
|
||||
auditState = { ...(auditState || {}), ...body };
|
||||
function configureAudit(change) {
|
||||
pend('audit', change, { enabled: false, path: '', ...auditState });
|
||||
auditState = { ...(auditState || {}), ...change };
|
||||
renderAudit();
|
||||
try { auditState = await api('/api/audit', { method: 'PUT', body }); } catch (e) { toast(e.message); }
|
||||
renderAudit();
|
||||
setTimeout(loadAudit, 1500); // the file is read in the background
|
||||
}
|
||||
|
||||
$('#auditEnabled').addEventListener('change', (ev) => configureAudit({ enabled: ev.target.checked }));
|
||||
@@ -2212,7 +2226,7 @@ $('#auditPath').addEventListener('change', (ev) => configureAudit({ path: ev.tar
|
||||
let selfState = null;
|
||||
|
||||
async function loadSelfLog() {
|
||||
try { selfState = await api('/api/selflog'); } catch { selfState = null; }
|
||||
try { selfState = withPending('selflog', await api('/api/selflog')); } catch { selfState = null; }
|
||||
renderSelfLog();
|
||||
}
|
||||
|
||||
@@ -2228,11 +2242,9 @@ function renderSelfLog() {
|
||||
status.className = 'docker-status ' + (s.enabled ? 'good' : 'muted');
|
||||
}
|
||||
|
||||
async function configureSelfLog(change) {
|
||||
const body = { enabled: selfState?.enabled ?? true, min_level: selfState?.min_level || 'warning', ...change };
|
||||
selfState = { ...(selfState || {}), ...body };
|
||||
renderSelfLog();
|
||||
try { selfState = await api('/api/selflog', { method: 'PUT', body }); } catch (e) { toast(e.message); }
|
||||
function configureSelfLog(change) {
|
||||
pend('selflog', change, { enabled: true, min_level: 'warning', ...selfState });
|
||||
selfState = { ...(selfState || {}), ...change };
|
||||
renderSelfLog();
|
||||
}
|
||||
|
||||
@@ -2247,9 +2259,18 @@ let fileDrafts = []; // rows added here, saved once they have a name and a pat
|
||||
let fileSeq = 0;
|
||||
const fileProbes = new Map(); // path -> result of /api/files/detect (format found, last line)
|
||||
|
||||
async function loadFiles() {
|
||||
try { filesState = await api('/api/files'); } catch { filesState = null; }
|
||||
renderFiles();
|
||||
const FILE_FIELDS = ['id', 'name', 'path', 'format', 'regex', 'host', 'enabled'];
|
||||
|
||||
// full: rebuild the rows (after Save or Cancel).
|
||||
async function loadFiles(full) {
|
||||
let fresh;
|
||||
try { fresh = await api('/api/files'); } catch { filesState = null; renderFiles(full); return; }
|
||||
if (settings.pending.files && filesState) {
|
||||
// Unsaved edits: keep the rows as edited, with the server status of each file.
|
||||
const byId = new Map(fresh.sources.map((x) => [x.id, x]));
|
||||
filesState = { ...fresh, sources: filesState.sources.map((x) => ({ ...byId.get(x.id), ...Object.fromEntries(FILE_FIELDS.map((k) => [k, x[k]])) })) };
|
||||
} else filesState = fresh;
|
||||
renderFiles(full);
|
||||
}
|
||||
|
||||
// Status line of a row: the check of the path just typed, else the state
|
||||
@@ -2263,7 +2284,10 @@ function fileStatus(s) {
|
||||
if (s.draft) return [t('fileDraft'), 'muted'];
|
||||
if (!s.enabled) return [t('fileOff'), 'muted'];
|
||||
if (s.code) return [t('file_' + s.code) + (s.code === 'not_found' ? '' : s.error || ''), 'bad'];
|
||||
if (s.open) return [t('fileOk', fmtNum(s.read)) + (s.format === 'auto' && s.detected ? t('fileDetected', t('fmt_' + s.detected)) : ''), 'good'];
|
||||
if (s.open) {
|
||||
return [t('fileOk', fmtNum(s.read)) + (s.format === 'auto' && s.detected ? t('fileDetected', t('fmt_' + s.detected)) : '')
|
||||
+ (s.behind > 1 << 20 ? t('fileBehind', fmtNum(Math.round(s.behind / (1 << 20)))) : ''), 'good'];
|
||||
}
|
||||
return [t('hostWaiting'), 'muted'];
|
||||
}
|
||||
|
||||
@@ -2330,18 +2354,10 @@ function fileRowOf(key) {
|
||||
|
||||
async function saveFiles() {
|
||||
const ready = fileDrafts.filter((d) => d.name.trim() && d.path.trim());
|
||||
|
||||
const sources = filesState.sources.concat(ready).map(({ id, name, path, format, regex, host, enabled }) => ({ id: id || '', name, path, format, regex, host, enabled }));
|
||||
try {
|
||||
filesState = await api('/api/files', { method: 'PUT', body: { sources } });
|
||||
fileDrafts = fileDrafts.filter((d) => !ready.includes(d));
|
||||
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
await loadFiles();
|
||||
return;
|
||||
}
|
||||
renderFiles();
|
||||
renderFiles(true);
|
||||
setTimeout(loadFiles, 1500); // the files are read in the background
|
||||
}
|
||||
|
||||
@@ -2362,7 +2378,7 @@ $('#fileList').addEventListener('change', (ev) => {
|
||||
if (f === 'format') row.querySelector('.fr-regex').hidden = s.format !== 'regex';
|
||||
if (f === 'format' && s.format === 'regex' && !s.regex) { row.querySelector('.fr-regex').focus(); return; }
|
||||
if (s.draft && !(s.name && s.path)) return;
|
||||
saveFiles();
|
||||
settings.pending.files = true;
|
||||
});
|
||||
|
||||
$('#fileList').addEventListener('click', (ev) => {
|
||||
@@ -2375,7 +2391,8 @@ $('#fileList').addEventListener('click', (ev) => {
|
||||
return;
|
||||
}
|
||||
filesState.sources = filesState.sources.filter((s) => s.id !== key);
|
||||
saveFiles();
|
||||
settings.pending.files = true;
|
||||
renderFiles(true);
|
||||
});
|
||||
|
||||
$('#addFile').addEventListener('click', () => {
|
||||
@@ -2393,9 +2410,18 @@ const LOCK_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11
|
||||
|
||||
async function loadDocker() {
|
||||
try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; }
|
||||
applyDockerPending();
|
||||
renderDocker();
|
||||
}
|
||||
|
||||
// Unsaved choices stay shown over the state refreshed from the server.
|
||||
function applyDockerPending() {
|
||||
const p = settings.pending.docker;
|
||||
if (!p || !dockerState) return;
|
||||
if ('defaultEnabled' in p) dockerState.defaultEnabled = p.defaultEnabled;
|
||||
for (const c of dockerState.containers || []) if (c.key in p.containers) c.enabled = p.containers[c.key];
|
||||
}
|
||||
|
||||
// One label per choice key ("project/service"): replicas of a service share it.
|
||||
function dockerChips() {
|
||||
const byKey = new Map();
|
||||
@@ -2458,12 +2484,28 @@ function renderDocker() {
|
||||
: `<span class="chip-empty">${esc(t('dockerNoMatch'))}</span>`;
|
||||
}
|
||||
|
||||
async function configureDocker(body) {
|
||||
try {
|
||||
dockerState = await api('/api/docker', { method: 'PUT', body });
|
||||
// Kept until Save; a choice set back to its saved value is dropped.
|
||||
function configureDocker(change) {
|
||||
if (!dockerState) return;
|
||||
const orig = settings.orig.docker ??= {
|
||||
defaultEnabled: !!dockerState.defaultEnabled,
|
||||
containers: Object.fromEntries(dockerChips().map((c) => [c.key, !!c.enabled])),
|
||||
};
|
||||
const p = settings.pending.docker || { containers: {} };
|
||||
if ('defaultEnabled' in change) {
|
||||
if (change.defaultEnabled === orig.defaultEnabled) delete p.defaultEnabled;
|
||||
else p.defaultEnabled = change.defaultEnabled;
|
||||
}
|
||||
for (const [k, on] of Object.entries(change.containers || {})) {
|
||||
if (orig.containers[k] === on) delete p.containers[k];
|
||||
else p.containers[k] = on;
|
||||
}
|
||||
if ('defaultEnabled' in p || Object.keys(p.containers).length) settings.pending.docker = p;
|
||||
else { delete settings.pending.docker; delete settings.orig.docker; }
|
||||
// Shown at once: the change itself, even when it brings back the saved value.
|
||||
if ('defaultEnabled' in change) dockerState.defaultEnabled = change.defaultEnabled;
|
||||
for (const c of dockerState.containers || []) if (c.key in (change.containers || {})) c.enabled = change.containers[c.key];
|
||||
renderDocker();
|
||||
setTimeout(loadDocker, 1500); // followers start or stop in the background
|
||||
} catch (e) { toast(e.message); loadDocker(); }
|
||||
}
|
||||
|
||||
$('#dockerDefault').addEventListener('change', (ev) => configureDocker({ defaultEnabled: ev.target.checked }));
|
||||
@@ -2476,12 +2518,7 @@ $('#dockerFilter').addEventListener('input', (ev) => { dockerView.filter = ev.ta
|
||||
$('#dockerList').addEventListener('click', (ev) => {
|
||||
const chip = ev.target.closest('.chip[data-key]');
|
||||
if (!chip || chip.disabled) return;
|
||||
const key = chip.dataset.key;
|
||||
const on = !chip.classList.contains('on');
|
||||
// Immediate feedback, confirmed by the server answer.
|
||||
for (const c of (dockerState && dockerState.containers) || []) if (c.key === key) c.enabled = on;
|
||||
renderDocker();
|
||||
configureDocker({ containers: { [key]: on } });
|
||||
configureDocker({ containers: { [chip.dataset.key]: !chip.classList.contains('on') } });
|
||||
});
|
||||
for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) {
|
||||
$(id).addEventListener('click', () => {
|
||||
@@ -2508,6 +2545,7 @@ setInterval(() => {
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
|
||||
/* ================= Database statistics ================= */
|
||||
|
||||
// 1536 -> "1,5 Ko" / "1.5 KB" (binary multiples, the usual reading for disk sizes).
|
||||
@@ -2703,19 +2741,10 @@ function filterTagList() {
|
||||
}
|
||||
$('#tagFilter').addEventListener('input', filterTagList);
|
||||
|
||||
const saveTimers = {};
|
||||
function scheduleSave(tag, rowEl) {
|
||||
clearTimeout(saveTimers[tag.id]);
|
||||
saveTimers[tag.id] = setTimeout(async () => {
|
||||
const errEl = rowEl.querySelector('.err');
|
||||
try {
|
||||
await api('/api/tags/' + encodeURIComponent(tag.id), { method: 'PUT', body: tag });
|
||||
errEl.textContent = '';
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
// Tags are edited here and sent as a whole list by Save (PUT /api/tags); a new
|
||||
// tag gets its id and code from the server then.
|
||||
let tagSeq = 0;
|
||||
const newTag = (fields) => ({ code: '', label: '', wholeWord: false, caseSensitive: false, regex: false, enabled: true, ...fields, id: 'new-' + (++tagSeq) });
|
||||
|
||||
const applyTags = debounce(() => { compileMatchers(); rehighlight(); }, 150);
|
||||
|
||||
@@ -2730,52 +2759,43 @@ $('#tagList').addEventListener('input', (ev) => {
|
||||
const mark = rowEl.querySelector('.preview mark');
|
||||
mark.textContent = tag.label || tag.pattern || t('preview');
|
||||
mark.setAttribute('style', tagStyle(tag.color));
|
||||
scheduleSave(tag, rowEl);
|
||||
rowEl.querySelector('.err').textContent = '';
|
||||
applyTags();
|
||||
});
|
||||
|
||||
$('#tagList').addEventListener('click', async (ev) => {
|
||||
$('#tagList').addEventListener('click', (ev) => {
|
||||
const del = ev.target.closest('[data-del]');
|
||||
if (!del) return;
|
||||
const rowEl = del.closest('.tag-row');
|
||||
const tag = state.tags.find((x) => x.id === rowEl.dataset.id);
|
||||
if (!tag || !confirm(t('confirmDelete', tag.pattern))) return;
|
||||
try {
|
||||
await api('/api/tags/' + encodeURIComponent(tag.id), { method: 'DELETE' });
|
||||
state.tags = state.tags.filter((x) => x !== tag);
|
||||
renderTagList();
|
||||
applyTags();
|
||||
} catch (e) { toast(e.message); }
|
||||
renderSaveState();
|
||||
});
|
||||
|
||||
$('#addTag').addEventListener('click', async () => {
|
||||
const color = PALETTE[state.tags.length % PALETTE.length];
|
||||
try {
|
||||
const tag = await api('/api/tags', { method: 'POST', body: { pattern: t('newTag'), color, wholeWord: true, enabled: true } });
|
||||
state.tags.push(tag);
|
||||
$('#addTag').addEventListener('click', () => {
|
||||
state.tags.push(newTag({ pattern: t('newTag'), color: PALETTE[state.tags.length % PALETTE.length], wholeWord: true }));
|
||||
$('#tagFilter').value = '';
|
||||
renderTagList();
|
||||
applyTags();
|
||||
const input = $('#tagList').lastElementChild.querySelector('input[type="text"]');
|
||||
input.focus();
|
||||
input.select();
|
||||
} catch (e) { toast(e.message); }
|
||||
});
|
||||
|
||||
// Adds a preset group, skipping tags whose pattern is already in the list.
|
||||
$('#presetTags').addEventListener('change', async (ev) => {
|
||||
$('#presetTags').addEventListener('change', (ev) => {
|
||||
const preset = state.presets.flatMap((g) => g.presets).find((p) => p.id === ev.target.value);
|
||||
ev.target.value = '';
|
||||
if (!preset) return;
|
||||
let added = 0;
|
||||
try {
|
||||
for (const p of preset.tags) {
|
||||
if (state.tags.some((x) => x.pattern === p.pattern)) continue;
|
||||
const tag = { ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true };
|
||||
state.tags.push(await api('/api/tags', { method: 'POST', body: tag }));
|
||||
state.tags.push(newTag({ ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true }));
|
||||
added++;
|
||||
}
|
||||
} catch (e) { toast(e.message); }
|
||||
renderTagList();
|
||||
applyTags();
|
||||
toast(t('presetAdded', added));
|
||||
@@ -2784,13 +2804,16 @@ $('#presetTags').addEventListener('change', async (ev) => {
|
||||
$('#resetTags').addEventListener('click', async () => {
|
||||
if (!confirm(t('confirmReset'))) return;
|
||||
try {
|
||||
state.tags = await api('/api/tags/reset', { method: 'POST' });
|
||||
state.tags = await api('/api/tags/defaults');
|
||||
renderTagList();
|
||||
applyTags();
|
||||
} catch (e) { toast(e.message); }
|
||||
renderSaveState();
|
||||
});
|
||||
|
||||
$('#settingsBtn').addEventListener('click', () => {
|
||||
settingsBaseline();
|
||||
renderSaveState();
|
||||
renderTimeSettings();
|
||||
renderTagList();
|
||||
loadPresets();
|
||||
@@ -2808,8 +2831,156 @@ $('#settingsBtn').addEventListener('click', () => {
|
||||
showSettingsTab(store.get('settingsTab', 'locale'));
|
||||
$('#settingsDlg').showModal();
|
||||
});
|
||||
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
|
||||
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
|
||||
/* ================= Settings: Save / Cancel ================= */
|
||||
|
||||
// Browser preferences of the Settings window (stored in localStorage by Save).
|
||||
function localPrefs() {
|
||||
return {
|
||||
lang, theme: document.documentElement.dataset.theme || 'system',
|
||||
font: ui.font, size: ui.size, density: ui.density, tz: timePrefs.tz, fmt: timePrefs.fmt, histo: { ...histoPrefs },
|
||||
};
|
||||
}
|
||||
|
||||
const tagFields = ({ id, pattern, label, color, wholeWord, caseSensitive, regex, enabled }) =>
|
||||
({ id, pattern, label: label || '', color, wholeWord: !!wholeWord, caseSensitive: !!caseSensitive, regex: !!regex, enabled: !!enabled });
|
||||
const tagsJSON = (tags) => JSON.stringify(tags.map(tagFields));
|
||||
|
||||
// What Cancel goes back to: the state when the window was opened or last saved.
|
||||
function settingsBaseline() {
|
||||
settings.saved = localPrefs();
|
||||
settings.tags = structuredClone(state.tags);
|
||||
settings.pending = {};
|
||||
settings.orig = {};
|
||||
}
|
||||
|
||||
function settingsDirty() {
|
||||
return JSON.stringify(localPrefs()) !== JSON.stringify(settings.saved)
|
||||
|| tagsJSON(state.tags) !== tagsJSON(settings.tags)
|
||||
|| Object.keys(settings.pending).length > 0;
|
||||
}
|
||||
|
||||
function renderSaveState() {
|
||||
if (!settings.saved) return;
|
||||
const dirty = settingsDirty();
|
||||
$('#setActions').classList.toggle('dirty', dirty);
|
||||
$('#setSave').disabled = !dirty || settings.saving;
|
||||
$('#setCancel').disabled = !dirty || settings.saving;
|
||||
$('#setState').textContent = t(dirty ? 'settingsDirty' : 'settingsClean');
|
||||
}
|
||||
|
||||
// Each source sends its whole setting, as edited.
|
||||
const SOURCE_SAVE = {
|
||||
syslog: async () => { syslogState = await api('/api/syslog', { method: 'PUT', body: { enabled: syslogState.enabled, udp: syslogState.udp, tcp: syslogState.tcp } }); },
|
||||
host: async () => { hostState = await api('/api/hostlogs', { method: 'PUT', body: { enabled: hostState.enabled } }); },
|
||||
dmesg: async () => { dmesgState = await api('/api/dmesg', { method: 'PUT', body: { enabled: dmesgState.enabled } }); },
|
||||
audit: async () => { auditState = await api('/api/audit', { method: 'PUT', body: { enabled: !!auditState.enabled, path: auditState.path || '' } }); },
|
||||
selflog: async () => { selfState = await api('/api/selflog', { method: 'PUT', body: { enabled: selfState.enabled, min_level: selfState.min_level || 'warning' } }); },
|
||||
docker: async () => { dockerState = await api('/api/docker', { method: 'PUT', body: settings.pending.docker }); },
|
||||
files: saveFiles,
|
||||
};
|
||||
|
||||
async function saveSettings() {
|
||||
settings.saving = true;
|
||||
renderSaveState();
|
||||
const p = localPrefs();
|
||||
for (const [k, v] of [['lang', p.lang], ['theme', p.theme], ['logFont', p.font], ['logSize', p.size], ['logDensity', p.density], ['tz', p.tz], ['timefmt', p.fmt]]) store.set(k, v);
|
||||
for (const [k, v] of Object.entries(p.histo)) store.set('histo.' + k, v);
|
||||
settings.saved = p;
|
||||
|
||||
const errors = [];
|
||||
if (tagsJSON(state.tags) !== tagsJSON(settings.tags)) {
|
||||
try {
|
||||
state.tags = await api('/api/tags', { method: 'PUT', body: state.tags.map(tagFields) });
|
||||
settings.tags = structuredClone(state.tags);
|
||||
renderTagList();
|
||||
applyTags();
|
||||
} catch (e) {
|
||||
errors.push(e.message);
|
||||
const row = $('#tagList').querySelectorAll('.tag-row')[e.data?.index];
|
||||
if (row) {
|
||||
showSettingsTab('filters');
|
||||
row.hidden = false;
|
||||
row.querySelector('.err').textContent = e.message;
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(settings.pending)) {
|
||||
try {
|
||||
await SOURCE_SAVE[key]();
|
||||
delete settings.pending[key];
|
||||
delete settings.orig[key];
|
||||
} catch (e) { errors.push(e.message); }
|
||||
}
|
||||
// Sources start or stop in the background: show their state a bit later.
|
||||
setTimeout(reloadSources, 1500);
|
||||
reloadSources();
|
||||
settings.saving = false;
|
||||
renderSaveState();
|
||||
toast(errors.length ? errors.join(' · ') : t('settingsSaved'));
|
||||
}
|
||||
|
||||
function reloadSources() {
|
||||
loadSyslog();
|
||||
loadHost();
|
||||
loadDmesg();
|
||||
loadAudit();
|
||||
loadSelfLog();
|
||||
loadFiles(true);
|
||||
loadDocker();
|
||||
}
|
||||
|
||||
function cancelSettings() {
|
||||
const p = settings.saved;
|
||||
setLang(p.lang);
|
||||
setTheme(p.theme, false);
|
||||
applyLogFont(p.font);
|
||||
applyLogSize(p.size);
|
||||
applyLogDensity(p.density);
|
||||
if (p.tz !== timePrefs.tz || p.fmt !== timePrefs.fmt) {
|
||||
Object.assign(timePrefs, { tz: p.tz, fmt: p.fmt });
|
||||
renderList();
|
||||
renderPeriod();
|
||||
refreshHisto();
|
||||
}
|
||||
for (const [k, v] of Object.entries(p.histo)) if (histoPrefs[k] !== v) setHistoPref(k, v);
|
||||
renderInterface();
|
||||
renderTimeSettings();
|
||||
|
||||
state.tags = structuredClone(settings.tags);
|
||||
renderTagList();
|
||||
applyTags();
|
||||
|
||||
settings.pending = {};
|
||||
settings.orig = {};
|
||||
fileDrafts = [];
|
||||
reloadSources();
|
||||
renderSaveState();
|
||||
}
|
||||
|
||||
// Closing with unsaved changes asks first, then drops them.
|
||||
function closeSettings() {
|
||||
if (settingsDirty()) {
|
||||
if (!confirm(t('unsavedConfirm'))) return;
|
||||
cancelSettings();
|
||||
}
|
||||
$('#settingsDlg').close();
|
||||
}
|
||||
|
||||
$('#setSave').addEventListener('click', saveSettings);
|
||||
$('#setCancel').addEventListener('click', cancelSettings);
|
||||
$('#closeSettings').addEventListener('click', closeSettings);
|
||||
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) closeSettings(); });
|
||||
$('#settingsDlg').addEventListener('cancel', (ev) => { ev.preventDefault(); closeSettings(); }); // Escape
|
||||
// Closed anyway (a second Escape in some browsers): the changes are not kept.
|
||||
$('#settingsDlg').addEventListener('close', (ev) => { if (!ev.currentTarget.open && settingsDirty()) cancelSettings(); });
|
||||
// Every control of the window: refresh the Save button once the change is applied.
|
||||
for (const type of ['input', 'change', 'click']) {
|
||||
$('#settingsDlg').addEventListener(type, () => setTimeout(renderSaveState));
|
||||
}
|
||||
addEventListener('beforeunload', (ev) => {
|
||||
if ($('#settingsDlg').open && settingsDirty()) ev.preventDefault();
|
||||
});
|
||||
|
||||
/* ================= Startup ================= */
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
|
||||
<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>
|
||||
@@ -174,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>
|
||||
@@ -196,6 +198,15 @@
|
||||
<span data-i18n="tabData">Database</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 -->
|
||||
|
||||
+58
-27
@@ -22,6 +22,10 @@
|
||||
--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) */
|
||||
@@ -74,6 +78,10 @@
|
||||
--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;
|
||||
@@ -110,10 +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-file: #9ccc54;
|
||||
--src-tint: 9%;
|
||||
--sev-crit: #fb7185;
|
||||
--sev-err: #f87171;
|
||||
@@ -344,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);
|
||||
@@ -379,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; }
|
||||
@@ -410,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); }
|
||||
@@ -424,16 +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-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);
|
||||
}
|
||||
@@ -448,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); }
|
||||
@@ -544,11 +559,24 @@ 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: minmax(176px, max-content) minmax(0, 1fr); }
|
||||
.set-nav {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 12px 10px; border-right: 1px solid var(--border);
|
||||
.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;
|
||||
@@ -576,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; }
|
||||
@@ -814,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; }
|
||||
@@ -831,10 +861,11 @@ 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;
|
||||
|
||||
Reference in new issue
Block a user