Files
logstream/web/app.js
T
cedricandClaude Opus 5.5 84e71f741c 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>
2026-09-28 16:13:53 +02:00

1174 lines
48 KiB
JavaScript

'use strict';
/* ================= Translations ================= */
const I18N = {
en: {
locale: 'en-US',
searchAria: 'Search logs',
searchSimple: 'Search… error -timeout "disk full" (press /)',
searchLogsql: 'LogsQL: error AND host:web-01 | sort by (_time)',
modeTitle: 'Switch between simple search and LogsQL',
live: 'Live',
liveTitle: 'Show new messages live',
liveUnavailable: 'Live view is not available in LogsQL mode',
settings: 'Settings',
theme: 'Light / dark theme',
close: 'Close',
rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application',
histoAria: 'Log volume over time',
r5m: '5 min', r15m: '15 min', r1h: '1 hour', r6h: '6 hours', r24h: '24 hours', r7d: '7 days', r30d: '30 days', rAll: 'All',
sevAll: 'All severities', sev2: 'Critical and above', sev3: 'Errors and above',
sev4: 'Warnings and above', sev5: 'Notice and above', sev6: 'Info and above',
allHosts: 'All hosts', allApps: 'All apps',
emptyTitle: 'No matching logs.',
emptyHint: 'Widen the time range or send syslog messages to port 514 (UDP or TCP).',
count: (n) => `${n} ${n === 1 ? 'log' : 'logs'} shown`,
histoMax: (n) => `max ${n} / interval`,
searchError: 'Search error: ',
connLive: 'live', connConnecting: 'connecting…', connRetry: 'reconnecting…',
connPaused: 'live paused', connLogsql: 'live unavailable (LogsQL)',
newLogs: (n) => `↑ ${n} new ${n === 1 ? 'log' : 'logs'}`,
skipped: (n) => `${n} messages not shown (rate too high)`,
stats: (s) => `received ${s.received} · stored ${s.ingested} · dropped ${s.dropped} · queue ${s.queue}`,
storageErr: 'storage: ',
unreachable: 'server unreachable',
fTime: 'timestamp', fMsg: 'message', fPid: 'pid', fSd: 'structured data',
filterHost: 'Filter on this host', filterApp: 'Filter on this app',
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.',
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',
confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error, ok)?',
tagsLoadErr: 'Tags: ',
err_pattern_required: 'The keyword is required',
err_invalid_color: 'Invalid color (expected #rrggbb)',
err_invalid_regex: 'Invalid regular expression',
err_tag_not_found: 'Tag not found',
err_live_logsql: 'Live view is not available in LogsQL mode',
dateTime: 'Date & time',
tzLabel: 'Time zone',
fmtLabel: 'Reception time format',
tzBrowser: (z) => `Browser time zone (${z})`,
tzGroups: { General: 'General', Europe: 'Europe', Africa: 'Africa', America: 'Americas', Asia: 'Asia', Oceania: 'Oceania & Pacific', Other: 'Atlantic & Indian Ocean' },
fmtEpoch: 'Unix epoch (milliseconds)',
timePreview: 'Preview: ',
timeHelp: 'The reception time comes from the server clock. The time zone applies to every date shown.',
fReceived: 'received',
rcvTitle: 'Received by the server',
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…',
purgePrompt: 'This permanently deletes ALL stored logs.\n\nType PURGE to confirm:',
purgeStarted: 'Deletion started',
purgeRunning: 'Deletion in progress… logs disappear gradually.',
purgeDone: 'All logs have been deleted.',
purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)',
err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)',
err_purge_confirm: 'Type PURGE to confirm',
},
fr: {
locale: 'fr-FR',
searchAria: 'Rechercher dans les logs',
searchSimple: 'Rechercher… error -timeout "disk full" (touche /)',
searchLogsql: 'LogsQL : error AND host:web-01 | sort by (_time)',
modeTitle: 'Basculer entre recherche simple et LogsQL',
live: 'Direct',
liveTitle: 'Afficher les nouveaux messages en direct',
liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL',
settings: 'Paramètres',
theme: 'Thème clair / sombre',
close: 'Fermer',
rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application',
histoAria: 'Volume de logs dans le temps',
r5m: '5 min', r15m: '15 min', r1h: '1 heure', r6h: '6 heures', r24h: '24 heures', r7d: '7 jours', r30d: '30 jours', rAll: 'Tout',
sevAll: 'Toutes sévérités', sev2: 'Critiques et +', sev3: 'Erreurs et +',
sev4: 'Warnings et +', sev5: 'Notice et +', sev6: 'Info et +',
allHosts: 'Tous les hôtes', allApps: 'Toutes les applis',
emptyTitle: 'Aucun log ne correspond.',
emptyHint: 'Élargissez la période ou envoyez des messages syslog sur le port 514 (UDP ou TCP).',
count: (n) => `${n} ${n > 1 ? 'logs affichés' : 'log affiché'}`,
histoMax: (n) => `max ${n} / intervalle`,
searchError: 'Erreur de recherche : ',
connLive: 'direct', connConnecting: 'connexion…', connRetry: 'reconnexion…',
connPaused: 'direct en pause', connLogsql: 'direct indisponible (LogsQL)',
newLogs: (n) => `↑ ${n} ${n > 1 ? 'nouveaux logs' : 'nouveau log'}`,
skipped: (n) => `${n} messages non affichés (débit trop élevé)`,
stats: (s) => `reçus ${s.received} · stockés ${s.ingested} · perdus ${s.dropped} · file ${s.queue}`,
storageErr: 'stockage : ',
unreachable: 'serveur injoignable',
fTime: 'horodatage', fMsg: 'message', fPid: 'pid', fSd: 'données structurées',
filterHost: 'Filtrer sur cet hôte', filterApp: 'Filtrer sur cette appli',
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.',
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',
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?',
tagsLoadErr: 'Tags : ',
err_pattern_required: 'Le mot-clé est obligatoire',
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
err_invalid_regex: 'Expression régulière invalide',
err_tag_not_found: 'Tag introuvable',
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire',
fmtLabel: 'Format de l\'heure de réception',
tzBrowser: (z) => `Fuseau du navigateur (${z})`,
tzGroups: { General: 'Général', Europe: 'Europe', Africa: 'Afrique', America: 'Amériques', Asia: 'Asie', Oceania: 'Océanie et Pacifique', Other: 'Atlantique et océan Indien' },
fmtEpoch: 'Epoch Unix (millisecondes)',
timePreview: 'Aperçu : ',
timeHelp: 'L\'heure de réception provient de l\'horloge du serveur. Le fuseau horaire s\'applique à toutes les dates affichées.',
fReceived: 'réception',
rcvTitle: 'Reçu par le serveur',
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…',
purgePrompt: 'Cette action supprime définitivement TOUS les logs stockés.\n\nTapez PURGE pour confirmer :',
purgeStarted: 'Suppression lancée',
purgeRunning: 'Suppression en cours… les logs disparaissent progressivement.',
purgeDone: 'Tous les logs ont été supprimés.',
purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)',
err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)',
err_purge_confirm: 'Tapez PURGE pour confirmer',
},
};
/* ================= Utilities ================= */
const $ = (sel) => document.querySelector(sel);
const PAGE_SIZE = 500; // rows loaded per search
const MAX_ROWS = 2000; // rows kept on screen in live mode
const RANGE_MS = { '5m': 3e5, '15m': 9e5, '1h': 36e5, '6h': 216e5, '24h': 864e5, '7d': 6048e5, '30d': 2592e6 };
const store = {
get(k, def) { try { const v = localStorage.getItem('logstream.' + k); return v === null ? def : v; } catch { return def; } },
set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch { /* storage unavailable */ } },
};
let lang = I18N[store.get('lang', 'en')] ? store.get('lang', 'en') : 'en';
function t(key, arg) {
const v = I18N[lang][key] ?? I18N.en[key] ?? key;
return typeof v === 'function' ? v(arg) : v;
}
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
const pad = (n, w = 2) => String(n).padStart(w, '0');
const debounce = (fn, ms) => { let tm; return (...a) => { clearTimeout(tm); tm = setTimeout(() => fn(...a), ms); }; };
const fmtNum = (n) => Number(n || 0).toLocaleString(t('locale'));
// Date.parse only accepts 3 decimals everywhere: drop the nanoseconds.
const parseTime = (s) => new Date(String(s || '').replace(/(\.\d{3})\d+/, '$1'));
/* ================= Time zones and time formats ================= */
const TIME_ZONES = {
General: ['UTC'],
Europe: ['Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Europe/Paris', 'Europe/Brussels', 'Europe/Amsterdam',
'Europe/Luxembourg', 'Europe/Berlin', 'Europe/Zurich', 'Europe/Madrid', 'Europe/Rome', 'Europe/Vienna',
'Europe/Prague', 'Europe/Warsaw', 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Helsinki',
'Europe/Athens', 'Europe/Bucharest', 'Europe/Kyiv', 'Europe/Istanbul', 'Europe/Moscow'],
Africa: ['Africa/Casablanca', 'Africa/Algiers', 'Africa/Tunis', 'Africa/Dakar', 'Africa/Abidjan', 'Africa/Lagos',
'Africa/Cairo', 'Africa/Nairobi', 'Africa/Johannesburg'],
America: ['America/St_Johns', 'America/Halifax', 'America/Montreal', 'America/Toronto', 'America/New_York',
'America/Chicago', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Vancouver',
'America/Anchorage', 'America/Mexico_City', 'America/Martinique', 'America/Guadeloupe', 'America/Cayenne',
'America/Bogota', 'America/Lima', 'America/Santiago', 'America/Sao_Paulo', 'America/Buenos_Aires'],
Asia: ['Asia/Dubai', 'Asia/Tehran', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Kathmandu', 'Asia/Dhaka', 'Asia/Bangkok',
'Asia/Ho_Chi_Minh', 'Asia/Jakarta', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Taipei',
'Asia/Manila', 'Asia/Seoul', 'Asia/Tokyo'],
Oceania: ['Australia/Perth', 'Australia/Adelaide', 'Australia/Brisbane', 'Australia/Sydney', 'Pacific/Noumea',
'Pacific/Auckland', 'Pacific/Tahiti', 'Pacific/Honolulu'],
Other: ['Atlantic/Reykjavik', 'Atlantic/Azores', 'Atlantic/Canary', 'Indian/Reunion', 'Indian/Mayotte', 'Indian/Mauritius'],
};
// Each format receives the date parts in the selected time zone (and the Date itself).
const TIME_FORMATS = [
{ id: 'dmy', label: 'DD/MM/YYYY HH:MM:SS', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}` },
{ id: 'dmy-ms', label: 'DD/MM/YYYY HH:MM:SS.mmm', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'ymd', label: 'YYYY-MM-DD HH:MM:SS', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}` },
{ id: 'ymd-ms', label: 'YYYY-MM-DD HH:MM:SS.mmm', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'mdy12', label: 'MM/DD/YYYY hh:MM:SS.mmm AM/PM', f: (p) => {
const H = Number(p.h);
return `${p.M}/${p.D}/${p.Y} ${pad(H % 12 || 12)}:${p.m}:${p.s}.${p.ms} ${H < 12 ? 'AM' : 'PM'}`;
} },
{ id: 'time-ms', label: 'HH:MM:SS.mmm', f: (p) => `${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'iso', label: 'ISO 8601 (YYYY-MM-DDTHH:MM:SS.mmm+hh:mm)', f: (p) => `${p.Y}-${p.M}-${p.D}T${p.h}:${p.m}:${p.s}.${p.ms}${p.off === '+00:00' ? 'Z' : p.off}` },
{ id: 'epoch', label: null, f: (p, d) => String(d.getTime()) },
];
const DEFAULT_TIME_FMT = 'dmy'; // usual French display: 28/09/2026 14:55:23
const timePrefs = { tz: '', fmt: DEFAULT_TIME_FMT }; // tz '' = browser time zone
const partsFormatters = new Map();
function validTimeZone(tz) {
if (!tz) return true;
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
}
// Date parts in a time zone ('' = browser zone), plus the UTC offset as "+hh:mm".
function zoneParts(date, tz = timePrefs.tz) {
let f = partsFormatters.get(tz);
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone: tz || undefined, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'longOffset',
});
partsFormatters.set(tz, f);
}
const p = {};
for (const x of f.formatToParts(date)) p[x.type] = x.value;
const off = (p.timeZoneName || 'GMT').replace('GMT', '') || '+00:00';
return {
Y: p.year, M: p.month, D: p.day, h: p.hour === '24' ? '00' : p.hour, m: p.minute, s: p.second,
ms: pad(date.getUTCMilliseconds(), 3), off,
};
}
function fmtWith(fmtId, date) {
const fmt = TIME_FORMATS.find((x) => x.id === fmtId) || TIME_FORMATS[0];
return fmt.f(zoneParts(date), date);
}
// Full date in the selected format (reception time, details panel).
function fmtFull(s) {
const d = parseTime(s);
return isNaN(d) ? String(s ?? '') : fmtWith(timePrefs.fmt, d);
}
async function api(url, opts = {}) {
if (opts.body && typeof opts.body !== 'string') {
opts.body = JSON.stringify(opts.body);
opts.headers = { 'Content-Type': 'application/json' };
}
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
if (!res.ok) {
// Known error codes are translated; otherwise show the server message.
let msg = (data && data.error) || text || res.statusText;
if (data && data.code && I18N[lang]['err_' + data.code]) {
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
}
throw new Error(msg);
}
return data;
}
function toast(msg) {
const el = $('#toast');
// A modal dialog sits above everything: move the toast into it so it stays visible.
const dlg = $('#settingsDlg');
(dlg.open ? dlg : document.body).append(el);
el.textContent = msg;
el.hidden = false;
clearTimeout(toast.tm);
toast.tm = setTimeout(() => { el.hidden = true; }, 1800);
}
/* ================= State ================= */
const state = {
rows: [], // displayed records, newest first
pending: [], // live messages received while scrolled down
tags: [],
matchers: [], // compiled tags + search terms
mode: store.get('mode', 'simple'),
live: store.get('live', '1') === '1',
es: null,
reqId: 0,
histo: null,
tookMs: null,
conn: ['', 'connPaused'],
stats: null,
};
const recOf = new WeakMap(); // .row element -> record
const list = $('#list');
/* ================= Language ================= */
function applyLang() {
document.documentElement.lang = lang;
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang));
}
function setLang(next) {
if (next === lang || !I18N[next]) return;
lang = next;
store.set('lang', lang);
applyLang();
syncControls();
setConn(...state.conn);
renderList();
updateCount(state.tookMs);
renderHisto(state.histo);
renderStats();
renderTagList();
renderTimeSettings();
renderPurge();
renderInterface();
}
$('#langSwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-lang]');
if (b) setLang(b.dataset.lang);
});
/* ================= Theme ================= */
function effectiveTheme() {
const th = document.documentElement.dataset.theme;
if (th) return th;
return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// 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 ================= */
function params() {
const p = new URLSearchParams();
const q = $('#q').value.trim();
if (q) p.set('q', q);
p.set('mode', state.mode);
p.set('range', $('#range').value);
for (const id of ['severity', 'host', 'app']) {
const v = $('#' + id).value;
if (v !== '') p.set(id, v);
}
return p;
}
function liveAvailable() {
return !(state.mode === 'logsql' && $('#q').value.trim());
}
function syncControls() {
const logsql = state.mode === 'logsql';
$('#mode').textContent = logsql ? 'LogsQL' : 'Simple';
$('#mode').classList.toggle('on', logsql);
$('#q').placeholder = logsql ? t('searchLogsql') : t('searchSimple');
const btn = $('#live');
btn.disabled = !liveAvailable();
btn.classList.toggle('on', state.live && liveAvailable());
btn.title = liveAvailable() ? t('liveTitle') : t('liveUnavailable');
for (const id of ['severity', 'host', 'app']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
}
/* ================= Tags and highlighting ================= */
// Readable text color (black or white) for a given background.
function textOn(hex) {
const n = parseInt(hex.slice(1), 16);
const yiq = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000;
return yiq >= 150 ? '#111827' : '#ffffff';
}
const tagStyle = (color) => `background:${color};color:${textOn(color)}`;
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Severity badges share the colors of the "error" and "warning" tags.
function applySeverityColors() {
const style = document.documentElement.style;
for (const name of ['error', 'warning']) {
const tag = state.tags.find((x) => String(x.pattern).toLowerCase() === name && /^#[0-9a-f]{6}$/i.test(x.color));
if (tag) {
style.setProperty(`--tag-${name}`, tag.color);
style.setProperty(`--tag-${name}-text`, textOn(tag.color));
} else {
style.removeProperty(`--tag-${name}`);
style.removeProperty(`--tag-${name}-text`);
}
}
}
function compileMatchers() {
applySeverityColors();
const out = [];
for (const tag of state.tags) {
if (!tag.enabled || !tag.pattern) continue;
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
if (tag.wholeWord) src = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
try {
out.push({ re: new RegExp(src, 'gu' + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` });
} catch (e) {
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
}
}
// Simple-search terms get a subtle highlight, with the lowest priority.
if (state.mode === 'simple') {
for (const tok of $('#q').value.match(/-?"[^"]*"|\S+/g) || []) {
if (tok.startsWith('-')) continue;
const word = tok.replace(/^"|"$/g, '');
if (word) out.push({ re: new RegExp(escapeRe(word), 'giu'), html: '<mark class="hit">' });
}
}
state.matchers = out;
}
function highlight(text) {
text = String(text ?? '');
if (!state.matchers.length || !text) return esc(text);
const spans = [];
state.matchers.forEach((m, prio) => {
m.re.lastIndex = 0;
let x;
while ((x = m.re.exec(text)) !== null) {
if (x[0] === '') { m.re.lastIndex++; continue; }
spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html });
}
});
if (!spans.length) return esc(text);
spans.sort((a, b) => a.s - b.s || a.prio - b.prio);
let out = '';
let pos = 0;
for (const sp of spans) {
if (sp.s < pos) continue; // overlap: first one wins
out += esc(text.slice(pos, sp.s)) + sp.html + esc(text.slice(sp.s, sp.e)) + '</mark>';
pos = sp.e;
}
return out + esc(text.slice(pos));
}
/* ================= List rendering ================= */
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
function fmtTime(s) {
const d = parseTime(s);
if (isNaN(d)) return esc(s);
const p = zoneParts(d);
const day = lang === 'fr' ? `${p.D}/${p.M}` : `${p.M}/${p.D}`;
return `${day} ${p.h}:${p.m}:${p.s}<span class="ms">.${p.ms}</span>`;
}
function rowHTML(r, isNew) {
const sev = r.severity || '';
const cls = SEV_CLASS[sev] || 'info';
return `<article class="row sev-${cls}${isNew ? ' new' : ''}" tabindex="0">`
+ `<time class="rcv" title="${esc(t('rcvTitle'))}">${r.received ? esc(fmtFull(r.received)) : '—'}</time>`
+ `<time class="mt" title="${esc(t('msgTimeTitle'))}">${fmtTime(r.msg_time || r._time)}</time>`
+ `<span class="sev">${esc(sev || '—')}</span>`
+ (r.host
? `<span class="host" data-act="host" title="${esc(hostTitle(r) + ' · ' + t('clickHost'))}">${esc(r.host_name || r.host)}</span>`
: '<span class="host"></span>')
+ (r.app
? `<span class="app" data-act="app" title="${esc(r.app + ' · ' + t('clickApp'))}">${esc(r.app)}</span>`
: '<span class="app"></span>')
+ `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>';
}
// Tooltip of the host cell: name and IP when both are known.
function hostTitle(r) {
const ip = r.host_ip || (r.host_name ? r.host : '');
const name = r.host_name || r.host;
return ip ? `${name} (${ip})` : name;
}
// Map the .row elements starting at `first` to their records.
function bindRows(first, rows) {
let el = first;
for (const r of rows) {
if (!el) break;
recOf.set(el, r);
el = el.nextElementSibling;
}
}
// Adds the hosts/apps of displayed logs to the filter lists (live logs can
// show a host before the next refresh of /api/facets).
function addFacetValues(rows) {
for (const id of ['host', 'app']) {
const sel = $('#' + id);
const have = new Set([...sel.options].map((o) => o.value));
let added = false;
for (const r of rows) {
const v = r[id];
if (!v || have.has(v)) continue;
have.add(v);
sel.add(new Option(id === 'host' && r.host_name ? `${r.host_name} (${v})` : v, v));
added = true;
}
if (added) {
const cur = sel.value;
const [first, ...rest] = [...sel.options];
rest.sort((a, b) => a.text.localeCompare(b.text));
sel.replaceChildren(first, ...rest);
sel.value = cur;
}
}
}
function renderList() {
addFacetValues(state.rows);
list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join('');
bindRows(list.firstElementChild, state.rows);
$('#empty').hidden = state.rows.length > 0 || !$('#error').hidden;
}
function rehighlight() {
for (const row of list.children) {
const r = recOf.get(row);
if (r) row.querySelector('.msg').innerHTML = highlight(r._msg);
}
}
function prependRows(rows) {
if (!rows.length) return;
addFacetValues(rows);
list.insertAdjacentHTML('afterbegin', rows.map((r) => rowHTML(r, true)).join(''));
bindRows(list.firstElementChild, rows);
state.rows = rows.concat(state.rows);
while (state.rows.length > MAX_ROWS) {
state.rows.pop();
list.lastElementChild?.remove();
}
$('#empty').hidden = true;
updateCount();
}
function updateCount(tookMs) {
if (!$('#error').hidden) { $('#count').textContent = ''; return; }
const took = tookMs != null ? ` · ${tookMs} ms` : '';
$('#count').textContent = t('count', state.rows.length).replace(/^\d+/, fmtNum(state.rows.length)) + took;
}
function detailsHTML(r) {
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const hidden = new Set(['_stream_id', '_stream', 'sevnum']);
if (r.msg_time) hidden.add('_time'); // same as the reception time
const keys = order.filter((k) => r[k] != null && r[k] !== '' && !hidden.has(k))
.concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort());
const label = { msg_time: t('fTime'), host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const val = (k) => (k === '_time' || k === 'received' || k === 'msg_time'
? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>`
: esc(r[k]));
return '<div class="details"><dl>'
+ keys.map((k) => `<dt>${esc(label[k] || k)}</dt><dd>${val(k)}</dd>`).join('')
+ '</dl><div class="acts">'
+ (r.host ? `<button class="btn" data-act="host">${esc(t('filterHost'))}</button>` : '')
+ (r.app ? `<button class="btn" data-act="app">${esc(t('filterApp'))}</button>` : '')
+ `<button class="btn" data-act="copy">${esc(t('copyMsg'))}</button>`
+ `<button class="btn" data-act="json">${esc(t('copyJson'))}</button>`
+ '</div></div>';
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for pages served over plain HTTP (not localhost).
const ta = Object.assign(document.createElement('textarea'), { value: text });
document.body.append(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
toast(t('copied'));
}
function setSelect(id, value) {
const sel = $('#' + id);
if (![...sel.options].some((o) => o.value === value)) sel.add(new Option(value, value));
sel.value = value;
}
list.addEventListener('click', (ev) => {
const row = ev.target.closest('.row');
if (!row) return;
const r = recOf.get(row);
const act = ev.target.closest('[data-act]');
if (act) {
const a = act.dataset.act;
if (a === 'host' || a === 'app') { setSelect(a, r[a]); onFilterChange(); }
if (a === 'copy') copy(r._msg || '');
if (a === 'json') copy(JSON.stringify(r, null, 2));
return;
}
if (ev.target.closest('.details')) return;
if (String(window.getSelection())) return; // selecting text: don't collapse
const open = row.classList.toggle('open');
const det = row.querySelector('.details');
if (open && !det) row.insertAdjacentHTML('beforeend', detailsHTML(r));
else if (det) det.hidden = !open;
});
list.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && ev.target.classList.contains('row')) ev.target.click();
});
/* ================= Histogram ================= */
function renderHisto(h) {
const el = $('#histo');
if (!h || !h.buckets) { el.innerHTML = ''; return; }
const stepMs = h.step * 1000;
const counts = new Map();
for (const b of h.buckets) counts.set(Math.floor(parseTime(b.t).getTime() / stepMs), b.n);
const span = RANGE_MS[$('#range').value];
const end = Math.floor(Date.now() / stepMs);
let start = span ? Math.floor((Date.now() - span) / stepMs) + 1 : (counts.size ? Math.min(...counts.keys()) : end);
if (end - start > 400) start = end - 400;
let max = 1;
for (let k = start; k <= end; k++) max = Math.max(max, counts.get(k) || 0);
const loc = t('locale');
const label = (k) => {
const d = new Date(k * stepMs);
const timeZone = timePrefs.tz || undefined;
return stepMs >= 864e5
? d.toLocaleDateString(loc, { timeZone })
: d.toLocaleString(loc, { timeZone, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
};
let bars = '';
for (let k = start; k <= end; k++) {
const n = counts.get(k) || 0;
const hgt = n ? Math.max(4, (n / max) * 100) : 0;
bars += `<i style="height:${hgt}%" title="${label(k)}: ${fmtNum(n)}"></i>`;
}
el.innerHTML = `<div class="bars">${bars}</div><div class="axis"><span>${label(start)}</span><span>${t('histoMax', fmtNum(max))}</span><span>${label(end)}</span></div>`;
}
async function refreshHisto() {
try { state.histo = await api('/api/histogram?' + params()); } catch { state.histo = null; }
renderHisto(state.histo);
}
/* ================= Search ================= */
function setError(msg) {
const el = $('#error');
el.textContent = msg || '';
el.hidden = !msg;
}
async function refresh() {
const id = ++state.reqId;
syncControls();
compileMatchers();
document.body.classList.add('busy');
connectLive();
try {
const res = await api(`/api/logs?${params()}&limit=${PAGE_SIZE}`);
if (id !== state.reqId) return;
setError(null);
state.rows = res.rows || [];
state.pending = [];
state.tookMs = res.took_ms;
$('#newPill').hidden = true;
renderList();
updateCount(state.tookMs);
} catch (e) {
if (id !== state.reqId) return;
setError(t('searchError') + e.message);
state.rows = [];
renderList();
updateCount();
} finally {
if (id === state.reqId) document.body.classList.remove('busy');
}
refreshHisto();
}
const onFilterChange = () => {
store.set('range', $('#range').value);
store.set('severity', $('#severity').value);
refresh();
};
$('#q').addEventListener('input', debounce(refresh, 350));
$('#q').addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') refresh();
if (ev.key === 'Escape') { ev.target.value = ''; refresh(); }
});
for (const id of ['range', 'severity', 'host', 'app']) $('#' + id).addEventListener('change', onFilterChange);
$('#mode').addEventListener('click', () => {
state.mode = state.mode === 'simple' ? 'logsql' : 'simple';
store.set('mode', state.mode);
refresh();
$('#q').focus();
});
document.addEventListener('keydown', (ev) => {
if (ev.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) && !$('#settingsDlg').open) {
ev.preventDefault();
$('#q').focus();
}
});
/* ================= Live view (SSE) ================= */
function setConn(cls, key) {
state.conn = [cls, key];
const el = $('#conn');
el.className = 'conn ' + cls;
el.textContent = t(key);
}
function connectLive() {
if (state.es) { state.es.close(); state.es = null; }
if (!state.live || !liveAvailable()) {
setConn('', state.live ? 'connLogsql' : 'connPaused');
return;
}
const es = new EventSource('/api/stream?' + params());
state.es = es;
setConn('', 'connConnecting');
es.onopen = () => setConn('ok', 'connLive');
es.onerror = () => setConn('ko', 'connRetry');
es.onmessage = (ev) => {
const data = JSON.parse(ev.data);
const rows = (data.rows || []).reverse(); // newest first
if (data.skipped) toast(t('skipped', fmtNum(data.skipped)));
if (window.scrollY > 80) {
state.pending = rows.concat(state.pending).slice(0, MAX_ROWS);
const pill = $('#newPill');
pill.textContent = t('newLogs', state.pending.length).replace(/\d+/, fmtNum(state.pending.length));
pill.hidden = false;
} else {
prependRows(rows);
}
};
}
function flushPending() {
if (!state.pending.length) return;
prependRows(state.pending);
state.pending = [];
$('#newPill').hidden = true;
}
$('#newPill').addEventListener('click', () => {
flushPending();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
window.addEventListener('scroll', debounce(() => { if (window.scrollY <= 80) flushPending(); }, 150), { passive: true });
$('#live').addEventListener('click', () => {
state.live = !state.live;
store.set('live', state.live ? '1' : '0');
if (state.live) refresh(); // catch up on what arrived while paused
else { syncControls(); connectLive(); }
});
/* ================= Host / app lists, stats ================= */
async function loadFacets() {
try {
const f = await api('/api/facets');
for (const id of ['host', 'app']) {
const sel = $('#' + id);
const cur = sel.value;
const first = sel.options[0];
sel.replaceChildren(first, ...(f[id] || []).map((x) => new Option(x.label || x.value, x.value)));
if (cur) setSelect(id, cur);
}
addFacetValues(state.rows);
} catch { /* VictoriaLogs not ready yet: retried later */ }
}
function renderStats() {
const el = $('#stats');
const s = state.stats;
if (!s) { el.textContent = t('unreachable'); return; }
const n = { received: fmtNum(s.received), ingested: fmtNum(s.ingested), dropped: '%DROPPED%', queue: fmtNum(s.queue) };
const dropped = s.dropped ? `<span class="bad">${fmtNum(s.dropped)}</span>` : fmtNum(0);
let html = esc(t('stats', n)).replace('%DROPPED%', dropped);
if (s.lastError) html += ` · <span class="bad">${esc(t('storageErr') + s.lastError)}</span>`;
el.innerHTML = html;
}
async function loadStats() {
try { state.stats = await api('/api/stats'); } catch { state.stats = null; }
renderStats();
}
/* ================= Date & time settings ================= */
function renderTimeSettings() {
const now = new Date();
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local';
const offsetOf = (z) => 'UTC' + zoneParts(now, z).off;
const groups = t('tzGroups');
let html = `<option value="">${esc(t('tzBrowser', browserZone))}</option>`;
for (const [group, zones] of Object.entries(TIME_ZONES)) {
html += `<optgroup label="${esc(groups[group] || group)}">`;
for (const z of zones) {
if (!validTimeZone(z)) continue;
html += `<option value="${z}">${esc(z.replace(/_/g, ' '))} (${offsetOf(z)})</option>`;
}
html += '</optgroup>';
}
const tzSel = $('#tzSelect');
tzSel.innerHTML = html;
tzSel.value = timePrefs.tz;
const fmtSel = $('#fmtSelect');
fmtSel.innerHTML = TIME_FORMATS.map((f) => `<option value="${f.id}">${esc(f.label || t('fmtEpoch'))}</option>`).join('');
fmtSel.value = timePrefs.fmt;
updateTimePreview();
}
function updateTimePreview() {
$('#timePreview').textContent = t('timePreview') + fmtWith(timePrefs.fmt, new Date());
}
function onTimePrefsChange() {
timePrefs.tz = $('#tzSelect').value;
timePrefs.fmt = $('#fmtSelect').value;
store.set('tz', timePrefs.tz);
store.set('timefmt', timePrefs.fmt);
updateTimePreview();
renderList();
renderHisto(state.histo);
}
$('#tzSelect').addEventListener('change', onTimePrefsChange);
$('#fmtSelect').addEventListener('change', onTimePrefsChange);
setInterval(() => { if ($('#settingsDlg').open) updateTimePreview(); }, 1000);
/* ================= Purge ================= */
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
function renderPurge() {
const btn = $('#purgeBtn');
const status = $('#purgeStatus');
btn.disabled = !purge.allowed || purge.running > 0;
let msg = '';
let cls = 'muted';
if (!purge.allowed) msg = t('purgeForbidden');
else if (purge.code && I18N[lang]['err_' + purge.code]) { msg = t('err_' + purge.code); cls = 'bad'; }
else if (purge.running > 0) msg = t('purgeRunning');
else if (purge.wasRunning) { msg = t('purgeDone'); cls = 'good'; }
status.textContent = msg;
status.className = 'purge-status ' + cls;
status.hidden = !msg;
}
async function loadPurgeStatus() {
try {
const s = await api('/api/purge');
purge.allowed = s.allowed;
purge.code = s.code || null;
if (purge.running > 0 && !s.running) {
purge.wasRunning = true;
refresh();
loadFacets();
loadStats();
}
purge.running = s.running || 0;
} catch { /* shown on the next attempt */ }
renderPurge();
clearTimeout(purge.timer);
if (purge.running > 0) purge.timer = setTimeout(loadPurgeStatus, 2000);
}
$('#purgeBtn').addEventListener('click', async () => {
const answer = prompt(t('purgePrompt'));
if (answer === null) return;
try {
await api('/api/purge', { method: 'POST', body: { confirm: answer.trim() } });
toast(t('purgeStarted'));
purge.running = 1;
purge.wasRunning = false;
state.rows = [];
renderList();
updateCount();
loadPurgeStatus();
} catch (e) {
toast(e.message);
}
});
/* ================= Tag management ================= */
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
async function loadTags() {
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
compileMatchers();
}
function tagRowHTML(tag) {
const opt = (field, label, title) =>
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`;
return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}">
<input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
<span class="preview"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.pattern || t('preview'))}</mark></span>
<div class="opts">
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))}
${opt('caseSensitive', 'Aa', t('optCaseTitle'))}
${opt('regex', '.*', t('optRegexTitle'))}
${opt('enabled', t('optActive'), t('optActiveTitle'))}
</div>
<button class="icon-btn danger" type="button" data-del title="${esc(t('del'))}" aria-label="${esc(t('del'))}">
<svg viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
</button>
<div class="err"></div>
</div>`;
}
function renderTagList() {
$('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || `<p class="muted small">${esc(t('noTags'))}</p>`;
}
const saveTimers = {};
function scheduleSave(tag, rowEl) {
clearTimeout(saveTimers[tag.id]);
saveTimers[tag.id] = setTimeout(async () => {
const errEl = rowEl.querySelector('.err');
try {
await api('/api/tags/' + encodeURIComponent(tag.id), { method: 'PUT', body: tag });
errEl.textContent = '';
} catch (e) {
errEl.textContent = e.message;
}
}, 400);
}
const applyTags = debounce(() => { compileMatchers(); rehighlight(); }, 150);
$('#tagList').addEventListener('input', (ev) => {
const rowEl = ev.target.closest('.tag-row');
const field = ev.target.dataset.f;
if (!rowEl || !field) return;
const tag = state.tags.find((x) => x.id === rowEl.dataset.id);
if (!tag) return;
tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
rowEl.classList.toggle('off', !tag.enabled);
const mark = rowEl.querySelector('.preview mark');
mark.textContent = tag.pattern || t('preview');
mark.setAttribute('style', tagStyle(tag.color));
scheduleSave(tag, rowEl);
applyTags();
});
$('#tagList').addEventListener('click', async (ev) => {
const del = ev.target.closest('[data-del]');
if (!del) return;
const rowEl = del.closest('.tag-row');
const tag = state.tags.find((x) => x.id === rowEl.dataset.id);
if (!tag || !confirm(t('confirmDelete', tag.pattern))) return;
try {
await api('/api/tags/' + encodeURIComponent(tag.id), { method: 'DELETE' });
state.tags = state.tags.filter((x) => x !== tag);
renderTagList();
applyTags();
} catch (e) { toast(e.message); }
});
$('#addTag').addEventListener('click', async () => {
const color = PALETTE[state.tags.length % PALETTE.length];
try {
const tag = await api('/api/tags', { method: 'POST', body: { pattern: t('newTag'), color, wholeWord: true, enabled: true } });
state.tags.push(tag);
renderTagList();
applyTags();
const input = $('#tagList').lastElementChild.querySelector('input[type="text"]');
input.focus();
input.select();
} catch (e) { toast(e.message); }
});
$('#resetTags').addEventListener('click', async () => {
if (!confirm(t('confirmReset'))) return;
try {
state.tags = await api('/api/tags/reset', { method: 'POST' });
renderTagList();
applyTags();
} catch (e) { toast(e.message); }
});
$('#settingsBtn').addEventListener('click', () => {
renderTimeSettings();
renderTagList();
renderInterface();
loadPurgeStatus();
showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal();
});
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
/* ================= Startup ================= */
{
const tz = store.get('tz', '');
timePrefs.tz = validTimeZone(tz) ? tz : '';
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';
$('#severity').value = store.get('severity', '');
(async () => {
await loadTags();
loadFacets();
loadStats();
refresh();
setInterval(loadStats, 5000);
setInterval(loadFacets, 60000);
setInterval(() => { if (state.live && liveAvailable() && !document.hidden) refreshHisto(); }, 30000);
})();