Stream and Time range display modes with start/end fields

A segmented control at the start of the filter bar switches between
Stream (sliding duration, live view) and Time range (start and end
dates in the chosen time zone, previous/next and zoom-out buttons).
Timeline clicks and drags switch to Time range mode; the mode and
range are kept across reloads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-03 20:37:05 +02:00
1 parent 389a679c9f
commit aff7cf8839
5 files changed
+192 -33

No files matched your search

+13 -4
View File
@@ -102,6 +102,16 @@ restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Inter
Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
présentation sur deux lignes, sans colonnes.
## Modes Flux et Période
Le premier contrôle de la barre de filtres bascule entre deux modes d'affichage :
- **Flux** : les derniers logs sur une durée glissante (5 min à 30 jours, ou tout), avec le direct.
- **Période** : les logs entre une date de début et une date de fin, saisies dans le fuseau choisi
dans les Paramètres. ◀ et ▶ passent à la période précédente ou suivante de même durée, la loupe
la double autour de son milieu. Le direct se met en pause ; le mode et la période sont conservés
au rechargement.
## Frise
La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure
@@ -109,10 +119,9 @@ de réception** sur l'horloge du serveur (elle correspond donc aux heures affich
lignes).
- Au survol d'un intervalle : ses bornes, son total et le détail par sévérité.
- Un clic sur une barre zoome sur cet intervalle ; un glisser sur plusieurs barres zoome sur la
sélection. La plage de temps affiche alors la période zoomée (« × Annuler le zoom » ou le
choix d'une autre plage en sort) ; la liste, les compteurs et l'export CSV suivent le zoom,
et le direct se met en pause.
- Un clic sur une barre affiche cet intervalle en mode Période ; un glisser sur plusieurs barres
affiche la sélection. « × Revenir au flux » repasse en mode Flux. La liste, les compteurs et
l'export CSV suivent la période.
- En direct, le dernier intervalle grandit à l'arrivée des messages, et la frise se recharge à
chaque nouvel intervalle. Rien n'est rafraîchi tant que l'onglet du navigateur est masqué ;
la frise se met à jour dès qu'il redevient visible.
+13 -3
View File
@@ -92,15 +92,25 @@ width; the message takes the remaining space. Widths are remembered by the brows
(**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its
two-line layout without columns.
## Stream and Time range modes
The first control of the filter bar switches between two display modes:
- **Stream**: the latest logs over a sliding duration (5 min to 30 days, or all), with the live
view.
- **Time range**: the logs between a start and an end date, typed in the time zone chosen in
Settings. ◀ and ▶ move to the previous or next range of the same length, the magnifier doubles
it around its middle. The live view pauses; the mode and the range are kept across reloads.
## Timeline
The timeline above the list shows the volume of logs per interval, counted by **reception
time** on the server clock (so it matches the times shown in the rows).
- Hover an interval: its bounds, total and detail per severity.
- Click a bar to zoom on that interval, or drag across several bars to zoom on the selection.
The time range then shows the zoomed period ("× Reset zoom" or any other range leaves it);
the list, the counters and the CSV export follow the zoom, and the live view pauses.
- Click a bar to show that interval in Time range mode, or drag across several bars to show the
selection; "× Back to stream" returns to Stream mode. The list, the counters and the CSV export
follow the range.
- In live mode the last interval grows as messages arrive, and the timeline reloads at each new
interval. Nothing is refreshed while the browser tab is hidden; it catches up when shown again.
+119 -26
View File
@@ -150,20 +150,25 @@ const I18N = {
authOff: 'No authentication: anyone who can reach this page can read the logs and change the settings. Set AUTH_USER / AUTH_PASS or AUTH_MODE=oidc.',
readOnlyNote: 'Read-only account: these settings can only be changed by an administrator.',
err_purge_confirm: 'Type PURGE to confirm',
liveZoomed: 'Live view is not available on a zoomed range',
connZoom: 'live paused (zoom)',
liveZoomed: 'Live view is only available in Stream mode',
connZoom: 'live paused (time range)',
viewAria: 'Display mode', viewStream: 'Stream', viewPeriod: 'Time range',
viewStreamTitle: 'Stream: the latest logs over a sliding duration, updated live',
viewPeriodTitle: 'Time range: the logs between a start and an end date',
perFrom: 'Start', perTo: 'End', perPrev: 'Previous time range', perNext: 'Next time range',
perOut: 'Zoom out (twice as long)',
histoTitle: 'Timeline',
hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color',
hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None',
hRender: 'Display', hBars: 'Bars', hArea: 'Area',
hStep: 'Division', hAuto: 'Automatic', hRefresh: 'Refresh', hOff: 'Off', hRefreshAuto: 'At each new interval',
histoHelp: 'Click a bar to zoom on its interval, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.',
histoHelp: 'Click a bar to show its interval in Time range mode, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.',
hLeg_err: 'error and above', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)',
hDivision: (s) => `interval ${s}`, hCapped: 'enlarged',
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Reset zoom', unitDay: 'd',
hUnzoom: '× Back to stream', unitDay: 'd',
toTop: 'Back to top',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
codesTitle: 'Codes of the color tags found in the message',
@@ -319,20 +324,25 @@ const I18N = {
authOff: 'Aucune authentification : toute personne qui atteint cette page peut lire les logs et changer les réglages. Définissez AUTH_USER / AUTH_PASS ou AUTH_MODE=oidc.',
readOnlyNote: 'Compte en lecture seule : seul un administrateur peut modifier ces réglages.',
err_purge_confirm: 'Tapez PURGE pour confirmer',
liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée',
connZoom: 'direct en pause (zoom)',
liveZoomed: 'Le direct n\'est disponible qu\'en mode Flux',
connZoom: 'direct en pause (période)',
viewAria: 'Mode d\'affichage', viewStream: 'Flux', viewPeriod: 'Période',
viewStreamTitle: 'Flux : les derniers logs sur une durée glissante, mis à jour en direct',
viewPeriodTitle: 'Période : les logs entre une date de début et une date de fin',
perFrom: 'Début', perTo: 'Fin', perPrev: 'Période précédente', perNext: 'Période suivante',
perOut: 'Élargir (deux fois plus long)',
histoTitle: 'Frise',
hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur',
hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune',
hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire',
hStep: 'Division', hAuto: 'Automatique', hRefresh: 'Rafraîchissement', hOff: 'Désactivé', hRefreshAuto: 'À chaque nouvel intervalle',
histoHelp: 'Cliquez sur une barre pour zoomer sur son intervalle, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.',
histoHelp: 'Cliquez sur une barre pour afficher son intervalle en mode Période, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.',
hLeg_err: 'error et plus', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calme', hLeg_burst: 'rafale > 3×', hLeg_anom: 'anomalie > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× la médiane`, hUnshown: 'sans détail (débit élevé)',
hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi',
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Annuler le zoom', unitDay: 'j',
hUnzoom: '× Revenir au flux', unitDay: 'j',
toTop: 'Revenir en haut',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
codesTitle: 'Codes des tags de couleur trouvés dans le message',
@@ -493,7 +503,7 @@ const state = {
live: store.get('live', '1') === '1',
es: null,
reqId: 0,
zoom: null, // {from, to} in ms when the timeline is zoomed (range "custom")
zoom: null, // {from, to} in ms in Time range mode (range "custom"), null in Stream mode
tookMs: null,
conn: ['', 'connPaused'],
stats: null,
@@ -522,7 +532,7 @@ function setLang(next) {
setConn(...state.conn);
renderList();
updateCount(state.tookMs);
setZoomOption();
renderPeriod();
renderHisto();
renderStats();
renderTagList();
@@ -1435,33 +1445,115 @@ function showHistoSel() {
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;
/* ---- Display mode: Stream or Time range ---- */
// datetime-local fields hold a wall time in the chosen time zone.
function toWall(ms, sec) {
const p = zoneParts(new Date(ms));
return `${p.Y}-${p.M}-${p.D}T${p.h}:${p.m}${sec ? ':' + p.s : ''}`;
}
function zoneOffsetMs(ms) {
const m = /^([+-])(\d\d):(\d\d)$/.exec(zoneParts(new Date(ms)).off);
return m ? (m[1] === '-' ? -1 : 1) * (m[2] * 36e5 + m[3] * 6e4) : 0;
}
function fromWall(v) {
const m = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d))?/.exec(v || '');
if (!m) return NaN;
const wall = Date.UTC(m[1], m[2] - 1, m[3], m[4], m[5], m[6] || 0);
const guess = wall - zoneOffsetMs(wall);
return wall - zoneOffsetMs(guess); // second pass: right offset around DST changes
}
const serverNow = () => Date.now() + (histo.data ? histo.data.offset : 0);
function renderPeriod() {
const on = !!state.zoom;
for (const b of $('#viewMode').querySelectorAll('[data-view]')) {
b.setAttribute('aria-checked', String((b.dataset.view === 'period') === on));
}
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';
$('#range').hidden = on;
$('#period').hidden = !on;
if (!on) return;
const { from, to } = state.zoom;
const sec = from % 6e4 !== 0 || to % 6e4 !== 0;
for (const [el, v] of [[$('#perFrom'), from], [$('#perTo'), to]]) {
el.step = sec ? 1 : 60;
el.classList.remove('bad');
if (document.activeElement !== el) el.value = toWall(v, sec);
}
$('#perNext').disabled = to >= serverNow();
}
function zoomTo(from, to) {
state.zoom = { from: Math.round(from), to: Math.round(to) };
setZoomOption();
store.set('period', JSON.stringify(state.zoom));
renderPeriod();
hideHistoTip();
refresh();
}
function unzoom() {
state.zoom = null;
setZoomOption();
store.set('period', '');
renderPeriod();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
refresh();
}
// Entering Time range mode starts from what the stream was showing.
function enterPeriod() {
if (state.zoom) return;
const d = histo.data;
if (d) return zoomTo(d.start, d.start + d.count * d.step);
const to = Math.ceil(serverNow() / 6e4) * 6e4;
zoomTo(to - (RANGE_MS[$('#range').value] || 36e5), to);
}
$('#viewMode').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-view]');
if (!b) return;
if (b.dataset.view === 'period') enterPeriod();
else if (state.zoom) unzoom();
});
const onPeriodInput = debounce(() => {
if (!state.zoom) return;
const from = fromWall($('#perFrom').value);
const to = fromWall($('#perTo').value);
const ok = !isNaN(from) && !isNaN(to) && to > from;
$('#perFrom').classList.toggle('bad', isNaN(from) || (!isNaN(to) && !ok));
$('#perTo').classList.toggle('bad', isNaN(to) || (!isNaN(from) && !ok));
if (ok && (from !== state.zoom.from || to !== state.zoom.to)) zoomTo(from, to);
}, 500);
for (const id of ['perFrom', 'perTo']) {
$('#' + id).addEventListener('input', onPeriodInput);
$('#' + id).addEventListener('keydown', (ev) => { if (ev.key === 'Enter') ev.target.blur(); });
$('#' + id).addEventListener('blur', () => { if (!$('#' + id).classList.contains('bad')) renderPeriod(); });
}
// ◀ ▶ move by the width of the range, − doubles it around its middle.
function shiftPeriod(dir) {
const { from, to } = state.zoom;
const w = to - from;
zoomTo(from + dir * w, to + dir * w);
}
$('#perPrev').addEventListener('click', () => shiftPeriod(-1));
$('#perNext').addEventListener('click', () => shiftPeriod(1));
$('#perOut').addEventListener('click', () => {
const { from, to } = state.zoom;
const w = to - from;
let a = from - w / 2;
let b = to + w / 2;
const now = serverNow();
const end = Math.max(to, now); // widen into the past rather than the future
if (b > end) { a -= b - end; b = end; }
const r = w >= 12e4 ? 6e4 : 1e3; // whole minutes, or seconds for short ranges
zoomTo(Math.round(a / r) * r, Math.round(b / r) * r);
});
{
const el = $('#histo');
el.innerHTML = '<div class="h-plot"><svg class="h-svg" preserveAspectRatio="none" aria-hidden="true"></svg>'
@@ -1583,10 +1675,6 @@ async function refresh() {
}
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();
@@ -1750,7 +1838,7 @@ function onTimePrefsChange() {
store.set('timefmt', timePrefs.fmt);
updateTimePreview();
renderList();
setZoomOption();
renderPeriod();
refreshHisto(); // intervals are aligned on the local time
}
$('#tzSelect').addEventListener('change', onTimePrefsChange);
@@ -2350,6 +2438,11 @@ applyLang();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
$('#severity').value = store.get('severity', '');
try { // Time range mode is kept across reloads
const z = JSON.parse(store.get('period', '') || 'null');
if (z && z.to > z.from) state.zoom = { from: z.from, to: z.to };
} catch { /* stream mode */ }
renderPeriod();
// Without a login, warn that the UI is open to everyone. With a login (local or OIDC),
// show who is logged in and the log out button, and lock the admin settings of a
+27
View File
@@ -50,6 +50,33 @@
</header>
<section class="filters">
<div id="viewMode" class="seg view-mode" role="radiogroup" data-i18n-aria="viewAria">
<button type="button" role="radio" data-view="stream" data-i18n-title="viewStreamTitle">
<!-- Lucide "radio" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
<span data-i18n="viewStream">Stream</span>
</button>
<button type="button" role="radio" data-view="period" data-i18n-title="viewPeriodTitle">
<!-- Lucide "calendar-range" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M3 10h18M8 2v4M17 14h-6M13 18H7M7 14h.01M17 18h.01"/></svg>
<span data-i18n="viewPeriod">Time range</span>
</button>
</div>
<div id="period" class="period" hidden>
<button id="perPrev" class="btn tool" type="button" data-i18n-title="perPrev" data-i18n-aria="perPrev">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<input id="perFrom" type="datetime-local" data-i18n-title="perFrom" data-i18n-aria="perFrom">
<span class="muted" aria-hidden="true">→</span>
<input id="perTo" type="datetime-local" data-i18n-title="perTo" data-i18n-aria="perTo">
<button id="perNext" class="btn tool" type="button" data-i18n-title="perNext" data-i18n-aria="perNext">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
</button>
<button id="perOut" class="btn tool" type="button" data-i18n-title="perOut" data-i18n-aria="perOut">
<!-- Lucide "zoom-out" (ISC license) -->
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3M8 11h6"/></svg>
</button>
</div>
<select id="range" data-i18n-aria="rangeAria">
<option value="5m" data-i18n="r5m">5 min</option>
<option value="15m" data-i18n="r15m">15 min</option>
+20
View File
@@ -240,6 +240,19 @@ body.busy .progress::after {
font-size: 13px; max-width: 220px;
}
.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); }
/* Display mode: Stream or Time range, then the start → end fields of the range */
.view-mode { padding: 2px; gap: 2px; border-radius: 9px; }
.view-mode button { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; }
.view-mode svg { width: 15px; height: 15px; }
.period { display: inline-flex; align-items: center; gap: 6px; }
.period .btn.tool { width: 32px; padding: 0; justify-content: center; }
.period input {
height: 32px; padding: 0 8px;
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
font-size: 13px; font-variant-numeric: tabular-nums; color-scheme: light dark;
}
.period input:focus { outline: 0; box-shadow: 0 0 0 3px var(--accent-soft); }
.period input.bad { border-color: var(--sev-err); background: color-mix(in srgb, var(--sev-err) 10%, transparent); }
.spacer { flex: 1; }
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; }
@@ -711,6 +724,13 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.live .lbl { display: none; }
.filters { padding: 10px 16px 6px; }
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; }
.view-mode { flex: 1 1 100%; }
.view-mode button { flex: 1; justify-content: center; }
/* Phones: the two dates on one line, the ◀ ▶ − buttons below */
.period { flex: 1 1 100%; flex-wrap: wrap; }
.period > span { display: none; }
.period input { flex: 1 1 calc(50% - 3px); min-width: 0; }
.period .btn.tool { order: 1; flex: 1; }
.spacer { display: none; }
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.histo { padding: 0 16px 6px; }