); no stored width = automatic default (CSS).
const COLS = { rcv: [60, 420], mt: [60, 420], sev: [36, 200], host: [50, 600], app: [40, 600] }; // [min, max]
const clampCol = (k, w) => Math.min(COLS[k][1], Math.max(COLS[k][0], Math.round(w)));
function applyColWidths() {
const table = $('#table');
for (const k of Object.keys(COLS)) {
const w = parseInt(store.get('cols.' + k, ''), 10);
if (w > 0) table.style.setProperty('--col-' + k, clampCol(k, w) + 'px');
else table.style.removeProperty('--col-' + k);
}
}
$('#listHead').addEventListener('pointerdown', (ev) => {
const grip = ev.target.closest('.grip');
if (!grip || ev.button !== 0) return;
ev.preventDefault();
const k = grip.parentElement.dataset.col;
const x0 = ev.clientX;
const w0 = grip.parentElement.getBoundingClientRect().width;
const table = $('#table');
grip.setPointerCapture(ev.pointerId);
grip.classList.add('active');
document.body.classList.add('col-resizing');
const move = (e) => table.style.setProperty('--col-' + k, clampCol(k, w0 + e.clientX - x0) + 'px');
const end = () => {
grip.removeEventListener('pointermove', move);
grip.removeEventListener('pointerup', end);
grip.removeEventListener('pointercancel', end);
grip.classList.remove('active');
document.body.classList.remove('col-resizing');
const w = parseInt(table.style.getPropertyValue('--col-' + k), 10);
if (w > 0 && w !== Math.round(w0)) store.set('cols.' + k, String(w));
};
grip.addEventListener('pointermove', move);
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
});
// Double-click on a handle: back to the automatic width.
$('#listHead').addEventListener('dblclick', (ev) => {
const grip = ev.target.closest('.grip');
if (!grip) return;
store.set('cols.' + grip.parentElement.dataset.col, '');
applyColWidths();
});
$('#resetCols').addEventListener('click', () => {
for (const k of Object.keys(COLS)) store.set('cols.' + k, '');
applyColWidths();
toast(t('colsReset'));
});
// The header sticks right under the top bar, whose height changes with the layout.
new ResizeObserver(([e]) => {
document.documentElement.style.setProperty('--topbar-h', Math.ceil(e.target.getBoundingClientRect().height) + 'px');
}).observe($('.topbar'));
applyColWidths();
/* ================= Timeline (histogram) ================= */
// Settings > Interface > Timeline, remembered per browser; the first value is the default.
const HISTO_OPTS = {
scale: ['sqrt', 'linear', 'log'],
height: ['m', 's', 'l'],
color: ['severity', 'intensity', 'none'],
render: ['bars', 'area'],
step: ['auto', '1s', '10s', '1m', '5m', '1h', '1d'],
refresh: ['auto', 'off', '5', '15', '30', '60'],
};
const HISTO_HEIGHTS = { s: 40, m: 80, l: 120 };
const histoPrefs = {};
for (const [k, vals] of Object.entries(HISTO_OPTS)) {
const v = store.get('histo.' + k, vals[0]);
histoPrefs[k] = vals.includes(v) ? v : vals[0];
}
const HISTO_LAG = 1500; // logs reach VictoriaLogs in batches (FLUSH_MS): resync a bit after a boundary
const SEV_ORDER = ['emerg', 'alert', 'crit', 'err', 'warning', 'notice', 'info', 'debug'];
// Stacked layers, bottom to top: error and above, warning, everything else.
const sevLayer = (sev) => { const n = SEV_ORDER.indexOf(sev); return n < 0 ? 2 : n <= 3 ? 0 : n === 4 ? 1 : 2; };
const SEV_LAYERS = ['err', 'warn', 'other'];
const INTENSITY = ['calm', 'burst', 'anom']; // <= 3x, > 3x, > 10x the median of the window
const histo = {
data: null, // see buildHisto
reqId: 0,
slideTimer: 0, // end of the last interval
refreshTimer: 0, // periodic refresh (5 s … 1 min)
resyncTimer: 0, // reload shortly after a new interval
drag: null, // {a, b}: interval indexes of a drag selection
ticksKey: '',
ticks: [],
};
const sliding = () => !state.zoom; // the window follows the current time
// Server answer -> dense arrays. offset = server clock - browser clock: the
// timeline runs on the server clock, like the reception times it counts.
function buildHisto(res, localNow) {
const d = {
step: res.step, start: res.start, count: res.count, capped: res.capped, offset: res.now - localNow,
n: [], layers: [], sev: [],
};
for (let i = 0; i < d.count; i++) { d.n.push(0); d.layers.push([0, 0, 0]); d.sev.push({}); }
for (const b of res.buckets || []) {
if (!(b.i >= 0 && b.i < d.count)) continue;
d.n[b.i] = b.n;
for (const [s, c] of Object.entries(b.sev || {})) {
d.sev[b.i][s] = c;
d.layers[b.i][sevLayer(s)] += c;
}
}
return d;
}
async function refreshHisto() {
const id = ++histo.reqId;
clearTimeout(histo.resyncTimer);
const p = params();
p.set('step', histoPrefs.step);
p.set('tz', timePrefs.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
const t0 = Date.now();
let res = null;
try { res = await api('/api/histogram?' + p); } catch { /* shown as an empty timeline */ }
if (id !== histo.reqId) return;
histo.data = res && buildHisto(res, (t0 + Date.now()) / 2);
renderHisto();
scheduleSlide();
scheduleRefresh();
}
// Moves the window k intervals forward (new empty intervals on the right).
function histoSlide(k, resync) {
const d = histo.data;
k = Math.min(Math.max(k, 0), d.count);
if (!k) return;
d.start += k * d.step;
for (const a of [d.n, d.layers, d.sev]) a.splice(0, k);
for (let j = 0; j < k; j++) { d.n.push(0); d.layers.push([0, 0, 0]); d.sev.push({}); }
if (resync && !document.hidden) {
clearTimeout(histo.resyncTimer);
histo.resyncTimer = setTimeout(refreshHisto, HISTO_LAG);
}
}
const liveOn = () => state.live && liveAvailable();
// At the end of the last interval: slide, so that the axis keeps up with the
// clock even without new logs. Off only when nothing refreshes the timeline.
function scheduleSlide() {
clearTimeout(histo.slideTimer);
const d = histo.data;
if (!d || !sliding() || document.hidden || (!liveOn() && histoPrefs.refresh === 'off')) return;
const delay = d.start + d.count * d.step - (Date.now() + d.offset);
histo.slideTimer = setTimeout(() => {
const x = histo.data;
if (!x) return;
const late = Date.now() + x.offset - (x.start + x.count * x.step);
histoSlide(Math.floor(late / x.step) + 1, liveOn() || histoPrefs.refresh === 'auto');
renderHisto();
scheduleSlide();
}, Math.min(Math.max(delay, 50), 2 ** 31 - 1));
}
function scheduleRefresh() {
clearTimeout(histo.refreshTimer);
const sec = Number(histoPrefs.refresh);
if (!sec || !histo.data || !sliding() || document.hidden) return;
histo.refreshTimer = setTimeout(refreshHisto, sec * 1000);
}
function scheduleHisto() {
scheduleSlide();
scheduleRefresh();
}
// Live view: count the new messages right away; the next resync corrects the
// approximations (messages skipped by the stream, late arrivals).
function histoAddLive(rows, skipped) {
const d = histo.data;
if (!d || !sliding() || document.hidden) return;
let changed = false;
for (const r of rows) {
const t = parseTime(r.received || r._time).getTime();
if (isNaN(t)) continue;
let i = Math.floor((t - d.start) / d.step);
if (i >= d.count) {
histoSlide(i - d.count + 1, true);
i = Math.floor((t - d.start) / d.step);
}
if (i < 0 || i >= d.count) continue;
const sev = r.severity || 'info';
d.n[i]++;
d.layers[i][sevLayer(sev)]++;
d.sev[i][sev] = (d.sev[i][sev] || 0) + 1;
changed = true;
}
if (skipped > 0) {
d.n[d.count - 1] += skipped;
d.layers[d.count - 1][2] += skipped;
changed = true;
}
if (changed) renderHisto();
}
/* ---- Rendering ---- */
function histoScale(n, max) {
if (n <= 0 || max <= 0) return 0;
const v = histoPrefs.scale === 'linear' ? n / max
: histoPrefs.scale === 'log' ? Math.log1p(n) / Math.log1p(max)
: Math.sqrt(n / max);
return Math.max(v, 0.04); // a single log stays visible
}
// Median of the non-empty intervals (0 when too few to judge).
function histoMedian(d) {
const v = d.n.filter((x) => x > 0).sort((a, b) => a - b);
if (v.length < 3) return 0;
const m = v.length >> 1;
return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2;
}
const intensityOf = (n, med) => (!med || !n ? 0 : n > 10 * med ? 2 : n > 3 * med ? 1 : 0);
// Local wall-clock offset (ms) of a date in the selected time zone.
function zoneOffset(ms) {
const m = /^([+-])(\d\d):(\d\d)$/.exec(zoneParts(new Date(ms)).off);
return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) * 60000 : 0;
}
const TICK_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800, 604800]
.map((s) => s * 1000);
const dayLabel = (p) => (lang === 'fr' ? `${p.D}/${p.M}` : `${p.M}/${p.D}`);
// Vertical graduations on interval boundaries, at least ~70 px apart, on
// round local times; labels hh:mm:ss, hh:mm or dd/mm depending on the spacing.
function histoTicks(d, width) {
const key = [d.start, d.step, d.count, width, timePrefs.tz, lang].join();
if (histo.ticksKey === key) return histo.ticks;
const pxPerMs = width / (d.count * d.step);
const every = TICK_STEPS.find((s) => s >= d.step && s % d.step === 0 && s * pxPerMs >= 70)
|| d.step * Math.max(1, Math.ceil(70 / (pxPerMs * d.step)));
const endMs = d.start + d.count * d.step;
const off0 = zoneOffset(d.start);
const sameOffset = off0 === zoneOffset(endMs); // no DST change in the window
const ticks = [];
for (let i = 0; i <= d.count; i++) {
const t = d.start + i * d.step;
const wall = t + (sameOffset ? off0 : zoneOffset(t));
if (((wall % every) + every) % every !== 0) continue;
const x = i / d.count;
if (x < 0.03 || x > 0.97) continue; // labels would be cut at the edges
const p = zoneParts(new Date(t));
const midnight = wall % 864e5 === 0;
const label = every >= 864e5 || (midnight && every >= 6e4) ? dayLabel(p)
: every < 6e4 ? `${p.h}:${p.m}:${p.s}` : `${p.h}:${p.m}`;
ticks.push({ x, label, day: midnight });
}
histo.ticksKey = key;
histo.ticks = ticks;
return ticks;
}
// "01/10 19:52 → 20:07" (with seconds for short intervals).
function fmtSpan(from, to, withSec) {
const a = zoneParts(new Date(from));
const b = zoneParts(new Date(to - 1));
const e = zoneParts(new Date(to));
const hm = (p) => `${p.h}:${p.m}${withSec ? ':' + p.s : ''}`;
return `${dayLabel(a)} ${hm(a)} → ${a.D === b.D && a.M === b.M ? '' : dayLabel(e) + ' '}${hm(e)}`;
}
function fmtStep(ms) {
const units = [[864e5, t('unitDay')], [36e5, 'h'], [6e4, 'min'], [1e3, 's']];
const [size, unit] = units.find(([u]) => ms % u === 0) || units[3];
return `${fmtNum(ms / size)} ${unit}`;
}
function histoSVG(d) {
const N = d.count;
const max = Math.max(0, ...d.n);
if (!max) return '';
const area = histoPrefs.render === 'area';
const mode = histoPrefs.color;
const med = mode === 'intensity' ? histoMedian(d) : 0;
const top = (n) => histoScale(n, max) * 100; // height in % of the plot
const y = (h) => (100 - h).toFixed(2);
if (!area) {
const g = N > 120 ? 0.08 : 0.15;
const groups = {};
const rect = (cls, i, y0, h) => {
if (h <= 0) return;
groups[cls] = (groups[cls] || '') + ``;
};
for (let i = 0; i < N; i++) {
const n = d.n[i];
if (!n) continue;
const h = top(n);
if (mode === 'severity') {
let y0 = 0;
d.layers[i].forEach((c, k) => { const hk = (h * c) / n; rect('l-' + SEV_LAYERS[k], i, y0, hk); y0 += hk; });
} else {
rect(mode === 'intensity' ? 'l-' + INTENSITY[intensityOf(n, med)] : 'l-all', i, 0, h);
}
}
return Object.entries(groups).map(([cls, r]) => `${r}`).join('');
}
// Area: one point per interval center, stacked by severity when asked.
const line = (vals) => vals.map((v, i) => `${i === 0 ? '0' : (i + 0.5).toFixed(2)},${y(v)}`).join(' L')
+ ` L${N},${y(vals[N - 1])}`;
const back = (vals) => [...vals.keys()].reverse().map((i) => `L${i === 0 ? '0' : (i + 0.5).toFixed(2)},${y(vals[i])}`).join(' ');
if (mode === 'severity') {
let below = d.n.map(() => 0);
let out = '';
for (let k = 0; k < 3; k++) {
const cum = d.n.map((n, i) => (n ? below[i] + (top(n) * d.layers[i][k]) / n : 0));
out += ``;
below = cum;
}
return out;
}
const tops = d.n.map(top);
const path = `M0,100 L${line(tops)} L${N},100 Z`;
if (mode !== 'intensity') return ``;
// Intensity: a horizontal gradient with one color band per interval.
let stops = '';
for (let i = 0; i < N; i++) {
const c = `var(--h-${INTENSITY[intensityOf(d.n[i], med)]})`;
stops += ``;
}
return `${stops}`
+ ``;
}
function histoLegend() {
const items = histoPrefs.color === 'severity' ? SEV_LAYERS.map((k) => [k, t('hLeg_' + k)])
: histoPrefs.color === 'intensity' ? INTENSITY.map((k) => [k, t('hLeg_' + k)]) : [];
return items.map(([k, label]) => `${esc(label)}`).join('');
}
function renderHisto() {
const el = $('#histo');
const d = histo.data;
el.style.setProperty('--histo-h', HISTO_HEIGHTS[histoPrefs.height] + 'px');
el.hidden = !d;
if (!d) return;
const plot = el.querySelector('.h-plot');
const svg = el.querySelector('.h-svg');
svg.setAttribute('viewBox', `0 0 ${d.count} 100`);
svg.innerHTML = histoSVG(d);
const ticks = histoTicks(d, plot.clientWidth || 1000);
el.querySelector('.h-grid').innerHTML = ticks.map((k) => ``).join('');
el.querySelector('.h-axis').innerHTML = ticks.map((k) => `${esc(k.label)}`).join('');
const max = Math.max(0, ...d.n);
el.querySelector('.h-meta').innerHTML = `${histoLegend()}`
+ `${esc(t('histoMax', fmtNum(max)))} · ${esc(t('hDivision', fmtStep(d.step)))}`
+ (d.capped ? ` ${esc(t('hCapped'))}` : '') + ''
+ (state.zoom ? `` : '');
if (histo.tipIdx != null) showHistoTip(histo.tipIdx);
}
/* ---- Tooltip, zoom ---- */
function histoIdx(ev) {
const d = histo.data;
const r = $('#histo .h-plot').getBoundingClientRect();
return Math.min(d.count - 1, Math.max(0, Math.floor(((ev.clientX - r.left) / r.width) * d.count)));
}
function showHistoTip(i, clientX) {
const d = histo.data;
const tip = $('#histo .h-tip');
const hover = $('#histo .h-hover');
if (!d || i >= d.count) { hideHistoTip(); return; }
histo.tipIdx = i;
const from = d.start + i * d.step;
const n = d.n[i];
let html = `${esc(fmtSpan(from, from + d.step, d.step < 6e4))}
`
+ `${esc(t('hTotal'))}${fmtNum(n)}
`;
if (histoPrefs.color === 'intensity' && n) {
const med = histoMedian(d);
if (med) html += `${esc(t('hLeg_' + INTENSITY[intensityOf(n, med)]))}${esc(t('hRatio', (n / med).toLocaleString(t('locale'), { maximumFractionDigits: 1 })))}
`;
}
const sevs = Object.keys(d.sev[i]).sort((a, b) => SEV_ORDER.indexOf(a) - SEV_ORDER.indexOf(b));
for (const s of sevs) {
html += `${esc(s)}${fmtNum(d.sev[i][s])}
`;
}
const other = n - sevs.reduce((sum, s) => sum + d.sev[i][s], 0);
if (other > 0) html += `${esc(t('hUnshown'))}${fmtNum(other)}
`;
tip.innerHTML = html;
tip.hidden = false;
const plot = $('#histo .h-plot');
const w = plot.clientWidth;
hover.hidden = false;
hover.style.left = (i / d.count) * 100 + '%';
hover.style.width = 100 / d.count + '%';
if (clientX != null) histo.tipX = clientX - plot.getBoundingClientRect().left;
const x = histo.tipX ?? ((i + 0.5) / d.count) * w;
tip.style.left = Math.min(Math.max(x + 12 + plot.offsetLeft, 0), plot.offsetLeft + w - tip.offsetWidth) + 'px';
tip.style.top = plot.offsetTop + plot.offsetHeight + 4 + 'px';
}
function hideHistoTip() {
histo.tipIdx = null;
$('#histo .h-tip').hidden = true;
$('#histo .h-hover').hidden = true;
}
function showHistoSel() {
const d = histo.data;
const sel = $('#histo .h-sel');
const a = Math.min(histo.drag.a, histo.drag.b);
const b = Math.max(histo.drag.a, histo.drag.b);
sel.hidden = false;
sel.style.left = (a / d.count) * 100 + '%';
sel.style.width = ((b - a + 1) / d.count) * 100 + '%';
}
function setZoomOption() {
const sel = $('#range');
let opt = sel.querySelector('option[value="custom"]');
if (!state.zoom) {
if (opt) opt.remove();
return;
}
if (!opt) { opt = new Option('', 'custom'); sel.add(opt); }
opt.textContent = fmtSpan(state.zoom.from, state.zoom.to, state.zoom.to - state.zoom.from < 3e5);
sel.value = 'custom';
}
function zoomTo(from, to) {
state.zoom = { from: Math.round(from), to: Math.round(to) };
setZoomOption();
hideHistoTip();
refresh();
}
function unzoom() {
state.zoom = null;
setZoomOption();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
refresh();
}
{
const el = $('#histo');
el.innerHTML = ''
+ '';
el.hidden = true;
const plot = el.querySelector('.h-plot');
plot.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 || !histo.data) return;
const i = histoIdx(ev);
histo.drag = { a: i, b: i };
plot.setPointerCapture(ev.pointerId);
showHistoSel();
});
plot.addEventListener('pointermove', (ev) => {
if (!histo.data) return;
const i = histoIdx(ev);
if (histo.drag) { histo.drag.b = i; showHistoSel(); }
showHistoTip(i, ev.clientX);
});
plot.addEventListener('pointerup', () => {
const d = histo.data;
const dr = histo.drag;
histo.drag = null;
el.querySelector('.h-sel').hidden = true;
if (!dr || !d) return;
const a = Math.min(dr.a, dr.b);
const b = Math.max(dr.a, dr.b);
zoomTo(d.start + a * d.step, d.start + (b + 1) * d.step); // click = one interval, drag = the selection
});
plot.addEventListener('pointercancel', () => { histo.drag = null; el.querySelector('.h-sel').hidden = true; });
plot.addEventListener('pointerleave', () => { if (!histo.drag) hideHistoTip(); });
el.querySelector('.h-meta').addEventListener('click', (ev) => {
if (ev.target.closest('[data-act="unzoom"]')) unzoom();
});
plot.title = '';
window.addEventListener('resize', debounce(() => renderHisto(), 200));
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearTimeout(histo.slideTimer);
clearTimeout(histo.refreshTimer);
clearTimeout(histo.resyncTimer);
} else if (histo.data && sliding()) {
refreshHisto(); // catch up on what happened while the tab was hidden
}
});
}
/* ---- Settings > Interface > Timeline ---- */
const HISTO_SELECT_LABELS = {
step: (v) => (v === 'auto' ? t('hAuto') : fmtStep({ s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[v.slice(-1)] * parseInt(v, 10))),
refresh: (v) => (v === 'auto' ? t('hRefreshAuto') : v === 'off' ? t('hOff') : fmtStep(Number(v) * 1000)),
};
function renderHistoSettings() {
for (const seg of document.querySelectorAll('.seg[data-histo]')) {
for (const b of seg.querySelectorAll('[data-v]')) b.setAttribute('aria-checked', String(b.dataset.v === histoPrefs[seg.dataset.histo]));
}
for (const sel of document.querySelectorAll('select[data-histo]')) {
const k = sel.dataset.histo;
sel.innerHTML = HISTO_OPTS[k].map((v) => ``).join('');
sel.value = histoPrefs[k];
}
}
function setHistoPref(k, v) {
if (!HISTO_OPTS[k] || !HISTO_OPTS[k].includes(v)) return;
histoPrefs[k] = v;
store.set('histo.' + k, v);
renderHistoSettings();
if (k === 'step') refreshHisto();
else if (k === 'refresh') { scheduleHisto(); if (v !== 'off') refreshHisto(); }
else renderHisto();
}
document.addEventListener('click', (ev) => {
const b = ev.target.closest('.seg[data-histo] [data-v]');
if (b) setHistoPref(b.closest('.seg').dataset.histo, b.dataset.v);
});
for (const sel of document.querySelectorAll('select[data-histo]')) {
sel.addEventListener('change', () => setHistoPref(sel.dataset.histo, sel.value));
}
/* ================= 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 = (ev) => {
if (ev && ev.target.id === 'range' && state.zoom && ev.target.value !== 'custom') {
state.zoom = null; // another range chosen: leave the zoom
setZoomOption();
}
if (!state.zoom) 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', 'source']) $('#' + 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 ? 'connPaused' : state.zoom ? 'connZoom' : 'connLogsql');
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)));
histoAddLive(rows, 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 });
// "Back to top" button, shown once the page has scrolled a little.
const syncToTop = () => { $('#toTop').hidden = window.scrollY < 400; };
window.addEventListener('scroll', syncToTop, { passive: true });
$('#toTop').addEventListener('click', () => {
const smooth = !matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
});
syncToTop();
$('#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(); scheduleHisto(); }
});
/* ================= 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; }
if (state.stats && state.stats.exportMax) exportMax = state.stats.exportMax;
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 += `';
}
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();
setZoomOption();
refreshHisto(); // intervals are aligned on the local time
}
$('#tzSelect').addEventListener('change', onTimePrefsChange);
$('#fmtSelect').addEventListener('change', onTimePrefsChange);
setInterval(() => { if ($('#settingsDlg').open) updateTimePreview(); }, 1000);
/* ================= CSV export ================= */
let exportMax = 100000; // EXPORT_MAX on the server, refreshed from /api/stats
function setExportMenu(open) {
$('#exportMenu').hidden = !open;
$('#exportBtn').setAttribute('aria-expanded', String(open));
if (open) $('#exportNote').textContent = t('exportNote', fmtNum(exportMax));
}
async function exportCSV(excel) {
const p = params();
p.set('tz', timePrefs.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
if (excel) p.set('excel', '1');
const btn = $('#exportBtn');
const lbl = btn.querySelector('.lbl');
btn.disabled = true;
lbl.textContent = t('exporting');
try {
const res = await fetch('/api/export.csv?' + p);
if (!res.ok) {
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch { /* not JSON */ }
throw apiError(res, text, data);
}
const blob = await res.blob();
const name = ((res.headers.get('Content-Disposition') || '').match(/filename="([^"]+)"/) || [])[1] || 'logstream.csv';
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: name });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
toast(t('exportDone', name));
} catch (e) {
toast(t('exportError') + e.message);
} finally {
btn.disabled = false;
lbl.textContent = t('export');
}
}
$('#exportBtn').addEventListener('click', () => setExportMenu($('#exportMenu').hidden));
$('#exportMenu').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-export]');
if (!b) return;
setExportMenu(false);
exportCSV(b.dataset.export === 'excel');
});
document.addEventListener('click', (ev) => { if (!ev.target.closest('.export')) setExportMenu(false); });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExportMenu(false); });
/* ================= Syslog source ================= */
let syslogState = null;
async function loadSyslog() {
try { syslogState = await api('/api/syslog'); } catch { syslogState = null; }
renderSyslog();
}
function renderSyslog() {
const s = syslogState;
const status = $('#syslogStatus');
if (!s) { status.textContent = ''; return; }
$('#syslogEnabled').checked = s.enabled;
for (const b of document.querySelectorAll('#syslogProtos [data-proto]')) {
const on = !!s[b.dataset.proto];
b.classList.toggle('on', on);
b.setAttribute('aria-pressed', String(on));
b.disabled = !s.enabled;
}
$('#syslogProtos').classList.toggle('off', !s.enabled);
$('#syslogPortVal').textContent = s.port || s.internalAddr;
const internal = String(s.internalAddr || '').replace(/^:/, '');
$('#syslogPortHelp').textContent = t('syslogPortHelp', internal);
const errs = [];
if (s.udpError) errs.push(t('syslogErr', 'UDP') + s.udpError);
if (s.tcpError) errs.push(t('syslogErr', 'TCP') + s.tcpError);
const protos = [s.udpListening && 'UDP', s.tcpListening && 'TCP'].filter(Boolean).join(' + ');
if (errs.length) {
status.textContent = errs.join(' · ');
status.className = 'docker-status bad';
} else if (!s.enabled) {
status.textContent = t('syslogOff');
status.className = 'docker-status muted';
} else if (!protos) {
status.textContent = t('syslogNone');
status.className = 'docker-status muted';
} else {
status.textContent = t('syslogListening', { port: s.port || internal, protos });
status.className = 'docker-status good';
}
}
async function configureSyslog(change) {
const s = syslogState || { enabled: true, udp: true, tcp: true };
const body = { enabled: s.enabled, udp: s.udp, tcp: s.tcp, ...change };
syslogState = { ...s, ...body };
renderSyslog();
try { syslogState = await api('/api/syslog', { method: 'PUT', body }); } catch (e) { toast(e.message); }
renderSyslog();
}
$('#syslogEnabled').addEventListener('change', (ev) => configureSyslog({ enabled: ev.target.checked }));
$('#syslogProtos').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-proto]');
if (!b || b.disabled || !syslogState) return;
configureSyslog({ [b.dataset.proto]: !syslogState[b.dataset.proto] });
});
/* ================= Host system logs ================= */
let hostState = null;
async function loadHost() {
try { hostState = await api('/api/hostlogs'); } catch { hostState = null; }
renderHost();
}
function renderHost() {
const s = hostState;
const status = $('#hostStatus');
if (!s) { status.textContent = ''; return; }
$('#hostEnabled').checked = s.enabled;
let text = t('hostWaiting');
let cls = 'muted';
if (!s.enabled) {
text = t('hostOff');
} else if (s.code) {
text = t('host_' + s.code) + (s.code === 'read' || s.code === 'permission' ? s.error || '' : '');
cls = 'bad';
} else if (s.mode === 'journal') {
text = t('hostJournal', { n: s.files, r: fmtNum(s.read) }) + (s.compressed ? t('hostCompressed', fmtNum(s.compressed)) : '');
cls = 'good';
} else if (s.mode === 'files') {
text = t('hostFiles', { n: s.files, r: fmtNum(s.read) });
cls = 'good';
}
status.textContent = text;
status.className = 'docker-status ' + cls;
}
async function configureHost(enabled) {
hostState = { ...(hostState || {}), enabled };
renderHost();
try { hostState = await api('/api/hostlogs', { method: 'PUT', body: { enabled } }); } catch (e) { toast(e.message); }
renderHost();
setTimeout(loadHost, 1500); // the first scan runs in the background
}
$('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.checked));
/* ================= Docker source ================= */
let dockerState = null;
const dockerView = { showStopped: store.get('dockerShowStopped', '0') === '1', filter: '' };
const LOCK_ICON = '';
async function loadDocker() {
try { dockerState = await api('/api/docker'); } catch (e) { dockerState = { enabled: true, connected: false, error: e.message }; }
renderDocker();
}
// One label per choice key ("project/service"): replicas of a service share it.
function dockerChips() {
const byKey = new Map();
for (const c of (dockerState && dockerState.containers) || []) {
const k = byKey.get(c.key);
if (!k) {
byKey.set(c.key, { ...c, names: [c.name], running: c.state === 'running', following: c.following });
} else {
k.names.push(c.name);
k.running = k.running || c.state === 'running';
k.following = k.following || c.following;
}
}
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
}
function chipHTML(c) {
const state = c.locked ? t('stLocked') : !c.running ? t('stStopped') : c.following ? t('stFollowing') : c.enabled ? t('stSelected') : t('stIgnored');
const title = [c.key, c.names.join(', '), c.image, state + (c.locked ? ` (${t('stLockedTitle')})` : '')].join('\n');
const cls = ['chip', c.enabled && !c.locked ? 'on' : '', c.running ? '' : 'stopped', c.locked ? 'locked' : ''].filter(Boolean).join(' ');
return ``;
}
function renderDocker() {
const status = $('#dockerStatus');
const body = $('#dockerBody');
const d = dockerState;
if (!d) { status.textContent = ''; body.hidden = true; return; }
if (!d.enabled) {
status.textContent = t('dockerOff');
status.className = 'docker-status muted';
body.hidden = true;
return;
}
const cs = d.containers || [];
if (!d.connected) {
status.textContent = t('dockerErr') + (d.error || '');
status.className = 'docker-status bad';
} else {
status.textContent = t('dockerOk', { h: d.host || 'docker', n: cs.length, f: cs.filter((c) => c.following).length });
status.className = 'docker-status good';
}
body.hidden = !cs.length && !d.connected;
$('#dockerDefault').checked = !!d.defaultEnabled;
$('#dockerStopped').checked = dockerView.showStopped;
const chips = dockerChips();
$('#dockerStoppedLbl').textContent = t('dockerStopped', chips.filter((c) => !c.running && !c.locked).length);
const q = dockerView.filter.toLowerCase();
const visible = chips.filter((c) => (dockerView.showStopped || c.running) && (!q || c.key.toLowerCase().includes(q) || c.image.toLowerCase().includes(q)));
const groups = [
visible.filter((c) => c.enabled && !c.locked),
visible.filter((c) => !c.enabled && !c.locked),
visible.filter((c) => c.locked),
].filter((g) => g.length);
$('#dockerList').innerHTML = groups.length
? groups.map((g) => g.map(chipHTML).join('')).join('')
: `${esc(t('dockerNoMatch'))}`;
}
async function configureDocker(body) {
try {
dockerState = await api('/api/docker', { method: 'PUT', body });
renderDocker();
setTimeout(loadDocker, 1500); // followers start or stop in the background
} catch (e) { toast(e.message); loadDocker(); }
}
$('#dockerDefault').addEventListener('change', (ev) => configureDocker({ defaultEnabled: ev.target.checked }));
$('#dockerStopped').addEventListener('change', (ev) => {
dockerView.showStopped = ev.target.checked;
store.set('dockerShowStopped', dockerView.showStopped ? '1' : '0');
renderDocker();
});
$('#dockerFilter').addEventListener('input', (ev) => { dockerView.filter = ev.target.value.trim(); renderDocker(); });
$('#dockerList').addEventListener('click', (ev) => {
const chip = ev.target.closest('.chip[data-key]');
if (!chip || chip.disabled) return;
const key = chip.dataset.key;
const on = !chip.classList.contains('on');
// Immediate feedback, confirmed by the server answer.
for (const c of (dockerState && dockerState.containers) || []) if (c.key === key) c.enabled = on;
renderDocker();
configureDocker({ containers: { [key]: on } });
});
for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) {
$(id).addEventListener('click', () => {
// Applies to the labels currently shown (filter and stopped option included).
const q = dockerView.filter.toLowerCase();
const containers = {};
for (const c of dockerChips()) {
if (c.locked || (!dockerView.showStopped && !c.running)) continue;
if (q && !c.key.toLowerCase().includes(q) && !c.image.toLowerCase().includes(q)) continue;
containers[c.key] = on;
}
configureDocker({ containers });
});
}
// Keep the list fresh while the Sources tab is open.
setInterval(() => {
if ($('#settingsDlg').open && !document.querySelector('[data-panel="sources"]').hidden) {
loadDocker();
loadHost();
}
}, 4000);
/* ================= 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'];
// Ready-made tags. The patterns are valid in both JavaScript and Go (RE2).
// The HTTP ones cover nginx/Apache (common, combined), Traefik (CLF, JSON),
// Caddy (JSON) and HAProxy (httplog); for status codes and methods only the
// "hl" group is colored, not the context around it. The others target the
// usual messages of sshd/PAM, sudo, the kernel, systemd, UFW/iptables,
// fail2ban, Docker, PostgreSQL and MySQL/MariaDB.
const HTTP_STATUS_CTX = '(?:" |"(?:status|DownstreamStatus|OriginStatus|status_code)": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )';
const httpStatus = (d, color) => ({ label: `HTTP ${d}xx`, pattern: `${HTTP_STATUS_CTX}(?${d}\\d\\d)\\b`, color });
const httpMethod = (m, color) => ({ label: m.replace(/\|/g, '/'), pattern: `"(?${m})[ "]`, color, caseSensitive: true });
const PRESETS = {
http_status: () => [httpStatus(2, '#86efac'), httpStatus(3, '#93c5fd'), httpStatus(4, '#fdba74'), httpStatus(5, '#f87171')],
http_methods: () => [httpMethod('GET|HEAD|OPTIONS', '#cbd5e1'), httpMethod('POST|PUT|PATCH', '#c4b5fd'), httpMethod('DELETE', '#f9a8d4')],
http_probes: () => [{ label: t('pl_probes'), color: '#fda4af',
pattern: '(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|