Index logs by reception time, host filter from displayed logs, pastel tags
- _time is now the reception time; the device timestamp moves to msg_time. Devices with a wrong clock were indexed in the past and escaped time ranges and the host list. - Host/app lists also include values seen in displayed and live logs; clicking a host or app cell filters on it. - Severity badges err/crit and warning use the colors of the error and warning tags; default tags are now pastel (old default colors migrated). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7a706eeb82
commit
e583ab4b87
5 files changed
+117
-20
No files matched your search
@@ -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()`.
|
`error AND host:web-01`, `app:~"ssh|nginx"`, or `* | stats by (host) count()`.
|
||||||
The live view is disabled in this mode.
|
The live view is disabled in this mode.
|
||||||
|
|
||||||
Each row shows, from left to right: the **reception time** (server clock, stored in the
|
Each row shows, from left to right: the **reception time** (server clock), the timestamp
|
||||||
`received` field), the timestamp found in the message itself, severity, host, app and message.
|
found in the message itself (`msg_time`), severity, host, app and message. Click a host or an
|
||||||
Logs stored before the `received` field existed show `—` in the first column.
|
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.
|
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
|
- **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,
|
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).
|
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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ type Entry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Record returns the entry in the shape stored in VictoriaLogs and returned by the API.
|
// 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 {
|
func (e *Entry) Record() map[string]string {
|
||||||
|
rcv := e.Received.UTC().Format(time.RFC3339Nano)
|
||||||
r := map[string]string{
|
r := map[string]string{
|
||||||
"_time": e.Time.UTC().Format(time.RFC3339Nano),
|
"_time": rcv,
|
||||||
"received": e.Received.UTC().Format(time.RFC3339Nano),
|
"received": rcv,
|
||||||
|
"msg_time": e.Time.UTC().Format(time.RFC3339Nano),
|
||||||
"_msg": e.Message,
|
"_msg": e.Message,
|
||||||
"host": e.Host,
|
"host": e.Host,
|
||||||
"app": e.App,
|
"app": e.App,
|
||||||
|
|||||||
@@ -26,12 +26,31 @@ type Tag struct {
|
|||||||
|
|
||||||
func defaultTags() []Tag {
|
func defaultTags() []Tag {
|
||||||
return []Tag{
|
return []Tag{
|
||||||
{ID: "warning", Pattern: "warning", Color: "#f59e0b", WholeWord: true, Enabled: true},
|
{ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
|
||||||
{ID: "error", Pattern: "error", Color: "#ef4444", WholeWord: true, Enabled: true},
|
{ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
|
||||||
{ID: "ok", Pattern: "ok", Color: "#22c55e", 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.
|
// codedError carries a stable code the UI can translate.
|
||||||
type codedError struct {
|
type codedError struct {
|
||||||
code string
|
code string
|
||||||
@@ -87,6 +106,11 @@ func LoadTagStore(path string) (*TagStore, error) {
|
|||||||
if err := json.Unmarshal(b, &s.tags); err != nil {
|
if err := json.Unmarshal(b, &s.tags); err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
if migrateDefaultColors(s.tags) {
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-7
@@ -65,6 +65,7 @@ const I18N = {
|
|||||||
rcvTitle: 'Received by the server',
|
rcvTitle: 'Received by the server',
|
||||||
msgTimeTitle: 'Timestamp from the message',
|
msgTimeTitle: 'Timestamp from the message',
|
||||||
fHostName: 'host name (DNS)', fHostIp: 'host IP',
|
fHostName: 'host name (DNS)', fHostIp: 'host IP',
|
||||||
|
clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app',
|
||||||
dangerZone: 'Danger zone',
|
dangerZone: 'Danger zone',
|
||||||
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
|
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
|
||||||
purgeBtn: 'Delete all logs…',
|
purgeBtn: 'Delete all logs…',
|
||||||
@@ -139,6 +140,7 @@ const I18N = {
|
|||||||
rcvTitle: 'Reçu par le serveur',
|
rcvTitle: 'Reçu par le serveur',
|
||||||
msgTimeTitle: 'Horodatage contenu dans le message',
|
msgTimeTitle: 'Horodatage contenu dans le message',
|
||||||
fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte',
|
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',
|
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.',
|
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…',
|
purgeBtn: 'Supprimer tous les logs…',
|
||||||
@@ -393,7 +395,23 @@ function textOn(hex) {
|
|||||||
const tagStyle = (color) => `background:${color};color:${textOn(color)}`;
|
const tagStyle = (color) => `background:${color};color:${textOn(color)}`;
|
||||||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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() {
|
function compileMatchers() {
|
||||||
|
applySeverityColors();
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const tag of state.tags) {
|
for (const tag of state.tags) {
|
||||||
if (!tag.enabled || !tag.pattern) continue;
|
if (!tag.enabled || !tag.pattern) continue;
|
||||||
@@ -457,10 +475,14 @@ function rowHTML(r, isNew) {
|
|||||||
const cls = SEV_CLASS[sev] || 'info';
|
const cls = SEV_CLASS[sev] || 'info';
|
||||||
return `<article class="row sev-${cls}${isNew ? ' new' : ''}" tabindex="0">`
|
return `<article class="row sev-${cls}${isNew ? ' new' : ''}" tabindex="0">`
|
||||||
+ `<time class="rcv" title="${esc(t('rcvTitle'))}">${r.received ? esc(fmtFull(r.received)) : '—'}</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>`
|
+ `<time class="mt" title="${esc(t('msgTimeTitle'))}">${fmtTime(r.msg_time || r._time)}</time>`
|
||||||
+ `<span class="sev">${esc(sev || '—')}</span>`
|
+ `<span class="sev">${esc(sev || '—')}</span>`
|
||||||
+ `<span class="host" title="${esc(hostTitle(r))}">${esc(r.host_name || r.host)}</span>`
|
+ (r.host
|
||||||
+ `<span class="app" title="${esc(r.app)}">${esc(r.app)}</span>`
|
? `<span class="host" data-act="host" title="${esc(hostTitle(r) + ' · ' + t('clickHost'))}">${esc(r.host_name || r.host)}</span>`
|
||||||
|
: '<span class="host"></span>')
|
||||||
|
+ (r.app
|
||||||
|
? `<span class="app" data-act="app" title="${esc(r.app + ' · ' + t('clickApp'))}">${esc(r.app)}</span>`
|
||||||
|
: '<span class="app"></span>')
|
||||||
+ `<div class="msg">${highlight(r._msg)}</div>`
|
+ `<div class="msg">${highlight(r._msg)}</div>`
|
||||||
+ '</article>';
|
+ '</article>';
|
||||||
}
|
}
|
||||||
@@ -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() {
|
function renderList() {
|
||||||
|
addFacetValues(state.rows);
|
||||||
list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join('');
|
list.innerHTML = state.rows.map((r) => rowHTML(r, false)).join('');
|
||||||
bindRows(list.firstElementChild, state.rows);
|
bindRows(list.firstElementChild, state.rows);
|
||||||
$('#empty').hidden = state.rows.length > 0 || !$('#error').hidden;
|
$('#empty').hidden = state.rows.length > 0 || !$('#error').hidden;
|
||||||
@@ -497,6 +544,7 @@ function rehighlight() {
|
|||||||
|
|
||||||
function prependRows(rows) {
|
function prependRows(rows) {
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
|
addFacetValues(rows);
|
||||||
list.insertAdjacentHTML('afterbegin', rows.map((r) => rowHTML(r, true)).join(''));
|
list.insertAdjacentHTML('afterbegin', rows.map((r) => rowHTML(r, true)).join(''));
|
||||||
bindRows(list.firstElementChild, rows);
|
bindRows(list.firstElementChild, rows);
|
||||||
state.rows = rows.concat(state.rows);
|
state.rows = rows.concat(state.rows);
|
||||||
@@ -515,12 +563,13 @@ function updateCount(tookMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detailsHTML(r) {
|
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 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());
|
.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 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'
|
const val = (k) => (k === '_time' || k === 'received' || k === 'msg_time'
|
||||||
? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>`
|
? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>`
|
||||||
: esc(r[k]));
|
: esc(r[k]));
|
||||||
return '<div class="details"><dl>'
|
return '<div class="details"><dl>'
|
||||||
@@ -744,6 +793,7 @@ async function loadFacets() {
|
|||||||
sel.replaceChildren(first, ...(f[id] || []).map((x) => new Option(x.label || x.value, x.value)));
|
sel.replaceChildren(first, ...(f[id] || []).map((x) => new Option(x.label || x.value, x.value)));
|
||||||
if (cur) setSelect(id, cur);
|
if (cur) setSelect(id, cur);
|
||||||
}
|
}
|
||||||
|
addFacetValues(state.rows);
|
||||||
} catch { /* VictoriaLogs not ready yet: retried later */ }
|
} catch { /* VictoriaLogs not ready yet: retried later */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -19,6 +19,12 @@
|
|||||||
--sev-info: #0d9488;
|
--sev-info: #0d9488;
|
||||||
--sev-debug: #9ca3af;
|
--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;
|
--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;
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
@@ -238,14 +244,18 @@ body.busy .progress::after {
|
|||||||
padding: 1px 6px; border-radius: 5px;
|
padding: 1px 6px; border-radius: 5px;
|
||||||
color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent);
|
color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent);
|
||||||
}
|
}
|
||||||
.sev-crit { --sev: var(--sev-crit); }
|
.sev-crit { --sev: var(--tag-error); --sev-text: var(--tag-error-text); }
|
||||||
.sev-err { --sev: var(--sev-err); }
|
.sev-err { --sev: var(--tag-error); --sev-text: var(--tag-error-text); }
|
||||||
.sev-warning { --sev: var(--sev-warning); }
|
.sev-warning { --sev: var(--tag-warning); --sev-text: var(--tag-warning-text); }
|
||||||
.sev-notice { --sev: var(--sev-notice); }
|
.sev-notice { --sev: var(--sev-notice); }
|
||||||
.sev-info { --sev: var(--sev-info); }
|
.sev-info { --sev: var(--sev-info); }
|
||||||
.sev-debug { --sev: var(--sev-debug); }
|
.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, .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 {
|
mark.tag {
|
||||||
padding: 0 4px; border-radius: 4px; font-weight: 650;
|
padding: 0 4px; border-radius: 4px; font-weight: 650;
|
||||||
|
|||||||
Reference in new issue
Block a user