Show server reception time before the message time

- Store a 'received' field (server clock) with every message
- New first column with the reception time
- Settings: time zone (browser, UTC, ~80 zones) and reception time
  format (YYYY-MM-DD - HH:MM:SS:mmm by default, ISO 8601, 12h, epoch...)
- The chosen time zone applies to every date shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-09-28 14:55:43 +02:00
1 parent 9bb1939a67
commit 85701a9119
5 files changed
+207 -14

No files matched your search

+163 -9
View File
@@ -53,6 +53,17 @@ const I18N = {
err_invalid_regex: 'Invalid regular expression',
err_tag_not_found: 'Tag not found',
err_live_logsql: 'Live view is not available in LogsQL mode',
dateTime: 'Date & time',
tzLabel: 'Time zone',
fmtLabel: 'Reception time format',
tzBrowser: (z) => `Browser time zone (${z})`,
tzGroups: { General: 'General', Europe: 'Europe', Africa: 'Africa', America: 'Americas', Asia: 'Asia', Oceania: 'Oceania & Pacific', Other: 'Atlantic & Indian Ocean' },
fmtEpoch: 'Unix epoch (milliseconds)',
timePreview: 'Preview: ',
timeHelp: 'The reception time comes from the server clock. The time zone applies to every date shown.',
fReceived: 'received',
rcvTitle: 'Received by the server',
msgTimeTitle: 'Timestamp from the message',
},
fr: {
locale: 'fr-FR',
@@ -104,6 +115,17 @@ const I18N = {
err_invalid_regex: 'Expression régulière invalide',
err_tag_not_found: 'Tag introuvable',
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire',
fmtLabel: 'Format de l\'heure de réception',
tzBrowser: (z) => `Fuseau du navigateur (${z})`,
tzGroups: { General: 'Général', Europe: 'Europe', Africa: 'Afrique', America: 'Amériques', Asia: 'Asie', Oceania: 'Océanie et Pacifique', Other: 'Atlantique et océan Indien' },
fmtEpoch: 'Epoch Unix (millisecondes)',
timePreview: 'Aperçu : ',
timeHelp: 'L\'heure de réception provient de l\'horloge du serveur. Le fuseau horaire s\'applique à toutes les dates affichées.',
fReceived: 'réception',
rcvTitle: 'Reçu par le serveur',
msgTimeTitle: 'Horodatage contenu dans le message',
},
};
@@ -134,6 +156,82 @@ const fmtNum = (n) => Number(n || 0).toLocaleString(t('locale'));
// Date.parse only accepts 3 decimals everywhere: drop the nanoseconds.
const parseTime = (s) => new Date(String(s || '').replace(/(\.\d{3})\d+/, '$1'));
/* ================= Time zones and time formats ================= */
const TIME_ZONES = {
General: ['UTC'],
Europe: ['Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Europe/Paris', 'Europe/Brussels', 'Europe/Amsterdam',
'Europe/Luxembourg', 'Europe/Berlin', 'Europe/Zurich', 'Europe/Madrid', 'Europe/Rome', 'Europe/Vienna',
'Europe/Prague', 'Europe/Warsaw', 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Helsinki',
'Europe/Athens', 'Europe/Bucharest', 'Europe/Kyiv', 'Europe/Istanbul', 'Europe/Moscow'],
Africa: ['Africa/Casablanca', 'Africa/Algiers', 'Africa/Tunis', 'Africa/Dakar', 'Africa/Abidjan', 'Africa/Lagos',
'Africa/Cairo', 'Africa/Nairobi', 'Africa/Johannesburg'],
America: ['America/St_Johns', 'America/Halifax', 'America/Montreal', 'America/Toronto', 'America/New_York',
'America/Chicago', 'America/Denver', 'America/Phoenix', 'America/Los_Angeles', 'America/Vancouver',
'America/Anchorage', 'America/Mexico_City', 'America/Martinique', 'America/Guadeloupe', 'America/Cayenne',
'America/Bogota', 'America/Lima', 'America/Santiago', 'America/Sao_Paulo', 'America/Buenos_Aires'],
Asia: ['Asia/Dubai', 'Asia/Tehran', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Kathmandu', 'Asia/Dhaka', 'Asia/Bangkok',
'Asia/Ho_Chi_Minh', 'Asia/Jakarta', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Taipei',
'Asia/Manila', 'Asia/Seoul', 'Asia/Tokyo'],
Oceania: ['Australia/Perth', 'Australia/Adelaide', 'Australia/Brisbane', 'Australia/Sydney', 'Pacific/Noumea',
'Pacific/Auckland', 'Pacific/Tahiti', 'Pacific/Honolulu'],
Other: ['Atlantic/Reykjavik', 'Atlantic/Azores', 'Atlantic/Canary', 'Indian/Reunion', 'Indian/Mayotte', 'Indian/Mauritius'],
};
// Each format receives the date parts in the selected time zone (and the Date itself).
const TIME_FORMATS = [
{ id: 'ymd-dash', label: 'YYYY-MM-DD - HH:MM:SS:mmm', f: (p) => `${p.Y}-${p.M}-${p.D} - ${p.h}:${p.m}:${p.s}:${p.ms}` },
{ id: 'ymd-ms', label: 'YYYY-MM-DD HH:MM:SS.mmm', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'ymd', label: 'YYYY-MM-DD HH:MM:SS', f: (p) => `${p.Y}-${p.M}-${p.D} ${p.h}:${p.m}:${p.s}` },
{ id: 'dmy-ms', label: 'DD/MM/YYYY HH:MM:SS.mmm', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'dmy', label: 'DD/MM/YYYY HH:MM:SS', f: (p) => `${p.D}/${p.M}/${p.Y} ${p.h}:${p.m}:${p.s}` },
{ id: 'mdy12', label: 'MM/DD/YYYY hh:MM:SS.mmm AM/PM', f: (p) => {
const H = Number(p.h);
return `${p.M}/${p.D}/${p.Y} ${pad(H % 12 || 12)}:${p.m}:${p.s}.${p.ms} ${H < 12 ? 'AM' : 'PM'}`;
} },
{ id: 'time-ms', label: 'HH:MM:SS.mmm', f: (p) => `${p.h}:${p.m}:${p.s}.${p.ms}` },
{ id: 'iso', label: 'ISO 8601 (YYYY-MM-DDTHH:MM:SS.mmm+hh:mm)', f: (p) => `${p.Y}-${p.M}-${p.D}T${p.h}:${p.m}:${p.s}.${p.ms}${p.off === '+00:00' ? 'Z' : p.off}` },
{ id: 'epoch', label: null, f: (p, d) => String(d.getTime()) },
];
const timePrefs = { tz: '', fmt: 'ymd-dash' }; // tz '' = browser time zone
const partsFormatters = new Map();
function validTimeZone(tz) {
if (!tz) return true;
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
}
// Date parts in a time zone ('' = browser zone), plus the UTC offset as "+hh:mm".
function zoneParts(date, tz = timePrefs.tz) {
let f = partsFormatters.get(tz);
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone: tz || undefined, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'longOffset',
});
partsFormatters.set(tz, f);
}
const p = {};
for (const x of f.formatToParts(date)) p[x.type] = x.value;
const off = (p.timeZoneName || 'GMT').replace('GMT', '') || '+00:00';
return {
Y: p.year, M: p.month, D: p.day, h: p.hour === '24' ? '00' : p.hour, m: p.minute, s: p.second,
ms: pad(date.getUTCMilliseconds(), 3), off,
};
}
function fmtWith(fmtId, date) {
const fmt = TIME_FORMATS.find((x) => x.id === fmtId) || TIME_FORMATS[0];
return fmt.f(zoneParts(date), date);
}
// Full date in the selected format (reception time, details panel).
function fmtFull(s) {
const d = parseTime(s);
return isNaN(d) ? String(s ?? '') : fmtWith(timePrefs.fmt, d);
}
async function api(url, opts = {}) {
if (opts.body && typeof opts.body !== 'string') {
opts.body = JSON.stringify(opts.body);
@@ -207,6 +305,7 @@ function setLang(next) {
renderHisto(state.histo);
renderStats();
renderTagList();
renderTimeSettings();
}
$('#langSwitch').addEventListener('click', (ev) => {
@@ -323,15 +422,17 @@ const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warn
function fmtTime(s) {
const d = parseTime(s);
if (isNaN(d)) return esc(s);
const day = lang === 'fr' ? `${pad(d.getDate())}/${pad(d.getMonth() + 1)}` : `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
return `${day} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}<span class="ms">.${pad(d.getMilliseconds(), 3)}</span>`;
const p = zoneParts(d);
const day = lang === 'fr' ? `${p.D}/${p.M}` : `${p.M}/${p.D}`;
return `${day} ${p.h}:${p.m}:${p.s}<span class="ms">.${p.ms}</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>`
+ `<time class="rcv" title="${esc(t('rcvTitle'))}">${r.received ? esc(fmtFull(r.received)) : '—'}</time>`
+ `<time class="mt" title="${esc(t('msgTimeTitle'))}">${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>`
@@ -382,13 +483,13 @@ function updateCount(tookMs) {
}
function detailsHTML(r) {
const order = ['_time', 'host', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const order = ['received', '_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: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const val = (k) => (k === '_time'
? `${esc(parseTime(r[k]).toLocaleString(t('locale')))} <span class="muted">(${esc(r[k])})</span>`
const label = { received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const val = (k) => (k === '_time' || k === 'received'
? `${esc(fmtFull(r[k]))} <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('')
@@ -462,7 +563,10 @@ function renderHisto(h) {
const loc = t('locale');
const label = (k) => {
const d = new Date(k * stepMs);
return stepMs >= 864e5 ? d.toLocaleDateString(loc) : d.toLocaleString(loc, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
const timeZone = timePrefs.tz || undefined;
return stepMs >= 864e5
? d.toLocaleDateString(loc, { timeZone })
: d.toLocaleString(loc, { timeZone, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
};
let bars = '';
for (let k = start; k <= end; k++) {
@@ -627,6 +731,50 @@ async function loadStats() {
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 = `<option value="">${esc(t('tzBrowser', browserZone))}</option>`;
for (const [group, zones] of Object.entries(TIME_ZONES)) {
html += `<optgroup label="${esc(groups[group] || group)}">`;
for (const z of zones) {
if (!validTimeZone(z)) continue;
html += `<option value="${z}">${esc(z.replace(/_/g, ' '))} (${offsetOf(z)})</option>`;
}
html += '</optgroup>';
}
const tzSel = $('#tzSelect');
tzSel.innerHTML = html;
tzSel.value = timePrefs.tz;
const fmtSel = $('#fmtSelect');
fmtSel.innerHTML = TIME_FORMATS.map((f) => `<option value="${f.id}">${esc(f.label || t('fmtEpoch'))}</option>`).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();
renderHisto(state.histo);
}
$('#tzSelect').addEventListener('change', onTimePrefsChange);
$('#fmtSelect').addEventListener('change', onTimePrefsChange);
setInterval(() => { if ($('#settingsDlg').open) updateTimePreview(); }, 1000);
/* ================= Tag management ================= */
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
@@ -727,12 +875,18 @@ $('#resetTags').addEventListener('click', async () => {
} catch (e) { toast(e.message); }
});
$('#settingsBtn').addEventListener('click', () => { renderTagList(); $('#settingsDlg').showModal(); });
$('#settingsBtn').addEventListener('click', () => { renderTimeSettings(); renderTagList(); $('#settingsDlg').showModal(); });
$('#closeSettings').addEventListener('click', () => $('#settingsDlg').close());
$('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); });
/* ================= Startup ================= */
{
const tz = store.get('tz', '');
timePrefs.tz = validTimeZone(tz) ? tz : '';
const fmt = store.get('timefmt', 'ymd-dash');
timePrefs.fmt = TIME_FORMATS.some((f) => f.id === fmt) ? fmt : 'ymd-dash';
}
applyLang();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
+12
View File
@@ -104,6 +104,18 @@
</div>
</section>
<section class="set-section">
<h3 data-i18n="dateTime">Date &amp; time</h3>
<div class="field-grid">
<label for="tzSelect" data-i18n="tzLabel">Time zone</label>
<select id="tzSelect" class="field"></select>
<label for="fmtSelect" data-i18n="fmtLabel">Reception time format</label>
<select id="fmtSelect" class="field"></select>
</div>
<p id="timePreview" class="time-preview"></p>
<p class="muted small" data-i18n="timeHelp"></p>
</section>
<section class="set-section">
<h3 data-i18n="tagsTitle">Color tags</h3>
<p class="muted small" data-i18n="tagsHelp"></p>
+21 -3
View File
@@ -210,7 +210,7 @@ body.busy .progress::after {
.row {
display: grid;
grid-template-columns: 8.4rem 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr;
grid-template-columns: max-content max-content 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;
@@ -225,6 +225,7 @@ body.busy .progress::after {
@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.rcv { color: var(--text); }
.row time .ms { opacity: .6; }
.row .host, .row .app { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .host { font-weight: 600; }
@@ -292,6 +293,7 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
background: var(--panel); border-top: 1px solid var(--border);
font-variant-numeric: tabular-nums;
}
.conn { white-space: nowrap; }
.conn::before { content: "●"; margin-right: 6px; color: var(--muted); }
.conn.ok::before { color: var(--sev-info); }
.conn.ko::before { color: var(--sev-err); }
@@ -317,6 +319,19 @@ dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px)
.set-section + .set-section { padding-top: 16px; border-top: 1px solid var(--border); }
.set-section h3 { margin: 0 0 8px; font-size: 13px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.set-section p { margin: 0; }
.field-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 12px; align-items: center; }
.field-grid label { font-size: 13px; font-weight: 550; }
select.field {
height: 34px; padding: 0 30px 0 10px; min-width: 0; width: 100%;
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 9px center / 14px;
appearance: none; -webkit-appearance: none; font-size: 13px;
}
select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.time-preview {
margin: 10px 0 6px !important; padding: 8px 12px; border-radius: 8px;
background: var(--panel-2); font-family: var(--mono); font-size: 12.5px;
}
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
@@ -373,10 +388,11 @@ dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px)
.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";
grid-template-areas: "rcv sev host" "msg msg msg";
gap: 3px 8px; padding: 8px 12px 8px 10px;
}
.row time { grid-area: time; }
.row time.rcv { grid-area: rcv; }
.row time.mt { display: none; } /* still shown in the details panel */
.row .sev { grid-area: sev; }
.row .host { grid-area: host; justify-self: end; max-width: 100%; }
.row .app { display: none; }
@@ -384,6 +400,8 @@ dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px)
.details { grid-column: 1 / -1; }
.details dl { grid-template-columns: 1fr; }
.details dt { margin-top: 4px; }
.field-grid { grid-template-columns: 1fr; gap: 4px; }
.field-grid select { margin-bottom: 6px; }
.status { padding: 6px 16px; }
.tag-row { grid-template-columns: 38px 1fr 36px; }
.tag-row .preview { display: none; }