First commit

This commit is contained in:
cedric committed 2026-09-28 12:19:29 +02:00
commit 8200c2bc87
17 files changed
+2592

No files matched your search

+586
View File
@@ -0,0 +1,586 @@
'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('logwell.' + k); return v === null ? def : v; } catch { return def; } },
set(k, v) { try { localStorage.setItem('logwell.' + k, v); } catch { /* stockage indisponible */ } },
};
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 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 = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
try {
out.push({ re: new RegExp(src, 'gu' + (t.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(t.color)}">` });
} 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: '<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; // chevauchement : le premier arrivé l'emporte
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));
}
/* ================= 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())}<span class="ms">.${pad(d.getMilliseconds(), 3)}</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>${fmtTime(r._time)}</time>`
+ `<span class="sev">${esc(sev || '—')}</span>`
+ `<span class="host" title="${esc(r.host)}">${esc(r.host)}</span>`
+ `<span class="app" title="${esc(r.app)}">${esc(r.app)}</span>`
+ `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>';
}
// Associe les n premiers (ou derniers) éléments .row à leurs enregistrements.
function bindRows(first, rows) {
let el = first;
for (const r of rows) {
if (!el) break;
recOf.set(el, r);
el = el.nextElementSibling;
}
}
function renderList() {
list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join('');
bindRows(list.firstElementChild, state.rows);
$('#empty').hidden = state.rows.length > 0;
}
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;
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) {
const n = state.rows.length;
const took = tookMs != null ? ` · ${tookMs} ms` : '';
$('#count').textContent = `${fmtNum(n)} ${n > 1 ? 'logs affichés' : 'log affiché'}${took}`;
}
function detailsHTML(r) {
const order = ['_time', 'host', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const hidden = new Set(['_stream_id', '_stream', 'sevnum']);
const keys = order.filter((k) => r[k] != null && r[k] !== '')
.concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort());
const label = { _time: 'horodatage', _msg: 'message', procid: 'pid', sd: 'données structurées' };
const val = (k) => (k === '_time' ? `${esc(parseTime(r[k]).toLocaleString('fr-FR'))} <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">Filtrer sur cet hôte</button>' : '')
+ (r.app ? '<button class="btn" data-act="app">Filtrer sur cette appli</button>' : '')
+ '<button class="btn" data-act="copy">Copier le message</button>'
+ '<button class="btn" data-act="json">Copier en JSON</button>'
+ '</div></div>';
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
} catch {
// Repli pour les pages servies en HTTP simple (hors localhost).
const ta = Object.assign(document.createElement('textarea'), { value: text });
document.body.append(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
toast('Copié');
}
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; // on sélectionne du texte : ne pas replier
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();
});
/* ================= Histogramme ================= */
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 label = (k) => {
const d = new Date(k * stepMs);
return stepMs >= 864e5 ? d.toLocaleDateString('fr-FR') : d.toLocaleString('fr-FR', { 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>max ${fmtNum(max)} / intervalle</span><span>${label(end)}</span></div>`;
}
async function refreshHisto() {
try { renderHisto(await api('/api/histogram?' + params())); } catch { renderHisto(null); }
}
/* ================= Recherche ================= */
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;
state.rows = res.rows || [];
state.pending = [];
$('#newPill').hidden = true;
renderList();
updateCount(res.took_ms);
setError(null);
} catch (e) {
if (id !== state.reqId) return;
state.rows = [];
renderList();
$('#empty').hidden = true;
$('#count').textContent = '';
setError('Erreur de recherche : ' + e.message);
} 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) && !$('#tagsDlg').open) {
ev.preventDefault();
$('#q').focus();
}
});
/* ================= Direct (SSE) ================= */
function setConn(cls, text) {
const el = $('#conn');
el.className = 'conn ' + cls;
el.textContent = text;
}
function connectLive() {
if (state.es) { state.es.close(); state.es = null; }
if (!state.live || !liveAvailable()) {
setConn('', state.live ? 'direct indisponible (LogsQL)' : 'direct en pause');
return;
}
const es = new EventSource('/api/stream?' + params());
state.es = es;
setConn('', 'connexion…');
es.onopen = () => setConn('ok', 'direct');
es.onerror = () => setConn('ko', 'reconnexion…');
es.onmessage = (ev) => {
const data = JSON.parse(ev.data);
const rows = (data.rows || []).reverse(); // le plus récent en premier
if (data.skipped) toast(`${fmtNum(data.skipped)} messages non affichés (débit trop élevé)`);
if (window.scrollY > 80) {
state.pending = rows.concat(state.pending).slice(0, MAX_ROWS);
const pill = $('#newPill');
pill.textContent = `↑ ${fmtNum(state.pending.length)} nouveau${state.pending.length > 1 ? 'x' : ''} log${state.pending.length > 1 ? 's' : ''}`;
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(); // rattrape ce qui a été reçu pendant la pause
else { syncControls(); connectLive(); }
});
/* ================= Listes hôtes / applis, statistiques ================= */
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((v) => new Option(v, v)));
if (cur) setSelect(id, cur);
}
} catch { /* VictoriaLogs pas encore prêt : on réessaiera */ }
}
async function loadStats() {
try {
const s = await api('/api/stats');
const err = s.lastError ? ` · <span class="bad">stockage : ${esc(s.lastError)}</span>` : '';
const dropped = s.dropped ? `<span class="bad">perdus ${fmtNum(s.dropped)}</span>` : 'perdus 0';
$('#stats').innerHTML = `reçus ${fmtNum(s.received)} · stockés ${fmtNum(s.ingested)} · ${dropped} · file ${fmtNum(s.queue)}${err}`;
} catch {
$('#stats').textContent = 'serveur injoignable';
}
}
/* ================= Gestion des tags ================= */
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
async function loadTags() {
try { state.tags = await api('/api/tags'); } catch (e) { toast('Tags : ' + e.message); }
compileMatchers();
}
function tagRowHTML(t) {
const opt = (field, label, title) =>
`<label class="opt" title="${title}"><input type="checkbox" data-f="${field}"${t[field] ? ' checked' : ''}>${label}</label>`;
return `<div class="tag-row${t.enabled ? '' : ' off'}" data-id="${esc(t.id)}">
<input type="color" value="${esc(t.color)}" data-f="color" aria-label="Couleur">
<input type="text" value="${esc(t.pattern)}" data-f="pattern" placeholder="mot-clé" spellcheck="false" aria-label="Mot-clé">
<span class="preview"><mark class="tag" style="${tagStyle(t.color)}">${esc(t.pattern || 'aperçu')}</mark></span>
<div class="opts">
${opt('wholeWord', 'mot entier', 'Ne colorer que les mots entiers (évite « ok » dans « token »)')}
${opt('caseSensitive', 'Aa', 'Respecter la casse')}
${opt('regex', '.*', 'Expression régulière')}
${opt('enabled', 'actif', 'Activer ce tag')}
</div>
<button class="icon-btn danger" type="button" data-del title="Supprimer" aria-label="Supprimer">
<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">Aucun tag.</p>';
}
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);
})();
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logwell</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
<script>
// Applique le thème mémorisé avant l'affichage, pour éviter un flash.
try { var t = localStorage.getItem('logwell.theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
</script>
</head>
<body>
<header class="topbar">
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
<span>Logwell</span>
</div>
<div class="search">
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false" aria-label="Rechercher dans les logs">
<button id="mode" class="chip" type="button" title="Basculer entre recherche simple et LogsQL">Simple</button>
</div>
<div class="actions">
<button id="live" class="btn live" type="button" title="Afficher les nouveaux messages en direct">
<span class="dot"></span><span class="lbl">Direct</span>
</button>
<button id="tagsBtn" class="icon-btn" type="button" title="Tags de couleur" aria-label="Tags de couleur">
<svg viewBox="0 0 24 24"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></svg>
</button>
<button id="themeBtn" class="icon-btn" type="button" title="Thème clair / sombre" aria-label="Changer de thème">
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</div>
<div class="progress" aria-hidden="true"></div>
</header>
<section class="filters">
<select id="range" aria-label="Période">
<option value="5m">5 min</option>
<option value="15m">15 min</option>
<option value="1h" selected>1 heure</option>
<option value="6h">6 heures</option>
<option value="24h">24 heures</option>
<option value="7d">7 jours</option>
<option value="30d">30 jours</option>
<option value="all">Tout</option>
</select>
<select id="severity" aria-label="Sévérité">
<option value="">Toutes sévérités</option>
<option value="2">Critiques et +</option>
<option value="3">Erreurs et +</option>
<option value="4">Warnings et +</option>
<option value="5">Notice et +</option>
<option value="6">Info et +</option>
</select>
<select id="host" aria-label="Hôte"><option value="">Tous les hôtes</option></select>
<select id="app" aria-label="Application"><option value="">Toutes les applis</option></select>
<span class="spacer"></span>
<span id="count" class="muted"></span>
</section>
<section id="histo" class="histo" aria-label="Volume de logs dans le temps"></section>
<div id="error" class="error-banner" hidden></div>
<button id="newPill" class="pill" type="button" hidden></button>
<main id="list" class="list"></main>
<div id="empty" class="empty" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/></svg>
<p><strong>Aucun log ne correspond.</strong></p>
<p class="muted">Élargissez la période ou envoyez des messages syslog sur le port 514 (UDP ou TCP).</p>
</div>
<footer class="status">
<span id="conn" class="conn"></span>
<span id="stats" class="muted"></span>
</footer>
<dialog id="tagsDlg" aria-labelledby="tagsTitle">
<div class="dlg">
<header>
<h2 id="tagsTitle">Tags de couleur</h2>
<button id="closeTags" class="icon-btn" type="button" aria-label="Fermer">
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
</button>
</header>
<p class="muted small">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.</p>
<div id="tagList" class="tag-list"></div>
<footer>
<button id="addTag" class="btn primary" type="button">+ Ajouter un tag</button>
<button id="resetTags" class="btn ghost" type="button">Rétablir les tags par défaut</button>
</footer>
</div>
</dialog>
<div id="toast" class="toast" hidden></div>
<script src="app.js"></script>
</body>
</html>
+380
View File
@@ -0,0 +1,380 @@
/* ---------- Thème : tout passe par ces variables ---------- */
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--panel-2: #f0f2f5;
--border: #e2e5ea;
--text: #1a1d23;
--muted: #6b7280;
--accent: #6366f1;
--accent-soft: rgba(99, 102, 241, .12);
--row-hover: #f5f6fa;
--hit: rgba(99, 102, 241, .18);
--shadow: 0 10px 30px rgba(15, 23, 42, .12);
--sev-crit: #be123c;
--sev-err: #dc2626;
--sev-warning: #d97706;
--sev-notice: #2563eb;
--sev-info: #0d9488;
--sev-debug: #9ca3af;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--radius: 10px;
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #0f1115;
--panel: #161a20;
--panel-2: #1d222a;
--border: #2a3039;
--text: #e6e8ec;
--muted: #8b93a1;
--accent: #818cf8;
--accent-soft: rgba(129, 140, 248, .16);
--row-hover: #1a1f26;
--hit: rgba(129, 140, 248, .28);
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
--sev-crit: #fb7185;
--sev-err: #f87171;
--sev-warning: #fbbf24;
--sev-notice: #60a5fa;
--sev-info: #2dd4bf;
--sev-debug: #6b7280;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1115;
--panel: #161a20;
--panel-2: #1d222a;
--border: #2a3039;
--text: #e6e8ec;
--muted: #8b93a1;
--accent: #818cf8;
--accent-soft: rgba(129, 140, 248, .16);
--row-hover: #1a1f26;
--hit: rgba(129, 140, 248, .28);
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
--sev-crit: #fb7185;
--sev-err: #f87171;
--sev-warning: #fbbf24;
--sev-notice: #60a5fa;
--sev-info: #2dd4bf;
--sev-debug: #6b7280;
color-scheme: dark;
}
}
/* ---------- Base ---------- */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 var(--sans);
padding-bottom: 40px;
}
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.muted { color: var(--muted); }
.small { font-size: 12.5px; }
[hidden] { display: none !important; }
/* ---------- Barre du haut ---------- */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 16px;
padding: 10px 20px;
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: saturate(160%) blur(10px);
-webkit-backdrop-filter: saturate(160%) blur(10px);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 16px; letter-spacing: -.01em; }
.brand svg { width: 26px; height: 26px; }
.brand svg rect { fill: var(--accent); stroke: none; }
.brand svg path { stroke: #fff; stroke-width: 3; }
.search {
flex: 1; max-width: 720px;
display: flex; align-items: center; gap: 8px;
padding: 0 6px 0 12px; height: 38px;
background: var(--panel-2);
border: 1px solid transparent;
border-radius: var(--radius);
transition: border-color .15s, box-shadow .15s;
}
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search .ico { width: 17px; height: 17px; color: var(--muted); flex: none; }
.search input {
flex: 1; min-width: 0; height: 100%;
border: 0; outline: 0; background: transparent;
font-family: var(--mono); font-size: 13px;
}
.search input::-webkit-search-cancel-button { cursor: pointer; }
.chip {
flex: none;
border: 1px solid var(--border); background: var(--panel);
border-radius: 7px; padding: 3px 9px; font-size: 12px; font-weight: 600;
color: var(--muted);
}
.chip.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.icon-btn {
width: 36px; height: 36px; display: grid; place-items: center;
border: 1px solid transparent; background: transparent; border-radius: 9px; color: var(--muted);
}
.icon-btn:hover { background: var(--panel-2); color: var(--text); }
.icon-btn svg { width: 19px; height: 19px; }
.icon-btn.danger:hover { color: var(--sev-err); }
/* Soleil ou lune selon le thème effectif */
#themeBtn .moon { display: none; }
:root[data-theme="dark"] #themeBtn .sun { display: none; }
:root[data-theme="dark"] #themeBtn .moon { display: block; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) #themeBtn .sun { display: none; }
:root:not([data-theme="light"]) #themeBtn .moon { display: block; }
}
.btn {
display: inline-flex; align-items: center; gap: 7px;
height: 34px; padding: 0 12px;
border: 1px solid var(--border); border-radius: 9px;
background: var(--panel); font-weight: 550; font-size: 13px;
}
.btn:hover { background: var(--panel-2); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); }
.btn.ghost:hover { color: var(--text); background: var(--panel-2); }
.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.live.on { color: var(--sev-info); border-color: color-mix(in srgb, var(--sev-info) 45%, var(--border)); }
.live.on .dot { background: var(--sev-info); animation: pulse 1.6s infinite; }
.live:disabled { opacity: .5; cursor: not-allowed; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sev-info) 60%, transparent); }
70% { box-shadow: 0 0 0 7px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.progress { position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; overflow: hidden; }
body.busy .progress::after {
content: ""; position: absolute; inset: 0; width: 30%;
background: var(--accent);
animation: slide 1s ease-in-out infinite;
}
@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(400%); } }
/* ---------- Filtres ---------- */
.filters {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding: 12px 20px 8px;
}
.filters select {
height: 32px; padding: 0 28px 0 10px;
border: 1px solid var(--border); border-radius: 8px;
background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b93a1' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 8px center / 14px;
appearance: none; -webkit-appearance: none;
font-size: 13px; max-width: 220px;
}
.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); }
.spacer { flex: 1; }
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; }
/* ---------- Histogramme ---------- */
.histo { padding: 0 20px 6px; }
.histo .bars {
height: 46px; display: flex; align-items: flex-end; gap: 1px;
border-bottom: 1px solid var(--border);
}
.histo .bars i {
flex: 1; min-width: 1px; background: var(--accent); opacity: .55;
border-radius: 2px 2px 0 0;
}
.histo .bars i:hover { opacity: 1; }
.histo .axis { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); padding-top: 3px; }
/* ---------- Liste des logs ---------- */
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.list:empty { display: none; }
.row {
display: grid;
grid-template-columns: 8.4rem 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr;
gap: 0 12px; align-items: baseline;
padding: 5px 14px 5px 11px;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--border);
font-family: var(--mono); font-size: 12.5px;
cursor: pointer;
}
.row:last-child { border-bottom: 0; }
.row:hover { background: var(--row-hover); }
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.row.new { animation: flash 1.2s ease-out; }
@keyframes flash { from { background: var(--accent-soft); } to { background: transparent; } }
.row time { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.row time .ms { opacity: .6; }
.row .host, .row .app { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .host { font-weight: 600; }
.row .app { color: var(--muted); }
.row .msg { white-space: pre-wrap; word-break: break-word; min-width: 0; }
.sev {
justify-self: start;
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
padding: 1px 6px; border-radius: 5px;
color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent);
}
.sev-crit { --sev: var(--sev-crit); }
.sev-err { --sev: var(--sev-err); }
.sev-warning { --sev: var(--sev-warning); }
.sev-notice { --sev: var(--sev-notice); }
.sev-info { --sev: var(--sev-info); }
.sev-debug { --sev: var(--sev-debug); }
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 4%, transparent); }
mark.tag {
padding: 0 4px; border-radius: 4px; font-weight: 650;
box-decoration-break: clone; -webkit-box-decoration-break: clone;
}
mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding: 0 1px; }
/* Détails d'un message */
.details {
grid-column: 1 / -1;
margin: 8px 0 4px; padding: 10px 12px;
background: var(--panel-2); border-radius: 8px;
cursor: auto; font-size: 12px;
}
.details dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; }
.details dt { color: var(--muted); }
.details dd { margin: 0; word-break: break-all; }
.details .acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; font-family: var(--sans); }
.details .acts .btn { height: 28px; font-size: 12px; }
/* ---------- États ---------- */
.empty { text-align: center; padding: 60px 20px; }
.empty svg { width: 40px; height: 40px; color: var(--muted); opacity: .6; }
.empty p { margin: 6px 0; }
.error-banner {
margin: 6px 20px 0; padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--sev-err) 40%, transparent);
background: color-mix(in srgb, var(--sev-err) 10%, transparent);
color: var(--sev-err); border-radius: var(--radius);
font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap;
}
.pill {
position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30;
border: 0; border-radius: 999px; padding: 7px 16px;
background: var(--accent); color: #fff; font-weight: 600; font-size: 13px;
box-shadow: var(--shadow);
}
.status {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
display: flex; gap: 16px; align-items: center;
padding: 6px 20px; font-size: 12px;
background: var(--panel); border-top: 1px solid var(--border);
font-variant-numeric: tabular-nums;
}
.conn::before { content: "●"; margin-right: 6px; color: var(--muted); }
.conn.ok::before { color: var(--sev-info); }
.conn.ko::before { color: var(--sev-err); }
#stats .bad { color: var(--sev-err); }
.toast {
position: fixed; bottom: 48px; left: 50%; transform: translateX(-50%); z-index: 50;
background: var(--text); color: var(--bg);
padding: 8px 14px; border-radius: 8px; font-size: 13px; box-shadow: var(--shadow);
}
/* ---------- Fenêtre des tags ---------- */
dialog {
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
padding: 0; border: 1px solid var(--border); border-radius: 14px;
background: var(--panel); color: var(--text); box-shadow: var(--shadow);
}
dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); }
.dlg { padding: 18px 20px 16px; }
.dlg header { display: flex; align-items: center; justify-content: space-between; }
.dlg h2 { margin: 0; font-size: 17px; }
.dlg > footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.tag-row {
display: grid; align-items: center; gap: 8px 10px;
grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px;
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
background: var(--panel-2);
}
.tag-row.off { opacity: .55; }
.tag-row input[type="color"] {
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
background: none; cursor: pointer;
}
.tag-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 3px; }
.tag-row input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 5px; }
.tag-row input[type="text"] {
height: 32px; padding: 0 10px; min-width: 0;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
font-family: var(--mono); font-size: 13px; outline: 0;
}
.tag-row input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.tag-row .preview { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
.tag-row .opts { display: flex; gap: 4px; }
.opt {
display: inline-flex; align-items: center; height: 28px; padding: 0 8px;
border: 1px solid var(--border); border-radius: 7px; background: var(--panel);
font-size: 11.5px; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none;
}
.opt input { display: none; }
.opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; }
.tag-row .err:empty { display: none; }
/* ---------- Mobile ---------- */
@media (max-width: 760px) {
body { font-size: 13.5px; }
.topbar { flex-wrap: wrap; padding: 8px 16px; gap: 8px 10px; }
.search { order: 3; flex-basis: 100%; max-width: none; }
.live .lbl { display: none; }
.filters { padding: 10px 16px 6px; }
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; }
.spacer { display: none; }
#count { flex-basis: 100%; }
.histo { padding: 0 16px 6px; }
.list, .error-banner { margin-left: 16px; margin-right: 16px; }
.row {
grid-template-columns: auto auto 1fr;
grid-template-areas: "time sev host" "msg msg msg";
gap: 3px 8px; padding: 8px 12px 8px 10px;
}
.row time { grid-area: time; }
.row .sev { grid-area: sev; }
.row .host { grid-area: host; justify-self: end; max-width: 100%; }
.row .app { display: none; }
.row .msg { grid-area: msg; }
.details { grid-column: 1 / -1; }
.details dl { grid-template-columns: 1fr; }
.details dt { margin-top: 4px; }
.status { padding: 6px 16px; }
.tag-row { grid-template-columns: 38px 1fr 36px; }
.tag-row .preview { display: none; }
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
.tag-row [data-del] { grid-column: 3; grid-row: 1; }
}