diff --git a/README.md b/README.md index 70da5ce..7abb081 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ message, host and app. Words are combined with AND. `error AND host:web-01`, `app:~"ssh|nginx"`, or `* | stats by (host) count()`. The live view is disabled in this mode. +Each row shows, from left to right: the **reception time** (server clock, stored in the +`received` field), the timestamp found in the message itself, severity, host, app and message. +Logs stored before the `received` field existed show `—` in the first column. + Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields. ## Settings @@ -53,6 +57,9 @@ Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all The gear icon opens the settings panel: - **Language**: English or French. The choice is remembered in the browser. +- **Date & time**: time zone (browser zone, UTC, or about 80 common zones) and the display + format of the reception time, e.g. `YYYY-MM-DD - HH:MM:SS:mmm` (default), ISO 8601, + 12-hour clock or Unix epoch. The time zone applies to every date shown. Remembered in the browser. - **Color tags**: each tag has a keyword, a background color (the text automatically switches to black or white to stay readable) and options: whole word, match case, regular expression, active. Tags are stored in `/data/tags.json` (`logstream-data` volume). diff --git a/syslog.go b/syslog.go index e64a468..ef51b7e 100644 --- a/syslog.go +++ b/syslog.go @@ -22,7 +22,8 @@ var facilityNames = [24]string{ // Entry is a decoded syslog message. type Entry struct { - Time time.Time + Time time.Time // timestamp from the message header (or reception time if absent) + Received time.Time // reception time, from the server clock Host string App string ProcID string @@ -39,6 +40,7 @@ type Entry struct { func (e *Entry) Record() map[string]string { r := map[string]string{ "_time": e.Time.UTC().Format(time.RFC3339Nano), + "received": e.Received.UTC().Format(time.RFC3339Nano), "_msg": e.Message, "host": e.Host, "app": e.App, @@ -70,7 +72,7 @@ func ParseSyslog(raw []byte, source, proto string, now time.Time) *Entry { if !utf8.ValidString(s) { s = strings.ToValidUTF8(s, "\uFFFD") } - e := &Entry{Time: now, Host: source, Source: source, Proto: proto} + e := &Entry{Time: now, Received: now, Host: source, Source: source, Proto: proto} e.setPri(13) // user.notice, the RFC 3164 default if len(s) > 2 && s[0] == '<' { diff --git a/web/app.js b/web/app.js index 738a259..902db24 100644 --- a/web/app.js +++ b/web/app.js @@ -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())}.${pad(d.getMilliseconds(), 3)}`; + const p = zoneParts(d); + const day = lang === 'fr' ? `${p.D}/${p.M}` : `${p.M}/${p.D}`; + return `${day} ${p.h}:${p.m}:${p.s}.${p.ms}`; } function rowHTML(r, isNew) { const sev = r.severity || ''; const cls = SEV_CLASS[sev] || 'info'; return `
` - + `` + + `` + + `` + `${esc(sev || '—')}` + `${esc(r.host)}` + `${esc(r.app)}` @@ -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')))} (${esc(r[k])})` + 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]))} (${esc(r[k])})` : esc(r[k])); return '
' + keys.map((k) => `
${esc(label[k] || k)}
${val(k)}
`).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 = ``; + for (const [group, zones] of Object.entries(TIME_ZONES)) { + html += ``; + for (const z of zones) { + if (!validTimeZone(z)) continue; + html += ``; + } + 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(); + 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'; diff --git a/web/index.html b/web/index.html index 3a49bbf..0362f4c 100644 --- a/web/index.html +++ b/web/index.html @@ -104,6 +104,18 @@
+
+

Date & time

+
+ + + + +
+

+

+
+

Color tags

diff --git a/web/style.css b/web/style.css index ab3ed49..34d3d05 100644 --- a/web/style.css +++ b/web/style.css @@ -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; }