Settings: Save and Cancel buttons instead of saving each change

Changes made in the Settings window show at once but are only kept with
the Save button, placed under the tab labels; it takes the accent color
while changes are waiting. Cancel goes back to the saved settings, and
closing the window (or the page) with unsaved changes asks first.

Tags are now sent as a whole list (PUT /api/tags, nothing saved when one
is invalid, the error gives its position) and "Restore default tags"
shows GET /api/tags/defaults until Save.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-10-05 11:03:01 +02:00
1 parent dcb09e619e
commit 194edee6de
8 files changed
+442 -130

No files matched your search

+7 -2
View File
@@ -386,8 +386,13 @@ En ligne de commande : `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&
## Paramètres ## Paramètres
L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Tout, sauf les tags de L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Les modifications
couleur, est mémorisé par navigateur. 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** - **Localisation et langue**
- *Langue* : anglais ou français. - *Langue* : anglais ou français.
+5 -2
View File
@@ -351,8 +351,11 @@ From the command line: `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&
## Settings ## Settings
The gear icon opens the settings, organized in tabs. Everything except color tags is The gear icon opens the settings, organized in tabs. Changes show at once but are only kept
remembered per browser. 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** - **Localization**
- *Language*: English or French. - *Language*: English or French.
+37
View File
@@ -40,6 +40,8 @@ func (a *API) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/dbstats", a.dbStats) mux.HandleFunc("GET /api/dbstats", a.dbStats)
mux.HandleFunc("GET /api/tags", a.listTags) mux.HandleFunc("GET /api/tags", a.listTags)
mux.HandleFunc("POST /api/tags", a.createTag) 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("POST /api/tags/reset", a.resetTags)
mux.HandleFunc("GET /api/presets", a.listPresets) mux.HandleFunc("GET /api/presets", a.listPresets)
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag) 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}) 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) { func (a *API) resetTags(w http.ResponseWriter, r *http.Request) {
tags, err := a.tags.Reset() tags, err := a.tags.Reset()
if err != nil { if err != nil {
+45
View File
@@ -217,6 +217,51 @@ func (s *TagStore) Delete(id string) error {
return errTagNotFound 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) { func (s *TagStore) Reset() ([]Tag, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
+35
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -52,3 +53,37 @@ func TestTagCodes(t *testing.T) {
t.Fatalf("reloaded code = %q, want 07", got) 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)
}
}
+282 -119
View File
@@ -40,13 +40,16 @@ const I18N = {
copyMsg: 'Copy message', copyJson: 'Copy as JSON', copied: 'Copied', copyMsg: 'Copy message', copyJson: 'Copy as JSON', copied: 'Copied',
language: 'Language', language: 'Language',
tagsTitle: 'Color tags', 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', addTag: '+ Add tag', resetTags: 'Restore default tags',
optWhole: 'whole word', optWholeTitle: 'Only color whole words (avoids "ok" in "token")', optWhole: 'whole word', optWholeTitle: 'Only color whole words (avoids "ok" in "token")',
optCaseTitle: 'Match case', optRegexTitle: 'Regular expression', optCaseTitle: 'Match case', optRegexTitle: 'Regular expression',
optActive: 'active', optActiveTitle: 'Enable this tag', optActive: 'active', optActiveTitle: 'Enable this tag',
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.', colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
newTag: 'new', 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}"?`, confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error)?', confirmReset: 'Replace all tags with the defaults (warning, error)?',
presetAria: 'Add a preset', presetPick: '+ Preset…', presetAria: 'Add a preset', presetPick: '+ Preset…',
@@ -268,13 +271,16 @@ const I18N = {
copyMsg: 'Copier le message', copyJson: 'Copier en JSON', copied: 'Copié', copyMsg: 'Copier le message', copyJson: 'Copier en JSON', copied: 'Copié',
language: 'Langue', language: 'Langue',
tagsTitle: 'Tags de couleur', 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', 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 »)', optWhole: 'mot entier', optWholeTitle: 'Ne colorer que les mots entiers (évite « ok » dans « token »)',
optCaseTitle: 'Respecter la casse', optRegexTitle: 'Expression régulière', optCaseTitle: 'Respecter la casse', optRegexTitle: 'Expression régulière',
optActive: 'actif', optActiveTitle: 'Activer ce tag', optActive: 'actif', optActiveTitle: 'Activer ce tag',
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.', colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
newTag: 'nouveau', 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} » ?`, confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?', confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?',
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…', presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
@@ -585,7 +591,7 @@ function apiError(res, text, data) {
if (data && data.code && I18N[lang]['err_' + data.code]) { if (data && data.code && I18N[lang]['err_' + data.code]) {
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : ''); 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) { function toast(msg) {
@@ -618,6 +624,26 @@ const state = {
}; };
const recOf = new WeakMap(); // .row element -> record 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'); const list = $('#list');
/* ================= Language ================= */ /* ================= Language ================= */
@@ -634,7 +660,6 @@ function applyLang() {
function setLang(next) { function setLang(next) {
if (next === lang || !I18N[next]) return; if (next === lang || !I18N[next]) return;
lang = next; lang = next;
store.set('lang', lang);
applyLang(); applyLang();
syncControls(); syncControls();
setConn(...state.conn); setConn(...state.conn);
@@ -670,11 +695,12 @@ function effectiveTheme() {
if (th) return th; if (th) return th;
return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} }
// mode: 'system' (follow the OS), 'light' or 'dark'. // mode: 'system' (follow the OS), 'light' or 'dark'. In Settings the theme is
function setTheme(mode) { // only stored by the Save button (persist = false).
function setTheme(mode, persist = true) {
if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode; if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode;
else { delete document.documentElement.dataset.theme; mode = 'system'; } else { delete document.documentElement.dataset.theme; mode = 'system'; }
store.set('theme', mode); if (persist) store.set('theme', mode);
renderThemeSwitch(); renderThemeSwitch();
} }
@@ -689,7 +715,7 @@ function renderThemeSwitch() {
$('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark')); $('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark'));
$('#themeSwitch').addEventListener('click', (ev) => { $('#themeSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-theme-opt]'); 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 ================= */ /* ================= Log font and size ================= */
@@ -780,20 +806,15 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-size]'); const b = ev.target.closest('[data-size]');
if (!b) return; if (!b) return;
applyLogSize(b.dataset.size); applyLogSize(b.dataset.size);
store.set('logSize', ui.size);
renderInterface(); renderInterface();
}); });
$('#densitySwitch').addEventListener('click', (ev) => { $('#densitySwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-density]'); const b = ev.target.closest('[data-density]');
if (!b) return; if (!b) return;
applyLogDensity(b.dataset.density); applyLogDensity(b.dataset.density);
store.set('logDensity', ui.density);
renderInterface(); renderInterface();
}); });
$('#fontSelect').addEventListener('change', (ev) => { $('#fontSelect').addEventListener('change', (ev) => applyLogFont(ev.target.value));
applyLogFont(ev.target.value);
store.set('logFont', ui.font);
});
/* ================= Settings tabs ================= */ /* ================= Settings tabs ================= */
@@ -1747,7 +1768,6 @@ function renderHistoSettings() {
function setHistoPref(k, v) { function setHistoPref(k, v) {
if (!HISTO_OPTS[k] || !HISTO_OPTS[k].includes(v)) return; if (!HISTO_OPTS[k] || !HISTO_OPTS[k].includes(v)) return;
histoPrefs[k] = v; histoPrefs[k] = v;
store.set('histo.' + k, v);
renderHistoSettings(); renderHistoSettings();
if (k === 'step') refreshHisto(); if (k === 'step') refreshHisto();
else if (k === 'refresh') { scheduleHisto(); if (v !== 'off') refreshHisto(); } else if (k === 'refresh') { scheduleHisto(); if (v !== 'off') refreshHisto(); }
@@ -1958,8 +1978,6 @@ function updateTimePreview() {
function onTimePrefsChange() { function onTimePrefsChange() {
timePrefs.tz = $('#tzSelect').value; timePrefs.tz = $('#tzSelect').value;
timePrefs.fmt = $('#fmtSelect').value; timePrefs.fmt = $('#fmtSelect').value;
store.set('tz', timePrefs.tz);
store.set('timefmt', timePrefs.fmt);
updateTimePreview(); updateTimePreview();
renderList(); renderList();
renderPeriod(); renderPeriod();
@@ -2027,7 +2045,7 @@ document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExpor
let syslogState = null; let syslogState = null;
async function loadSyslog() { async function loadSyslog() {
try { syslogState = await api('/api/syslog'); } catch { syslogState = null; } try { syslogState = withPending('syslog', await api('/api/syslog')); } catch { syslogState = null; }
renderSyslog(); renderSyslog();
} }
@@ -2066,12 +2084,10 @@ function renderSyslog() {
} }
} }
async function configureSyslog(change) { function configureSyslog(change) {
const s = syslogState || { enabled: true, udp: true, tcp: true }; const s = syslogState || { enabled: true, udp: true, tcp: true };
const body = { enabled: s.enabled, udp: s.udp, tcp: s.tcp, ...change }; pend('syslog', change, s);
syslogState = { ...s, ...body }; syslogState = { ...s, ...change };
renderSyslog();
try { syslogState = await api('/api/syslog', { method: 'PUT', body }); } catch (e) { toast(e.message); }
renderSyslog(); renderSyslog();
} }
@@ -2087,7 +2103,7 @@ $('#syslogProtos').addEventListener('click', (ev) => {
let hostState = null; let hostState = null;
async function loadHost() { async function loadHost() {
try { hostState = await api('/api/hostlogs'); } catch { hostState = null; } try { hostState = withPending('host', await api('/api/hostlogs')); } catch { hostState = null; }
renderHost(); renderHost();
} }
@@ -2114,12 +2130,10 @@ function renderHost() {
status.className = 'docker-status ' + cls; status.className = 'docker-status ' + cls;
} }
async function configureHost(enabled) { function configureHost(enabled) {
pend('host', { enabled }, hostState || {});
hostState = { ...(hostState || {}), enabled }; hostState = { ...(hostState || {}), enabled };
renderHost(); 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)); $('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.checked));
@@ -2129,7 +2143,7 @@ $('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.che
let dmesgState = null; let dmesgState = null;
async function loadDmesg() { async function loadDmesg() {
try { dmesgState = await api('/api/dmesg'); } catch { dmesgState = null; } try { dmesgState = withPending('dmesg', await api('/api/dmesg')); } catch { dmesgState = null; }
renderDmesg(); renderDmesg();
} }
@@ -2153,12 +2167,10 @@ function renderDmesg() {
status.className = 'docker-status ' + cls; status.className = 'docker-status ' + cls;
} }
async function configureDmesg(enabled) { function configureDmesg(enabled) {
pend('dmesg', { enabled }, dmesgState || {});
dmesgState = { ...(dmesgState || {}), enabled }; dmesgState = { ...(dmesgState || {}), enabled };
renderDmesg(); 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)); $('#dmesgEnabled').addEventListener('change', (ev) => configureDmesg(ev.target.checked));
@@ -2168,7 +2180,7 @@ $('#dmesgEnabled').addEventListener('change', (ev) => configureDmesg(ev.target.c
let auditState = null; let auditState = null;
async function loadAudit() { async function loadAudit() {
try { auditState = await api('/api/audit'); } catch { auditState = null; } try { auditState = withPending('audit', await api('/api/audit')); } catch { auditState = null; }
renderAudit(); renderAudit();
} }
@@ -2195,13 +2207,10 @@ function renderAudit() {
status.className = 'docker-status ' + cls; status.className = 'docker-status ' + cls;
} }
async function configureAudit(change) { function configureAudit(change) {
const body = { enabled: auditState?.enabled || false, path: auditState?.path || '', ...change }; pend('audit', change, { enabled: false, path: '', ...auditState });
auditState = { ...(auditState || {}), ...body }; auditState = { ...(auditState || {}), ...change };
renderAudit(); 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 })); $('#auditEnabled').addEventListener('change', (ev) => configureAudit({ enabled: ev.target.checked }));
@@ -2212,7 +2221,7 @@ $('#auditPath').addEventListener('change', (ev) => configureAudit({ path: ev.tar
let selfState = null; let selfState = null;
async function loadSelfLog() { async function loadSelfLog() {
try { selfState = await api('/api/selflog'); } catch { selfState = null; } try { selfState = withPending('selflog', await api('/api/selflog')); } catch { selfState = null; }
renderSelfLog(); renderSelfLog();
} }
@@ -2228,11 +2237,9 @@ function renderSelfLog() {
status.className = 'docker-status ' + (s.enabled ? 'good' : 'muted'); status.className = 'docker-status ' + (s.enabled ? 'good' : 'muted');
} }
async function configureSelfLog(change) { function configureSelfLog(change) {
const body = { enabled: selfState?.enabled ?? true, min_level: selfState?.min_level || 'warning', ...change }; pend('selflog', change, { enabled: true, min_level: 'warning', ...selfState });
selfState = { ...(selfState || {}), ...body }; selfState = { ...(selfState || {}), ...change };
renderSelfLog();
try { selfState = await api('/api/selflog', { method: 'PUT', body }); } catch (e) { toast(e.message); }
renderSelfLog(); renderSelfLog();
} }
@@ -2247,9 +2254,18 @@ let fileDrafts = []; // rows added here, saved once they have a name and a pat
let fileSeq = 0; let fileSeq = 0;
const fileProbes = new Map(); // path -> result of /api/files/detect (format found, last line) const fileProbes = new Map(); // path -> result of /api/files/detect (format found, last line)
async function loadFiles() { const FILE_FIELDS = ['id', 'name', 'path', 'format', 'regex', 'host', 'enabled'];
try { filesState = await api('/api/files'); } catch { filesState = null; }
renderFiles(); // 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 // Status line of a row: the check of the path just typed, else the state
@@ -2330,18 +2346,10 @@ function fileRowOf(key) {
async function saveFiles() { async function saveFiles() {
const ready = fileDrafts.filter((d) => d.name.trim() && d.path.trim()); 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 })); 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 } });
filesState = await api('/api/files', { method: 'PUT', body: { sources } }); fileDrafts = fileDrafts.filter((d) => !ready.includes(d));
fileDrafts = fileDrafts.filter((d) => !ready.includes(d)); renderFiles(true);
} catch (e) {
toast(e.message);
await loadFiles();
return;
}
renderFiles();
setTimeout(loadFiles, 1500); // the files are read in the background setTimeout(loadFiles, 1500); // the files are read in the background
} }
@@ -2362,7 +2370,7 @@ $('#fileList').addEventListener('change', (ev) => {
if (f === 'format') row.querySelector('.fr-regex').hidden = s.format !== 'regex'; 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 (f === 'format' && s.format === 'regex' && !s.regex) { row.querySelector('.fr-regex').focus(); return; }
if (s.draft && !(s.name && s.path)) return; if (s.draft && !(s.name && s.path)) return;
saveFiles(); settings.pending.files = true;
}); });
$('#fileList').addEventListener('click', (ev) => { $('#fileList').addEventListener('click', (ev) => {
@@ -2375,7 +2383,8 @@ $('#fileList').addEventListener('click', (ev) => {
return; return;
} }
filesState.sources = filesState.sources.filter((s) => s.id !== key); filesState.sources = filesState.sources.filter((s) => s.id !== key);
saveFiles(); settings.pending.files = true;
renderFiles(true);
}); });
$('#addFile').addEventListener('click', () => { $('#addFile').addEventListener('click', () => {
@@ -2393,9 +2402,18 @@ const LOCK_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11
async function loadDocker() { async function loadDocker() {
try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; } try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; }
applyDockerPending();
renderDocker(); 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. // One label per choice key ("project/service"): replicas of a service share it.
function dockerChips() { function dockerChips() {
const byKey = new Map(); const byKey = new Map();
@@ -2458,12 +2476,28 @@ function renderDocker() {
: `<span class="chip-empty">${esc(t('dockerNoMatch'))}</span>`; : `<span class="chip-empty">${esc(t('dockerNoMatch'))}</span>`;
} }
async function configureDocker(body) { // Kept until Save; a choice set back to its saved value is dropped.
try { function configureDocker(change) {
dockerState = await api('/api/docker', { method: 'PUT', body }); if (!dockerState) return;
renderDocker(); const orig = settings.orig.docker ??= {
setTimeout(loadDocker, 1500); // followers start or stop in the background defaultEnabled: !!dockerState.defaultEnabled,
} catch (e) { toast(e.message); loadDocker(); } 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();
} }
$('#dockerDefault').addEventListener('change', (ev) => configureDocker({ defaultEnabled: ev.target.checked })); $('#dockerDefault').addEventListener('change', (ev) => configureDocker({ defaultEnabled: ev.target.checked }));
@@ -2476,12 +2510,7 @@ $('#dockerFilter').addEventListener('input', (ev) => { dockerView.filter = ev.ta
$('#dockerList').addEventListener('click', (ev) => { $('#dockerList').addEventListener('click', (ev) => {
const chip = ev.target.closest('.chip[data-key]'); const chip = ev.target.closest('.chip[data-key]');
if (!chip || chip.disabled) return; if (!chip || chip.disabled) return;
const key = chip.dataset.key; configureDocker({ containers: { [chip.dataset.key]: !chip.classList.contains('on') } });
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 } });
}); });
for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) { for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) {
$(id).addEventListener('click', () => { $(id).addEventListener('click', () => {
@@ -2508,6 +2537,7 @@ setInterval(() => {
} }
}, 4000); }, 4000);
/* ================= Database statistics ================= */ /* ================= Database statistics ================= */
// 1536 -> "1,5 Ko" / "1.5 KB" (binary multiples, the usual reading for disk sizes). // 1536 -> "1,5 Ko" / "1.5 KB" (binary multiples, the usual reading for disk sizes).
@@ -2703,19 +2733,10 @@ function filterTagList() {
} }
$('#tagFilter').addEventListener('input', filterTagList); $('#tagFilter').addEventListener('input', filterTagList);
const saveTimers = {}; // Tags are edited here and sent as a whole list by Save (PUT /api/tags); a new
function scheduleSave(tag, rowEl) { // tag gets its id and code from the server then.
clearTimeout(saveTimers[tag.id]); let tagSeq = 0;
saveTimers[tag.id] = setTimeout(async () => { const newTag = (fields) => ({ code: '', label: '', wholeWord: false, caseSensitive: false, regex: false, enabled: true, ...fields, id: 'new-' + (++tagSeq) });
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);
}
const applyTags = debounce(() => { compileMatchers(); rehighlight(); }, 150); const applyTags = debounce(() => { compileMatchers(); rehighlight(); }, 150);
@@ -2730,52 +2751,43 @@ $('#tagList').addEventListener('input', (ev) => {
const mark = rowEl.querySelector('.preview mark'); const mark = rowEl.querySelector('.preview mark');
mark.textContent = tag.label || tag.pattern || t('preview'); mark.textContent = tag.label || tag.pattern || t('preview');
mark.setAttribute('style', tagStyle(tag.color)); mark.setAttribute('style', tagStyle(tag.color));
scheduleSave(tag, rowEl); rowEl.querySelector('.err').textContent = '';
applyTags(); applyTags();
}); });
$('#tagList').addEventListener('click', async (ev) => { $('#tagList').addEventListener('click', (ev) => {
const del = ev.target.closest('[data-del]'); const del = ev.target.closest('[data-del]');
if (!del) return; if (!del) return;
const rowEl = del.closest('.tag-row'); const rowEl = del.closest('.tag-row');
const tag = state.tags.find((x) => x.id === rowEl.dataset.id); const tag = state.tags.find((x) => x.id === rowEl.dataset.id);
if (!tag || !confirm(t('confirmDelete', tag.pattern))) return; if (!tag || !confirm(t('confirmDelete', tag.pattern))) return;
try { state.tags = state.tags.filter((x) => x !== tag);
await api('/api/tags/' + encodeURIComponent(tag.id), { method: 'DELETE' }); renderTagList();
state.tags = state.tags.filter((x) => x !== tag); applyTags();
renderTagList(); renderSaveState();
applyTags();
} catch (e) { toast(e.message); }
}); });
$('#addTag').addEventListener('click', async () => { $('#addTag').addEventListener('click', () => {
const color = PALETTE[state.tags.length % PALETTE.length]; state.tags.push(newTag({ pattern: t('newTag'), color: PALETTE[state.tags.length % PALETTE.length], wholeWord: true }));
try { $('#tagFilter').value = '';
const tag = await api('/api/tags', { method: 'POST', body: { pattern: t('newTag'), color, wholeWord: true, enabled: true } }); renderTagList();
state.tags.push(tag); applyTags();
$('#tagFilter').value = ''; const input = $('#tagList').lastElementChild.querySelector('input[type="text"]');
renderTagList(); input.focus();
applyTags(); input.select();
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. // 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); const preset = state.presets.flatMap((g) => g.presets).find((p) => p.id === ev.target.value);
ev.target.value = ''; ev.target.value = '';
if (!preset) return; if (!preset) return;
let added = 0; let added = 0;
try { for (const p of preset.tags) {
for (const p of preset.tags) { if (state.tags.some((x) => x.pattern === p.pattern)) continue;
if (state.tags.some((x) => x.pattern === p.pattern)) continue; state.tags.push(newTag({ ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true }));
const tag = { ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true }; added++;
state.tags.push(await api('/api/tags', { method: 'POST', body: tag })); }
added++;
}
} catch (e) { toast(e.message); }
renderTagList(); renderTagList();
applyTags(); applyTags();
toast(t('presetAdded', added)); toast(t('presetAdded', added));
@@ -2784,13 +2796,16 @@ $('#presetTags').addEventListener('change', async (ev) => {
$('#resetTags').addEventListener('click', async () => { $('#resetTags').addEventListener('click', async () => {
if (!confirm(t('confirmReset'))) return; if (!confirm(t('confirmReset'))) return;
try { try {
state.tags = await api('/api/tags/reset', { method: 'POST' }); state.tags = await api('/api/tags/defaults');
renderTagList(); renderTagList();
applyTags(); applyTags();
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
renderSaveState();
}); });
$('#settingsBtn').addEventListener('click', () => { $('#settingsBtn').addEventListener('click', () => {
settingsBaseline();
renderSaveState();
renderTimeSettings(); renderTimeSettings();
renderTagList(); renderTagList();
loadPresets(); loadPresets();
@@ -2808,8 +2823,156 @@ $('#settingsBtn').addEventListener('click', () => {
showSettingsTab(store.get('settingsTab', 'locale')); showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal(); $('#settingsDlg').showModal();
}); });
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close()); /* ================= Settings: Save / Cancel ================= */
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
// 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 ================= */ /* ================= Startup ================= */
+10
View File
@@ -174,6 +174,7 @@
</header> </header>
<div class="set-body"> <div class="set-body">
<div class="set-side">
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical"> <nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
<button type="button" role="tab" data-tab="locale"> <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> <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> <span data-i18n="tabData">Database</span>
</button> </button>
</nav> </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"> <div class="set-panels">
<!-- Localization --> <!-- Localization -->
+21 -7
View File
@@ -544,11 +544,24 @@ dialog.settings {
dialog.settings .dlg { display: flex; flex-direction: column; height: 100%; padding: 0; } 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); } 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-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(176px, max-content) minmax(0, 1fr); }
.set-nav { .set-side {
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; min-height: 0; overflow-y: auto;
padding: 12px 10px; border-right: 1px solid var(--border); border-right: 1px solid var(--border);
background: color-mix(in srgb, var(--panel-2) 50%, transparent); 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 { .set-nav button {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
height: 38px; padding: 0 12px; border: 0; border-radius: 9px; 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; } .field-grid { grid-template-columns: 1fr; gap: 4px; }
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); } dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } .set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.set-nav { .set-side { border-right: 0; border-bottom: 1px solid var(--border); overflow: visible; }
display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; .set-nav { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; }
border-right: 0; border-bottom: 1px solid var(--border); .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 { .set-nav button {
flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px; flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px;
font-size: 11.5px; text-align: center; font-size: 11.5px; text-align: center;