feat(syslog): lines beside the readings: around an anomaly, in get_anomaly, and marked on host and SNMP charts (#6002)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m37s
CI / publish (push) Successful in 1m9s

get_anomaly's circumstances gain the host's or SNMP device's syslog
warnings and worse (mcp_events / mcp_resource_events). An anomaly row opens
"Syslog around it", read only when opened. A shared Chart.js plugin
(chart-marks.js) draws a dashed line at each syslog error on the host
metrics charts and an SNMP device's charts, with a line saying how many.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 06:36:31 -04:00
co-authored by Claude Opus 5.5
parent f37a578136
commit 521d84f318
12 changed files with 335 additions and 23 deletions
+2 -1
View File
@@ -161,7 +161,8 @@ async def get_anomaly(anomaly_id: str, occurrences: int = 5) -> dict:
runs, other anomalies on the host, what plugins saw (container
starts, deaths and OOM kills; maintenance jobs; reboots and agent
stops; for a Traefik router, its swarm service's containers on any
host), and kind "steward": a stretch Steward itself wasn't recording
host; syslog warnings and worse from the host's or the SNMP device's
own log), and kind "steward": a stretch Steward itself wasn't recording
(a restart or deploy), around which any reading can be wrong. From 30
minutes before to 30 minutes after. Each kind keeps the 40 events
nearest the occurrence's start; `around_left_out` counts the rest
+82
View File
@@ -0,0 +1,82 @@
// Marks on a time chart: a thin line at each moment something was logged
// (#6002, the syslog errors of a host or a device), so a reading's jump can be
// read against what the device said at the time.
//
// A chart opts in by holding marks in options.plugins.stewardMarks.marks, as
// [{at: epoch ms, level, text}]. A linear x axis takes the time itself; a
// category axis of ISO times takes the nearest label. Hovering near a mark
// shows its text in the chart's title attribute.
(function () {
if (!window.Chart) return;
function xOf(chart, at) {
var x = chart.scales.x;
if (!x) return null;
if (x.type !== 'category') return x.getPixelForValue(at);
var labels = chart.data.labels || [];
if (!labels.length) return null;
var first = Date.parse(labels[0]), last = Date.parse(labels[labels.length - 1]);
if (at < first || at > last) return null;
var best = 0, gap = Infinity;
for (var i = 0; i < labels.length; i++) {
var d = Math.abs(Date.parse(labels[i]) - at);
if (d < gap) { gap = d; best = i; }
}
return x.getPixelForValue(best);
}
Chart.register({
id: 'stewardMarks',
afterDatasetsDraw: function (chart, args, opts) {
var marks = (opts && opts.marks) || [];
if (!marks.length) return;
var area = chart.chartArea, ctx = chart.ctx;
var crit = window.stewardColor ? stewardColor('--red') : 'red';
chart.$stewardMarks = [];
ctx.save();
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.strokeStyle = crit;
ctx.globalAlpha = 0.55;
marks.forEach(function (m) {
var px = xOf(chart, m.at);
if (px == null || px < area.left || px > area.right) return;
ctx.beginPath();
ctx.moveTo(px, area.top);
ctx.lineTo(px, area.bottom);
ctx.stroke();
chart.$stewardMarks.push({ x: px, text: m.text, at: m.at });
});
ctx.restore();
},
afterEvent: function (chart, args) {
var near = (chart.$stewardMarks || []).filter(function (m) {
return Math.abs(m.x - args.event.x) <= 3;
});
chart.canvas.title = near.map(function (m) {
return new Date(m.at).toLocaleString([], { hour12: false }) + ' ' + m.text;
}).join('\n');
},
});
// Fetch marks from `url` and put them on each chart, redrawing it; `note`
// (an element, optional) says in words how many there are.
window.stewardMarkCharts = function (url, charts, note) {
fetch(url, { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : { marks: [] }; })
.then(function (body) {
var marks = body.marks || [];
charts.forEach(function (ch) {
if (!ch) return;
ch.options.plugins.stewardMarks = { marks: marks };
ch.update('none');
});
if (note) {
note.hidden = !marks.length;
note.textContent = marks.length
? (body.more ? 'The newest ' : '') + marks.length + ' syslog error' + (marks.length === 1 ? '' : 's') +
' in this span marked with dashed red lines; hover one to read it.'
: '';
}
})
.catch(function () {});
};
})();
+9
View File
@@ -68,5 +68,14 @@ Held {{ metric_value(a.metric_name, a.peak_level) }}; usual for that hour
{{ anomaly_when(v) }} · {{ anomaly_level(a) }}
</div>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.2rem;">{{ anomaly_review(a, back) }}</div>
{%- if "syslog" in enabled_plugins and v.last_span %}
{#- The device's own log around its latest occurrence (#6002), read only when opened. #}
<details style="font-size:0.8rem;margin-top:0.3rem;"
hx-get="/plugins/syslog/around?source={{ a.source_module|urlencode }}&amp;resource={{ a.resource_name|urlencode }}&amp;since={{ v.last_span.started_at.isoformat()|urlencode }}{% if v.last_span.ended_at %}&amp;until={{ v.last_span.ended_at.isoformat()|urlencode }}{% endif %}"
hx-trigger="toggle once" hx-target="find .anomaly-syslog" hx-swap="innerHTML">
<summary style="cursor:pointer;color:var(--text-muted);">Syslog around it</summary>
<div class="anomaly-syslog"><p class="field-hint" style="margin:0.3rem 0 0;">Reading…</p></div>
</details>
{%- endif %}
</div>
{%- endmacro %}
+1
View File
@@ -20,6 +20,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
<script src="{{ url_for('static', filename='chart-marks.js') }}"></script>
<script src="{{ url_for('static', filename='scroll-more.js') }}" defer></script>
{# A form with data-confirm="…" asks in #confirm-dialog below before it submits (#5314). #}
<script src="{{ url_for('static', filename='confirm.js') }}" defer></script>