diff --git a/README.md b/README.md index ac031bc..6b4005f 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,16 @@ 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. +Each row shows, from left to right: the **reception time** (server clock), the timestamp +found in the message itself (`msg_time`), severity, host, app and message. Click a host or an +app to filter on it. + +Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock +(e.g. access points whose NTP fails) still show up in the right time range. Logs stored by +versions before this change are indexed by their message timestamp; purge them from +Settings to start clean. + +Severity badges `err`/`crit` and `warning` use the colors of the `error` and `warning` tags. Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields. @@ -80,7 +87,8 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set - **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). - Default tags: `warning` (orange), `error` (red), `ok` (green). + Default tags (pastel): `warning` (orange), `error` (red), `ok` (green). Default tags + still using the colors of earlier versions are switched to the pastel ones automatically. ## Configuration diff --git a/syslog.go b/syslog.go index dbe572b..72ba7c4 100644 --- a/syslog.go +++ b/syslog.go @@ -38,10 +38,15 @@ type Entry struct { } // Record returns the entry in the shape stored in VictoriaLogs and returned by the API. +// The time axis (_time) is the reception time: device clocks can be wrong, and +// logs indexed in the past would escape time ranges. The device timestamp is +// kept in msg_time. func (e *Entry) Record() map[string]string { + rcv := e.Received.UTC().Format(time.RFC3339Nano) r := map[string]string{ - "_time": e.Time.UTC().Format(time.RFC3339Nano), - "received": e.Received.UTC().Format(time.RFC3339Nano), + "_time": rcv, + "received": rcv, + "msg_time": e.Time.UTC().Format(time.RFC3339Nano), "_msg": e.Message, "host": e.Host, "app": e.App, diff --git a/tags.go b/tags.go index d5dc81a..1b59c62 100644 --- a/tags.go +++ b/tags.go @@ -26,12 +26,31 @@ type Tag struct { func defaultTags() []Tag { return []Tag{ - {ID: "warning", Pattern: "warning", Color: "#f59e0b", WholeWord: true, Enabled: true}, - {ID: "error", Pattern: "error", Color: "#ef4444", WholeWord: true, Enabled: true}, - {ID: "ok", Pattern: "ok", Color: "#22c55e", WholeWord: true, Enabled: true}, + {ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true}, + {ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true}, + {ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true}, } } +// Colors of the default tags in earlier versions: still unchanged, they are +// switched to the new pastel defaults when the file is loaded. +var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"} + +func migrateDefaultColors(tags []Tag) bool { + newColors := map[string]string{} + for _, t := range defaultTags() { + newColors[t.ID] = t.Color + } + changed := false + for i := range tags { + if old, ok := oldDefaultColors[tags[i].ID]; ok && strings.EqualFold(tags[i].Color, old) { + tags[i].Color = newColors[tags[i].ID] + changed = true + } + } + return changed +} + // codedError carries a stable code the UI can translate. type codedError struct { code string @@ -87,6 +106,11 @@ func LoadTagStore(path string) (*TagStore, error) { if err := json.Unmarshal(b, &s.tags); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } + if migrateDefaultColors(s.tags) { + if err := s.save(); err != nil { + return nil, err + } + } return s, nil } diff --git a/web/app.js b/web/app.js index 282dcaa..c0a4aec 100644 --- a/web/app.js +++ b/web/app.js @@ -65,6 +65,7 @@ const I18N = { rcvTitle: 'Received by the server', msgTimeTitle: 'Timestamp from the message', fHostName: 'host name (DNS)', fHostIp: 'host IP', + clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app', dangerZone: 'Danger zone', purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.', purgeBtn: 'Delete all logs…', @@ -139,6 +140,7 @@ const I18N = { rcvTitle: 'Reçu par le serveur', msgTimeTitle: 'Horodatage contenu dans le message', fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte', + clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli', dangerZone: 'Zone de danger', purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.', purgeBtn: 'Supprimer tous les logs…', @@ -393,7 +395,23 @@ function textOn(hex) { const tagStyle = (color) => `background:${color};color:${textOn(color)}`; const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +// Severity badges share the colors of the "error" and "warning" tags. +function applySeverityColors() { + const style = document.documentElement.style; + for (const name of ['error', 'warning']) { + const tag = state.tags.find((x) => String(x.pattern).toLowerCase() === name && /^#[0-9a-f]{6}$/i.test(x.color)); + if (tag) { + style.setProperty(`--tag-${name}`, tag.color); + style.setProperty(`--tag-${name}-text`, textOn(tag.color)); + } else { + style.removeProperty(`--tag-${name}`); + style.removeProperty(`--tag-${name}-text`); + } + } +} + function compileMatchers() { + applySeverityColors(); const out = []; for (const tag of state.tags) { if (!tag.enabled || !tag.pattern) continue; @@ -457,10 +475,14 @@ function rowHTML(r, isNew) { const cls = SEV_CLASS[sev] || 'info'; return `
` + `` - + `` + + `` + `${esc(sev || '—')}` - + `${esc(r.host_name || r.host)}` - + `${esc(r.app)}` + + (r.host + ? `${esc(r.host_name || r.host)}` + : '') + + (r.app + ? `${esc(r.app)}` + : '') + `
${highlight(r._msg)}
` + '
'; } @@ -482,7 +504,32 @@ function bindRows(first, rows) { } } +// Adds the hosts/apps of displayed logs to the filter lists (live logs can +// show a host before the next refresh of /api/facets). +function addFacetValues(rows) { + for (const id of ['host', 'app']) { + const sel = $('#' + id); + const have = new Set([...sel.options].map((o) => o.value)); + let added = false; + for (const r of rows) { + const v = r[id]; + if (!v || have.has(v)) continue; + have.add(v); + sel.add(new Option(id === 'host' && r.host_name ? `${r.host_name} (${v})` : v, v)); + added = true; + } + if (added) { + const cur = sel.value; + const [first, ...rest] = [...sel.options]; + rest.sort((a, b) => a.text.localeCompare(b.text)); + sel.replaceChildren(first, ...rest); + sel.value = cur; + } + } +} + function renderList() { + addFacetValues(state.rows); list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join(''); bindRows(list.firstElementChild, state.rows); $('#empty').hidden = state.rows.length > 0 || !$('#error').hidden; @@ -497,6 +544,7 @@ function rehighlight() { function prependRows(rows) { if (!rows.length) return; + addFacetValues(rows); list.insertAdjacentHTML('afterbegin', rows.map((r) => rowHTML(r, true)).join('')); bindRows(list.firstElementChild, rows); state.rows = rows.concat(state.rows); @@ -515,12 +563,13 @@ function updateCount(tookMs) { } function detailsHTML(r) { - const order = ['received', '_time', 'host', 'host_name', 'host_ip', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg']; + const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', '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] !== '') + if (r.msg_time) hidden.add('_time'); // same as the reception time + const keys = order.filter((k) => r[k] != null && r[k] !== '' && !hidden.has(k)) .concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort()); - const label = { host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') }; - const val = (k) => (k === '_time' || k === 'received' + const label = { msg_time: t('fTime'), host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') }; + const val = (k) => (k === '_time' || k === 'received' || k === 'msg_time' ? `${esc(fmtFull(r[k]))} (${esc(r[k])})` : esc(r[k])); return '
' @@ -744,6 +793,7 @@ async function loadFacets() { 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 */ } } diff --git a/web/style.css b/web/style.css index c8ffb20..5bcedd9 100644 --- a/web/style.css +++ b/web/style.css @@ -19,6 +19,12 @@ --sev-info: #0d9488; --sev-debug: #9ca3af; + /* Filled severity badges; overwritten by the colors of the "error" / "warning" tags */ + --tag-error: #fca5a5; + --tag-error-text: #111827; + --tag-warning: #fdba74; + --tag-warning-text: #111827; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --radius: 10px; @@ -238,14 +244,18 @@ body.busy .progress::after { padding: 1px 6px; border-radius: 5px; color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent); } -.sev-crit { --sev: var(--sev-crit); } -.sev-err { --sev: var(--sev-err); } -.sev-warning { --sev: var(--sev-warning); } +.sev-crit { --sev: var(--tag-error); --sev-text: var(--tag-error-text); } +.sev-err { --sev: var(--tag-error); --sev-text: var(--tag-error-text); } +.sev-warning { --sev: var(--tag-warning); --sev-text: var(--tag-warning-text); } .sev-notice { --sev: var(--sev-notice); } .sev-info { --sev: var(--sev-info); } .sev-debug { --sev: var(--sev-debug); } +.row.sev-crit .sev, .row.sev-err .sev, .row.sev-warning .sev { background: var(--sev); color: var(--sev-text); } +.row.sev-crit .sev { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--sev-text) 45%, transparent); } .row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); } -.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 4%, transparent); } +.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); } +.row .host[data-act], .row .app[data-act] { cursor: pointer; } +.row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } mark.tag { padding: 0 4px; border-radius: 4px; font-weight: 650;