Modes Flux et Période avec dates de début et de fin #19

Merged
cedric merged 1 commits from feat/mode-periode into main 2026-10-03 22:45:55 +02:00
5 changed files with 192 additions and 33 deletions

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 Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
présentation sur deux lignes, sans colonnes. 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 ## Frise
La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure 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). lignes).
- Au survol d'un intervalle : ses bornes, son total et le détail par sévérité. - 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 - Un clic sur une barre affiche cet intervalle en mode Période ; un glisser sur plusieurs barres
sélection. La plage de temps affiche alors la période zoomée (« × Annuler le zoom » ou le affiche la sélection. « × Revenir au flux » repasse en mode Flux. La liste, les compteurs et
choix d'une autre plage en sort) ; la liste, les compteurs et l'export CSV suivent le zoom, l'export CSV suivent la période.
et le direct se met en pause.
- En direct, le dernier intervalle grandit à l'arrivée des messages, et la frise se recharge à - 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é ; 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. 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 (**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its
two-line layout without columns. 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 ## Timeline
The timeline above the list shows the volume of logs per interval, counted by **reception 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). time** on the server clock (so it matches the times shown in the rows).
- Hover an interval: its bounds, total and detail per severity. - 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. - Click a bar to show that interval in Time range mode, or drag across several bars to show the
The time range then shows the zoomed period ("× Reset zoom" or any other range leaves it); selection; "× Back to stream" returns to Stream mode. The list, the counters and the CSV export
the list, the counters and the CSV export follow the zoom, and the live view pauses. follow the range.
- In live mode the last interval grows as messages arrive, and the timeline reloads at each new - 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. 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.', 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.', readOnlyNote: 'Read-only account: these settings can only be changed by an administrator.',
err_purge_confirm: 'Type PURGE to confirm', err_purge_confirm: 'Type PURGE to confirm',
liveZoomed: 'Live view is not available on a zoomed range', liveZoomed: 'Live view is only available in Stream mode',
connZoom: 'live paused (zoom)', 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', histoTitle: 'Timeline',
hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color', hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color',
hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None', hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None',
hRender: 'Display', hBars: 'Bars', hArea: 'Area', hRender: 'Display', hBars: 'Bars', hArea: 'Area',
hStep: 'Division', hAuto: 'Automatic', hRefresh: 'Refresh', hOff: 'Off', hRefreshAuto: 'At each new interval', 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_err: 'error and above', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×', hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)', hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)',
hDivision: (s) => `interval ${s}`, hCapped: 'enlarged', hDivision: (s) => `interval ${s}`, hCapped: 'enlarged',
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been 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', toTop: 'Back to top',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message', 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', 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.', 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.', readOnlyNote: 'Compte en lecture seule : seul un administrateur peut modifier ces réglages.',
err_purge_confirm: 'Tapez PURGE pour confirmer', err_purge_confirm: 'Tapez PURGE pour confirmer',
liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée', liveZoomed: 'Le direct n\'est disponible qu\'en mode Flux',
connZoom: 'direct en pause (zoom)', 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', histoTitle: 'Frise',
hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur', hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur',
hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune', hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune',
hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire', hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire',
hStep: 'Division', hAuto: 'Automatique', hRefresh: 'Rafraîchissement', hOff: 'Désactivé', hRefreshAuto: 'À chaque nouvel intervalle', 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_err: 'error et plus', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calme', hLeg_burst: 'rafale > 3×', hLeg_anom: 'anomalie > 10×', 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é)', hTotal: 'Total', hRatio: (x) => `${x}× la médiane`, hUnshown: 'sans détail (débit élevé)',
hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi', hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi',
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.', 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', toTop: 'Revenir en haut',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message', 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', codesTitle: 'Codes des tags de couleur trouvés dans le message',
@@ -493,7 +503,7 @@ const state = {
live: store.get('live', '1') === '1', live: store.get('live', '1') === '1',
es: null, es: null,
reqId: 0, 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, tookMs: null,
conn: ['', 'connPaused'], conn: ['', 'connPaused'],
stats: null, stats: null,
@@ -522,7 +532,7 @@ function setLang(next) {
setConn(...state.conn); setConn(...state.conn);
renderList(); renderList();
updateCount(state.tookMs); updateCount(state.tookMs);
setZoomOption(); renderPeriod();
renderHisto(); renderHisto();
renderStats(); renderStats();
renderTagList(); renderTagList();
@@ -1435,33 +1445,115 @@ function showHistoSel() {
sel.style.width = ((b - a + 1) / d.count) * 100 + '%'; sel.style.width = ((b - a + 1) / d.count) * 100 + '%';
} }
function setZoomOption() { /* ---- Display mode: Stream or Time range ---- */
const sel = $('#range');
let opt = sel.querySelector('option[value="custom"]'); // datetime-local fields hold a wall time in the chosen time zone.
if (!state.zoom) { function toWall(ms, sec) {
if (opt) opt.remove(); const p = zoneParts(new Date(ms));
return; 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); } $('#range').hidden = on;
opt.textContent = fmtSpan(state.zoom.from, state.zoom.to, state.zoom.to - state.zoom.from < 3e5); $('#period').hidden = !on;
sel.value = 'custom'; 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) { function zoomTo(from, to) {
state.zoom = { from: Math.round(from), to: Math.round(to) }; state.zoom = { from: Math.round(from), to: Math.round(to) };
setZoomOption(); store.set('period', JSON.stringify(state.zoom));
renderPeriod();
hideHistoTip(); hideHistoTip();
refresh(); refresh();
} }
function unzoom() { function unzoom() {
state.zoom = null; state.zoom = null;
setZoomOption(); store.set('period', '');
renderPeriod();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h'; if (!$('#range').value) $('#range').value = '1h';
refresh(); 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'); const el = $('#histo');
el.innerHTML = '<div class="h-plot"><svg class="h-svg" preserveAspectRatio="none" aria-hidden="true"></svg>' 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) => { 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); if (!state.zoom) store.set('range', $('#range').value);
store.set('severity', $('#severity').value); store.set('severity', $('#severity').value);
refresh(); refresh();
@@ -1750,7 +1838,7 @@ function onTimePrefsChange() {
store.set('timefmt', timePrefs.fmt); store.set('timefmt', timePrefs.fmt);
updateTimePreview(); updateTimePreview();
renderList(); renderList();
setZoomOption(); renderPeriod();
refreshHisto(); // intervals are aligned on the local time refreshHisto(); // intervals are aligned on the local time
} }
$('#tzSelect').addEventListener('change', onTimePrefsChange); $('#tzSelect').addEventListener('change', onTimePrefsChange);
@@ -2350,6 +2438,11 @@ applyLang();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h'; if (!$('#range').value) $('#range').value = '1h';
$('#severity').value = store.get('severity', ''); $('#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), // 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 // 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> </header>
<section class="filters"> <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"> <select id="range" data-i18n-aria="rangeAria">
<option value="5m" data-i18n="r5m">5 min</option> <option value="5m" data-i18n="r5m">5 min</option>
<option value="15m" data-i18n="r15m">15 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; font-size: 13px; max-width: 220px;
} }
.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); } .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; } .spacer { flex: 1; }
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; } #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; } .live .lbl { display: none; }
.filters { padding: 10px 16px 6px; } .filters { padding: 10px 16px 6px; }
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; } .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; } .spacer { display: none; }
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.histo { padding: 0 16px 6px; } .histo { padding: 0 16px 6px; }