From 84e71f741c2100a4c4dfd74e10890f8349de7611 Mon Sep 17 00:00:00 2001 From: Cedric Date: Mon, 28 Sep 2026 16:13:53 +0200 Subject: [PATCH] Settings in tabs: localization, filters, interface, data - Tabbed settings dialog (side navigation, 4-column tabs on mobile); the last opened tab is remembered. - Interface: theme System/Light/Dark (System follows the OS preference), log font size (tiny, small, medium, large) and log font: system monospace or 12 free monospace fonts loaded from Bunny Fonts, with a live preview. Ligatures disabled in log rows. Co-Authored-By: Claude Opus 5.5 --- README.md | 31 ++++++++--- web/app.js | 137 +++++++++++++++++++++++++++++++++++++++++++++++-- web/index.html | 132 ++++++++++++++++++++++++++++++++++------------- web/style.css | 60 +++++++++++++++++++++- 4 files changed, 311 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 6b4005f..b58221d 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,31 @@ Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all ## Settings -The gear icon opens the settings panel: +The gear icon opens the settings, organized in tabs. Everything except color tags is +remembered per browser. -- **Language**: English or French. The choice is remembered in the browser. -- **Date & time**: time zone (browser zone, UTC, or about 80 common zones) and the display - format of the reception time: `DD/MM/YYYY HH:MM:SS` (default, the usual French display), - with milliseconds, `YYYY-MM-DD`, 12-hour clock, ISO 8601 or Unix epoch. The time zone - applies to every date shown. Remembered in the browser. -- **Danger zone**: "Delete all logs" permanently erases every stored log (you must type +- **Localization** + - *Language*: English or French. + - *Date & time*: time zone (browser zone, UTC, or about 80 common zones) and the display + format of the reception time: `DD/MM/YYYY HH:MM:SS` (default, the usual French display), + with milliseconds, `YYYY-MM-DD`, 12-hour clock, ISO 8601 or Unix epoch. The time zone + applies to every date shown. +- **Filters**: color tags. Each tag has a keyword, a background color (the text automatically + switches to black or white to stay readable) and options: whole word, match case, + regular expression, active. Tags are stored on the server in `/data/tags.json` + (`logstream-data` volume), so they are shared by every browser. Default tags (pastel): + `warning` (orange), `error` (red), `ok` (green). Default tags still using the colors of + earlier versions are switched to the pastel ones automatically. +- **Interface** + - *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon + button in the header switches between light and dark. + - *Log display*: font size (tiny, small, medium, large) and font: the system monospace + font, or one of 12 free fonts made for dense text (JetBrains Mono, Fira Code, Source + Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat + Mono, Noto Sans Mono, Victor Mono, DM Mono). They are loaded by the browser from + [Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without + internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as typed. +- **Data**: "Delete all logs" permanently erases every stored log (you must type `PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable` (already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature. Anyone who can open the UI can purge: set `AUTH_USER` / `AUTH_PASS` if the UI is reachable diff --git a/web/app.js b/web/app.js index c0a4aec..8eaeb83 100644 --- a/web/app.js +++ b/web/app.js @@ -66,6 +66,14 @@ const I18N = { msgTimeTitle: 'Timestamp from the message', fHostName: 'host name (DNS)', fHostIp: 'host IP', clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app', + tabLocale: 'Localization', tabFilters: 'Filters', tabInterface: 'Interface', tabData: 'Data', + themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark', + themeHelp: 'System follows the light/dark preference of your computer or phone.', + logDisplay: 'Log display', fontSize: 'Font size', + sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large', + fontLabel: 'Font', fontSystem: 'System monospace (no download)', + fontHelp: 'Free fonts (SIL Open Font License) loaded by your browser from Bunny Fonts, a privacy-friendly European font service. Without internet access, the system font is used.', + previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'], dangerZone: 'Danger zone', purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.', purgeBtn: 'Delete all logs…', @@ -141,6 +149,14 @@ const I18N = { msgTimeTitle: 'Horodatage contenu dans le message', fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte', clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli', + tabLocale: 'Localisation', tabFilters: 'Filtres', tabInterface: 'Interface', tabData: 'Données', + themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre', + themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.', + logDisplay: 'Affichage des logs', fontSize: 'Taille du texte', + sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande', + fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', + fontHelp: 'Polices libres (licence SIL Open Font) chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée. Sans accès à internet, la police du système est utilisée.', + previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'], dangerZone: 'Zone de danger', purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.', purgeBtn: 'Supprimer tous les logs…', @@ -333,6 +349,7 @@ function setLang(next) { renderTagList(); renderTimeSettings(); renderPurge(); + renderInterface(); } $('#langSwitch').addEventListener('click', (ev) => { @@ -347,10 +364,118 @@ function effectiveTheme() { if (th) return th; return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } -$('#themeBtn').addEventListener('click', () => { - const next = effectiveTheme() === 'dark' ? 'light' : 'dark'; - document.documentElement.dataset.theme = next; - store.set('theme', next); +// mode: 'system' (follow the OS), 'light' or 'dark'. +function setTheme(mode) { + if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode; + else { delete document.documentElement.dataset.theme; mode = 'system'; } + store.set('theme', mode); + renderThemeSwitch(); +} + +function renderThemeSwitch() { + const mode = document.documentElement.dataset.theme || 'system'; + for (const b of document.querySelectorAll('#themeSwitch [data-theme-opt]')) { + b.setAttribute('aria-checked', String(b.dataset.themeOpt === mode)); + } +} + +// The header button switches between light and dark. +$('#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); +}); + +/* ================= Log font and size ================= */ + +// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net). +const LOG_FONTS = [ + { id: 'system' }, + { id: 'jetbrains-mono', family: 'JetBrains Mono' }, + { id: 'fira-code', family: 'Fira Code' }, + { id: 'source-code-pro', family: 'Source Code Pro' }, + { id: 'ibm-plex-mono', family: 'IBM Plex Mono' }, + { id: 'cascadia-code', family: 'Cascadia Code' }, + { id: 'roboto-mono', family: 'Roboto Mono' }, + { id: 'ubuntu-mono', family: 'Ubuntu Mono' }, + { id: 'inconsolata', family: 'Inconsolata' }, + { id: 'red-hat-mono', family: 'Red Hat Mono' }, + { id: 'noto-sans-mono', family: 'Noto Sans Mono' }, + { id: 'victor-mono', family: 'Victor Mono' }, + { id: 'dm-mono', family: 'DM Mono', weights: '400,500' }, +]; +const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' }; +const ui = { font: 'system', size: 'medium' }; + +function applyLogFont(id) { + const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0]; + ui.font = f.id; + const root = document.documentElement.style; + if (!f.family) { + root.removeProperty('--log-font'); + return; + } + const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`; + let link = document.getElementById('logFontCss'); + if (!link) { + link = Object.assign(document.createElement('link'), { id: 'logFontCss', rel: 'stylesheet' }); + document.head.append(link); + } + if (link.getAttribute('href') !== href) link.href = href; + root.setProperty('--log-font', `'${f.family}', var(--mono)`); +} + +function applyLogSize(id) { + ui.size = LOG_SIZES[id] ? id : 'medium'; + document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]); +} + +function renderInterface() { + renderThemeSwitch(); + for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) { + b.setAttribute('aria-checked', String(b.dataset.size === ui.size)); + } + const sel = $('#fontSelect'); + sel.innerHTML = LOG_FONTS.map((f) => ``).join(''); + sel.value = ui.font; + renderFontPreview(); +} + +function renderFontPreview() { + const now = new Date().toISOString(); + const [m1, m2, m3] = t('previewMsgs'); + const rows = [ + { received: now, msg_time: now, severity: 'err', host: 'fw-edge', app: 'kernel', _msg: m1 }, + { received: now, msg_time: now, severity: 'warning', host: 'nas', app: 'smartd', _msg: m2 }, + { received: now, msg_time: now, severity: 'info', host: 'web-01', app: 'backup', _msg: m3 }, + ]; + $('#fontPreview').innerHTML = rows.map((r) => rowHTML(r, false)).join(''); +} + +$('#sizeSwitch').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-size]'); + if (!b) return; + applyLogSize(b.dataset.size); + store.set('logSize', ui.size); + renderInterface(); +}); +$('#fontSelect').addEventListener('change', (ev) => { + applyLogFont(ev.target.value); + store.set('logFont', ui.font); +}); + +/* ================= Settings tabs ================= */ + +function showSettingsTab(name) { + const tabs = [...document.querySelectorAll('#setNav [data-tab]')]; + if (!tabs.some((b) => b.dataset.tab === name)) name = 'locale'; + for (const b of tabs) b.setAttribute('aria-selected', String(b.dataset.tab === name)); + for (const p of document.querySelectorAll('.set-panel')) p.hidden = p.dataset.panel !== name; + store.set('settingsTab', name); +} +$('#setNav').addEventListener('click', (ev) => { + const b = ev.target.closest('[data-tab]'); + if (b) showSettingsTab(b.dataset.tab); }); /* ================= Filters ================= */ @@ -1014,7 +1139,9 @@ $('#resetTags').addEventListener('click', async () => { $('#settingsBtn').addEventListener('click', () => { renderTimeSettings(); renderTagList(); + renderInterface(); loadPurgeStatus(); + showSettingsTab(store.get('settingsTab', 'locale')); $('#settingsDlg').showModal(); }); $('#closeSettings').addEventListener('click', () => $('#settingsDlg').close()); @@ -1028,6 +1155,8 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre const fmt = store.get('timefmt', DEFAULT_TIME_FMT); timePrefs.fmt = TIME_FORMATS.some((f) => f.id === fmt) ? fmt : DEFAULT_TIME_FMT; } +applyLogFont(store.get('logFont', 'system')); +applyLogSize(store.get('logSize', 'medium')); applyLang(); $('#range').value = store.get('range', '1h'); if (!$('#range').value) $('#range').value = '1h'; diff --git a/web/index.html b/web/index.html index 4c14e49..61fc53f 100644 --- a/web/index.html +++ b/web/index.html @@ -10,7 +10,7 @@ // Apply the saved theme and language before first paint to avoid a flash. try { var t = localStorage.getItem('logstream.theme'); - if (t) document.documentElement.dataset.theme = t; + if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; var l = localStorage.getItem('logstream.lang'); if (l) document.documentElement.lang = l; } catch (e) {} @@ -87,7 +87,7 @@ - +

