feat(syslog): lines beside the readings: around an anomaly, in get_anomaly, and marked on host and SNMP charts (#6002)
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:
@@ -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
|
||||
|
||||
@@ -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 () {});
|
||||
};
|
||||
})();
|
||||
@@ -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 }}&resource={{ a.resource_name|urlencode }}&since={{ v.last_span.started_at.isoformat()|urlencode }}{% if v.last_span.ended_at %}&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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user