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 <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-09-28 16:13:53 +02:00
1 parent e583ab4b87
commit 84e71f741c
4 files changed
+311 -49

No files matched your search

+24 -7
View File
@@ -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
+133 -4
View File
@@ -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) => `<option value="${f.id}">${esc(f.family || t('fontSystem'))}</option>`).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';
+96 -36
View File
@@ -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 @@
<span id="stats" class="muted"></span>
</footer>
<dialog id="settingsDlg" aria-labelledby="settingsTitle">
<dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle">
<div class="dlg">
<header>
<h2 id="settingsTitle" data-i18n="settings">Settings</h2>
@@ -96,44 +96,104 @@
</button>
</header>
<section class="set-section">
<h3 data-i18n="language">Language</h3>
<div id="langSwitch" class="seg" role="radiogroup" data-i18n-aria="language">
<button type="button" role="radio" data-lang="en">English</button>
<button type="button" role="radio" data-lang="fr">Français</button>
</div>
</section>
<div class="set-body">
<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>
<span data-i18n="tabLocale">Localization</span>
</button>
<button type="button" role="tab" data-tab="filters">
<svg viewBox="0 0 24 24"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></svg>
<span data-i18n="tabFilters">Filters</span>
</button>
<button type="button" role="tab" data-tab="interface">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
<span data-i18n="tabInterface">Interface</span>
</button>
<button type="button" role="tab" data-tab="data">
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
<span data-i18n="tabData">Data</span>
</button>
</nav>
<section class="set-section">
<h3 data-i18n="dateTime">Date &amp; time</h3>
<div class="field-grid">
<label for="tzSelect" data-i18n="tzLabel">Time zone</label>
<select id="tzSelect" class="field"></select>
<label for="fmtSelect" data-i18n="fmtLabel">Reception time format</label>
<select id="fmtSelect" class="field"></select>
</div>
<p id="timePreview" class="time-preview"></p>
<p class="muted small" data-i18n="timeHelp"></p>
</section>
<div class="set-panels">
<!-- Localization -->
<div class="set-panel" role="tabpanel" data-panel="locale">
<section class="set-section">
<h3 data-i18n="language">Language</h3>
<div id="langSwitch" class="seg" role="radiogroup" data-i18n-aria="language">
<button type="button" role="radio" data-lang="en">English</button>
<button type="button" role="radio" data-lang="fr">Français</button>
</div>
</section>
<section class="set-section">
<h3 data-i18n="dateTime">Date &amp; time</h3>
<div class="field-grid">
<label for="tzSelect" data-i18n="tzLabel">Time zone</label>
<select id="tzSelect" class="field"></select>
<label for="fmtSelect" data-i18n="fmtLabel">Reception time format</label>
<select id="fmtSelect" class="field"></select>
</div>
<p id="timePreview" class="time-preview"></p>
<p class="muted small" data-i18n="timeHelp"></p>
</section>
</div>
<section class="set-section">
<h3 data-i18n="tagsTitle">Color tags</h3>
<p class="muted small" data-i18n="tagsHelp"></p>
<div id="tagList" class="tag-list"></div>
<footer>
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
</footer>
</section>
<!-- Filters -->
<div class="set-panel" role="tabpanel" data-panel="filters" hidden>
<section class="set-section">
<h3 data-i18n="tagsTitle">Color tags</h3>
<p class="muted small" data-i18n="tagsHelp"></p>
<div id="tagList" class="tag-list"></div>
<footer>
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
</footer>
</section>
</div>
<section class="set-section danger">
<h3 data-i18n="dangerZone">Danger zone</h3>
<p class="muted small" data-i18n="purgeHelp"></p>
<div class="purge-row">
<button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button>
<span id="purgeStatus" class="purge-status muted" hidden></span>
<!-- Interface -->
<div class="set-panel" role="tabpanel" data-panel="interface" hidden>
<section class="set-section">
<h3 data-i18n="themeTitle">Theme</h3>
<div id="themeSwitch" class="seg" role="radiogroup" data-i18n-aria="themeTitle">
<button type="button" role="radio" data-theme-opt="system" data-i18n="themeSystem">System</button>
<button type="button" role="radio" data-theme-opt="light" data-i18n="themeLight">Light</button>
<button type="button" role="radio" data-theme-opt="dark" data-i18n="themeDark">Dark</button>
</div>
<p class="muted small hint" data-i18n="themeHelp"></p>
</section>
<section class="set-section">
<h3 data-i18n="logDisplay">Log display</h3>
<div class="field-grid">
<span class="lbl" data-i18n="fontSize">Font size</span>
<div id="sizeSwitch" class="seg" role="radiogroup" data-i18n-aria="fontSize">
<button type="button" role="radio" data-size="tiny" data-i18n="sizeTiny">Tiny</button>
<button type="button" role="radio" data-size="small" data-i18n="sizeSmall">Small</button>
<button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button>
<button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button>
</div>
<label for="fontSelect" data-i18n="fontLabel">Font</label>
<select id="fontSelect" class="field"></select>
</div>
<div id="fontPreview" class="list preview-list" aria-hidden="true"></div>
<p class="muted small" data-i18n="fontHelp"></p>
</section>
</div>
<!-- Data -->
<div class="set-panel" role="tabpanel" data-panel="data" hidden>
<section class="set-section danger">
<h3 data-i18n="dangerZone">Danger zone</h3>
<p class="muted small" data-i18n="purgeHelp"></p>
<div class="purge-row">
<button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button>
<span id="purgeStatus" class="purge-status muted" hidden></span>
</div>
</section>
</div>
</div>
</section>
</div>
</div>
</dialog>
+58 -2
View File
@@ -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; }