Settings

@@ -96,44 +96,104 @@
-
-

Language

-
- - -
-
+
+ -
-

Date & time

-
- - - - -
-

-

-
+
+ +
+
+

Language

+
+ + +
+
+
+

Date & time

+
+ + + + +
+

+

+
+
-
-

Color tags

-

-
-
- - -
-
+ + -
-

Danger zone

-

-
- - + + + + +
-
+
diff --git a/web/style.css b/web/style.css index 5bcedd9..47416e1 100644 --- a/web/style.css +++ b/web/style.css @@ -25,6 +25,8 @@ --tag-warning: #fdba74; --tag-warning-text: #111827; + --log-size: 12.5px; /* Settings > Interface > Font size */ + /* --log-font is set by Settings > Interface > Font (defaults to --mono) */ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --radius: 10px; @@ -221,7 +223,8 @@ body.busy .progress::after { padding: 5px 14px 5px 11px; border-left: 3px solid transparent; border-bottom: 1px solid var(--border); - font-family: var(--mono); font-size: 12.5px; + font-family: var(--log-font, var(--mono)); font-size: var(--log-size); + font-variant-ligatures: none; /* keep -> != etc. as typed */ cursor: pointer; } .row:last-child { border-bottom: 0; } @@ -268,7 +271,7 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding: grid-column: 1 / -1; margin: 8px 0 4px; padding: 10px 12px; background: var(--panel-2); border-radius: 8px; - cursor: auto; font-size: 12px; + cursor: auto; font-size: calc(var(--log-size) - .5px); } .details dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; } .details dt { color: var(--muted); } @@ -316,6 +319,45 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding: } /* ---------- Settings dialog ---------- */ +dialog.settings { + width: min(900px, calc(100vw - 32px)); + height: min(660px, calc(100vh - 64px)); max-height: none; +} +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: 200px minmax(0, 1fr); } +.set-nav { + display: flex; flex-direction: column; gap: 2px; + padding: 12px 10px; border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-2) 50%, transparent); +} +.set-nav button { + display: flex; align-items: center; gap: 10px; + height: 38px; padding: 0 12px; border: 0; border-radius: 9px; + background: transparent; color: var(--muted); font-weight: 560; font-size: 13.5px; text-align: left; + white-space: nowrap; +} +.set-nav button:hover { background: var(--panel-2); color: var(--text); } +.set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); } +.set-nav svg { width: 18px; height: 18px; flex: none; } +.set-panels { overflow-y: auto; padding: 2px 24px 22px; } +.set-panel > .set-section:first-child { margin-top: 16px; } +.hint { margin-top: 8px !important; } +.lbl { font-size: 13px; font-weight: 550; } +.set-panel .preview-list { margin: 14px 0 10px; pointer-events: none; } +.preview-list .row { + cursor: default; + grid-template-columns: max-content max-content minmax(0, max-content) minmax(0, 1fr); + grid-template-areas: "rcv sev host app" "msg msg msg msg"; + gap: 3px 10px; +} +.preview-list .row time.rcv { grid-area: rcv; } +.preview-list .row time.mt { display: none; } +.preview-list .row .sev { grid-area: sev; } +.preview-list .row .host { grid-area: host; } +.preview-list .row .app { grid-area: app; display: block; } +.preview-list .row .msg { grid-area: msg; } + dialog { width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px); padding: 0; border: 1px solid var(--border); border-radius: 14px; @@ -419,6 +461,20 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 .details dl { grid-template-columns: 1fr; } .details dt { margin-top: 4px; } .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(4, minmax(0, 1fr)); padding: 6px; + border-right: 0; border-bottom: 1px solid var(--border); + } + .set-nav button { + flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px; + font-size: 11.5px; text-align: center; + } + .set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } + .set-panels { padding: 0 16px 18px; } + #sizeSwitch { display: flex; } + #sizeSwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; } .field-grid select { margin-bottom: 6px; } .status { padding: 6px 16px; } .tag-row { grid-template-columns: 38px 1fr 36px; }