Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3b3ee23a | ||
|
|
194edee6de |
No files matched your search
+10
-3
@@ -342,7 +342,9 @@ conteneur, un **format** (*Automatique* par défaut), un **hôte** facultatif (p
|
||||
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 +388,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.
|
||||
|
||||
@@ -312,8 +312,10 @@ optional **host** (default: `/host/etc/hostname`, else `localhost`) and a switch
|
||||
- **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.
|
||||
- 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 +353,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)
|
||||
}
|
||||
}
|
||||
+272
-104
@@ -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.',
|
||||
@@ -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.',
|
||||
@@ -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 ================= */
|
||||
|
||||
@@ -1747,7 +1770,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 +1980,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 +2047,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 +2086,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 +2105,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 +2132,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 +2145,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 +2169,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 +2182,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 +2209,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 +2223,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 +2239,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 +2256,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 +2281,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 +2351,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 +2375,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 +2388,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 +2407,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 +2481,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 +2515,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 +2542,7 @@ setInterval(() => {
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
|
||||
/* ================= Database statistics ================= */
|
||||
|
||||
// 1536 -> "1,5 Ko" / "1.5 KB" (binary multiples, the usual reading for disk sizes).
|
||||
@@ -2703,19 +2738,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 +2756,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 +2801,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 +2828,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 ================= */
|
||||
|
||||
|
||||
@@ -174,6 +174,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 +197,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 -->
|
||||
|
||||
+21
-7
@@ -544,11 +544,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;
|
||||
@@ -831,10 +844,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