diff --git a/README.fr.md b/README.fr.md index 2aa181f..6d0505b 100644 --- a/README.fr.md +++ b/README.fr.md @@ -386,8 +386,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. diff --git a/README.md b/README.md index 1b7d7e3..7b928c9 100644 --- a/README.md +++ b/README.md @@ -351,8 +351,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. diff --git a/api.go b/api.go index d961e35..5bd2de5 100644 --- a/api.go +++ b/api.go @@ -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 { diff --git a/tags.go b/tags.go index f6e225b..0735f9d 100644 --- a/tags.go +++ b/tags.go @@ -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() diff --git a/tags_test.go b/tags_test.go index 57a62f7..8d9a7c4 100644 --- a/tags_test.go +++ b/tags_test.go @@ -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) + } +} diff --git a/web/app.js b/web/app.js index 77d8fbe..e4d7935 100644 --- a/web/app.js +++ b/web/app.js @@ -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…', @@ -268,13 +271,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…', @@ -585,7 +591,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 +624,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 +660,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 +695,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 +715,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 +806,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 +1768,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 +1978,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 +2045,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 +2084,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 +2103,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 +2130,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 +2143,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 +2167,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 +2180,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 +2207,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 +2221,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 +2237,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 +2254,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 @@ -2330,18 +2346,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(); + filesState = await api('/api/files', { method: 'PUT', body: { sources } }); + fileDrafts = fileDrafts.filter((d) => !ready.includes(d)); + renderFiles(true); 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' && 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 +2383,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 +2402,18 @@ const LOCK_ICON = '
+
+
+ + +

+
+
diff --git a/web/style.css b/web/style.css index 52ac9ba..e06aa1b 100644 --- a/web/style.css +++ b/web/style.css @@ -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;