'use strict';
/* ================= Utilitaires ================= */
const $ = (sel) => document.querySelector(sel);
const PAGE_SIZE = 500; // lignes chargées par recherche
const MAX_ROWS = 2000; // lignes gardées à l'écran en mode direct
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 { /* stockage indisponible */ } },
};
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 t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
const fmtNum = (n) => Number(n || 0).toLocaleString('fr-FR');
// Date.parse n'accepte que 3 décimales partout : on tronque les nanosecondes.
const parseTime = (s) => new Date(String(s || '').replace(/(\.\d{3})\d+/, '$1'));
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 { /* réponse non JSON */ }
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function toast(msg) {
const el = $('#toast');
// Une fenêtre modale passe au-dessus de tout : on y place le toast pour qu'il reste visible.
const dlg = $('#tagsDlg');
(dlg.open ? dlg : document.body).append(el);
el.textContent = msg;
el.hidden = false;
clearTimeout(toast.t);
toast.t = setTimeout(() => { el.hidden = true; }, 1800);
}
/* ================= État ================= */
const state = {
rows: [], // enregistrements affichés, du plus récent au plus ancien
pending: [], // messages reçus en direct pendant que l'on fait défiler
tags: [],
matchers: [], // tags compilés + termes recherchés
mode: store.get('mode', 'simple'),
live: store.get('live', '1') === '1',
es: null,
reqId: 0,
};
const recOf = new WeakMap(); // élément .row -> enregistrement
const list = $('#list');
/* ================= Thème ================= */
function effectiveTheme() {
const t = document.documentElement.dataset.theme;
if (t) return t;
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);
});
/* ================= Filtres ================= */
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
? 'LogsQL : error AND host:web-01 | sort by (_time)'
: 'Rechercher… error -timeout "disk full" (touche /)';
const btn = $('#live');
btn.disabled = !liveAvailable();
btn.classList.toggle('on', state.live && liveAvailable());
btn.title = liveAvailable() ? 'Afficher les nouveaux messages en direct' : 'Le direct n\'est pas disponible en mode LogsQL';
for (const id of ['severity', 'host', 'app']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
}
/* ================= Tags et surlignage ================= */
// Couleur de texte lisible (noir ou blanc) selon la luminosité du fond.
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, '\\$&');
function compileMatchers() {
const out = [];
for (const t of state.tags) {
if (!t.enabled || !t.pattern) continue;
let src = t.regex ? t.pattern : escapeRe(t.pattern);
if (t.wholeWord) src = `(?` });
} catch (e) {
console.warn('Tag ignoré (expression invalide) :', t.pattern, e.message);
}
}
// Les termes de la recherche simple sont soulignés discrètement, avec la priorité la plus basse.
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; // chevauchement : le premier arrivé l'emporte
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));
}
/* ================= Rendu de la liste ================= */
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);
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
function rowHTML(r, isNew) {
const sev = r.severity || '';
const cls = SEV_CLASS[sev] || 'info';
return `
Aucun tag.
'; } const saveTimers = {}; function scheduleSave(t, rowEl) { clearTimeout(saveTimers[t.id]); saveTimers[t.id] = setTimeout(async () => { const errEl = rowEl.querySelector('.err'); try { await api('/api/tags/' + encodeURIComponent(t.id), { method: 'PUT', body: t }); 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 t = state.tags.find((x) => x.id === rowEl.dataset.id); if (!t) return; t[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value; rowEl.classList.toggle('off', !t.enabled); const mark = rowEl.querySelector('.preview mark'); mark.textContent = t.pattern || 'aperçu'; mark.setAttribute('style', tagStyle(t.color)); scheduleSave(t, rowEl); applyTags(); }); $('#tagList').addEventListener('click', async (ev) => { const del = ev.target.closest('[data-del]'); if (!del) return; const rowEl = del.closest('.tag-row'); const t = state.tags.find((x) => x.id === rowEl.dataset.id); if (!t || !confirm(`Supprimer le tag « ${t.pattern} » ?`)) return; try { await api('/api/tags/' + encodeURIComponent(t.id), { method: 'DELETE' }); state.tags = state.tags.filter((x) => x !== t); renderTagList(); applyTags(); } catch (e) { toast(e.message); } }); $('#addTag').addEventListener('click', async () => { const color = PALETTE[state.tags.length % PALETTE.length]; try { const t = await api('/api/tags', { method: 'POST', body: { pattern: 'nouveau', color, wholeWord: true, enabled: true } }); state.tags.push(t); 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('Remplacer tous les tags par les tags par défaut (warning, error, ok) ?')) return; try { state.tags = await api('/api/tags/reset', { method: 'POST' }); renderTagList(); applyTags(); } catch (e) { toast(e.message); } }); $('#tagsBtn').addEventListener('click', () => { renderTagList(); $('#tagsDlg').showModal(); }); $('#closeTags').addEventListener('click', () => $('#tagsDlg').close()); $('#tagsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); }); /* ================= Démarrage ================= */ $('#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); })();