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()`.
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+57
-7
@@ -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 `<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="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="host" title="${esc(hostTitle(r))}">${esc(r.host_name || r.host)}</span>`
|
||||
+ `<span class="app" title="${esc(r.app)}">${esc(r.app)}</span>`
|
||||
+ (r.host
|
||||
? `<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>`
|
||||
+ '</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() {
|
||||
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]))} <span class="muted">(${esc(r[k])})</span>`
|
||||
: esc(r[k]));
|
||||
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)));
|
||||
if (cur) setSelect(id, cur);
|
||||
}
|
||||
addFacetValues(state.rows);
|
||||
} catch { /* VictoriaLogs not ready yet: retried later */ }
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user