'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', 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', 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 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(); } $('#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'; } $('#themeBtn').addEventListener('click', () => { const next = effectiveTheme() === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = next; store.set('theme', next); }); /* ================= 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 = `(?` }); } 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: '' }); } } 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)) + ''; 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}.${p.ms}`; } function rowHTML(r, isNew) { const sev = r.severity || ''; const cls = SEV_CLASS[sev] || 'info'; return `
` + `` + `` + `${esc(sev || '—')}` + (r.host ? `${esc(r.host_name || r.host)}` : '') + (r.app ? `${esc(r.app)}` : '') + `
${highlight(r._msg)}
` + '
'; } // 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]))} (${esc(r[k])})` : esc(r[k])); return '
' + keys.map((k) => `
${esc(label[k] || k)}
${val(k)}
`).join('') + '
' + (r.host ? `` : '') + (r.app ? `` : '') + `` + `` + '
'; } 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 += ``; } el.innerHTML = `
${bars}
${label(start)}${t('histoMax', fmtNum(max))}${label(end)}
`; } 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 ? `${fmtNum(s.dropped)}` : fmtNum(0); let html = esc(t('stats', n)).replace('%DROPPED%', dropped); if (s.lastError) html += ` · ${esc(t('storageErr') + s.lastError)}`; 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 = ``; for (const [group, zones] of Object.entries(TIME_ZONES)) { html += ``; for (const z of zones) { if (!validTimeZone(z)) continue; html += ``; } html += ''; } const tzSel = $('#tzSelect'); tzSel.innerHTML = html; tzSel.value = timePrefs.tz; const fmtSel = $('#fmtSelect'); fmtSel.innerHTML = TIME_FORMATS.map((f) => ``).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) => ``; return `
${esc(tag.pattern || t('preview'))}
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))} ${opt('caseSensitive', 'Aa', t('optCaseTitle'))} ${opt('regex', '.*', t('optRegexTitle'))} ${opt('enabled', t('optActive'), t('optActiveTitle'))}
`; } function renderTagList() { $('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || `

${esc(t('noTags'))}

`; } 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(); loadPurgeStatus(); $('#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; } 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); })();