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
@@ -42,6 +42,7 @@
</div>
{% endif %}
</div>
{% if "syslog" in enabled_plugins %}<p id="hm-marks-note" class="field-hint" hidden style="margin:0.5rem 0 0;"></p>{% endif %}
{# Hidden poller: fetches the history series and applies it to the charts above
(no visible swap). Lazy on load → never blocks paint; refreshed slowly + on
range change. #}
@@ -112,6 +113,11 @@
ch.update("none");
}
if (rangeKey) document.querySelectorAll(".hm-chart-range").forEach((s) => { s.textContent = rangeKey; });
{% if "syslog" in enabled_plugins %}
// The host's own syslog errors over the same span (#6002).
stewardMarkCharts("/plugins/syslog/marks?host_id={{ host.id }}&range=" + encodeURIComponent(rangeKey || ""),
Object.values(charts), document.getElementById("hm-marks-note"));
{% endif %}
};
})();
</script>
+9 -2
View File
@@ -42,6 +42,7 @@
{# The device's own log, when it sends syslog to an added source (#6000). #}
{% if "syslog" in enabled_plugins %}
{% if charts %}<p id="snmp-marks-note" class="field-hint" hidden style="margin:0.5rem 0 0;"></p>{% endif %}
<div hx-get="/plugins/syslog/panel?address={{ device.address|urlencode }}{% if device.host_id %}&amp;host_id={{ device.host_id }}{% endif %}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
@@ -56,6 +57,7 @@
? d.toLocaleDateString([], { weekday: "short", day: "numeric" }) + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false })
: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
var drawn = [];
charts.forEach(function (c, i) {
var el = document.getElementById("snmp-c-" + i);
if (!el) return;
@@ -63,7 +65,7 @@
el.parentElement.innerHTML = '<p class="field-hint" style="margin:0;">Not enough readings yet for a chart.</p>';
return;
}
new Chart(el.getContext("2d"), {
drawn.push(new Chart(el.getContext("2d"), {
type: "line",
data: { labels: c.points.map(function (p) { return p[0]; }),
datasets: [{ data: c.points.map(function (p) { return p[1]; }), borderColor: line,
@@ -81,8 +83,13 @@
title: { display: !!c.axis, text: c.axis, font: { size: 9 } } }
}
}
});
}));
});
{% if "syslog" in enabled_plugins %}
// The device's own syslog errors over the same span (#6002).
if (drawn.length) stewardMarkCharts("/plugins/syslog/marks?address={{ device.address|urlencode }}{% if device.host_id %}&host_id={{ device.host_id }}{% endif %}&hours=" + hours,
drawn, document.getElementById("snmp-marks-note"));
{% endif %}
})();
</script>
{% endblock %}
+10
View File
@@ -40,6 +40,16 @@ def register_mcp_tools(register) -> None:
register_tools(register)
async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dict]:
from .mcp_tools import mcp_events as events
return await events(db, host_id, host_name, since, until)
async def mcp_resource_events(db, source: str, resource: str, since, until) -> list[dict]:
from .mcp_tools import mcp_resource_events as events
return await events(db, source, resource, since, until)
def get_alert_metrics() -> dict[str, list[str]]:
"""A pattern's count is named for the pattern (lines_matching_<key>), so
the list is empty here and the rule form offers the counts recorded so far."""
+32 -8
View File
@@ -14,11 +14,8 @@ from steward.models.users import UserRole
from .models import SyslogLine, SyslogSource
from .parse import facility_name, severity_name
from .service import LineFilter, filtered, rates, severity_level, sources_for
def _source_name(s: SyslogSource) -> str:
return s.name or s.hostname_seen or s.address
from .service import (AROUND_LINES, AROUND_SEVERITY, LineFilter, filtered, lines_between, place_for, rates,
severity_level, shown_on, source_name, sources_for)
async def list_syslog_sources(state: str = "") -> dict:
@@ -43,7 +40,7 @@ async def list_syslog_sources(state: str = "") -> dict:
per_hour = await rates(db, [s.id for s in sources if s.state == "accepted"], now)
hosts = {h.id: h.name for h in (await db.execute(select(Host))).scalars()}
return {"sources": [{
"source_id": s.id, "name": _source_name(s), "address": s.address, "state": s.state,
"source_id": s.id, "name": source_name(s), "address": s.address, "state": s.state,
"host_id": s.host_id, "host_name": hosts.get(s.host_id), "calls_itself": s.hostname_seen or None,
"lines_total": s.lines_total, "lines_last_hour": per_hour.get(s.id, 0) if s.state == "accepted" else None,
"last_heard": iso(s.last_seen_at), "last_heard_age_s": age_s(s.last_seen_at, now),
@@ -94,14 +91,14 @@ async def search_syslog(source: str = "", host: str = "", text: str = "", severi
want = source.strip()
by_address = [s for s in accepted if s.address == want]
chosen = by_address or [pick(want, accepted, kind="syslog source",
list_tool="list_syslog_sources", name=_source_name)]
list_tool="list_syslog_sources", name=source_name)]
if host:
h = pick(host, list((await db.execute(select(Host))).scalars()), kind="host", list_tool="list_hosts")
chosen = sources_for(chosen, h.id, h.address)
if not chosen:
return {"range": key, "matches": [], "returned": 0, "truncated": False,
"note": f"No added syslog source is shown on {h.name}."}
names = {s.id: _source_name(s) for s in chosen}
names = {s.id: source_name(s) for s in chosen}
stmt = filtered(select(SyslogLine).where(SyslogLine.source_id.in_(list(names))), f)
if before_dt is not None:
stmt = stmt.where(SyslogLine.received_at < before_dt)
@@ -122,6 +119,33 @@ async def search_syslog(source: str = "", host: str = "", text: str = "", severi
return out
def _as_events(lines: list[SyslogLine], sources: list[SyslogSource]) -> list[dict]:
from steward.mcp.tools._events import event
names = {s.id: source_name(s) for s in sources}
return [event(ln.received_at, "syslog",
f"{severity_name(ln.severity)}{' ' + ln.app if ln.app else ''}: {cut_middle(ln.message, 300)}",
subject=names[ln.source_id]) for ln in reversed(lines)]
async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dict]:
"""Warnings and worse in a window (get_anomaly) from the syslog sources
shown on a host: bound to it, or at its address."""
sources = await shown_on(db, host_id, None)
return _as_events(await lines_between(db, sources, since, until, AROUND_SEVERITY, AROUND_LINES), sources)
async def mcp_resource_events(db, source: str, resource: str, since, until) -> list[dict]:
"""The same for an SNMP device, which has no host to read events by: the
sources at its address, or bound to its host."""
if source != "snmp":
return []
host_id, address = await place_for(db, source, resource)
if not (host_id or address):
return []
sources = await shown_on(db, host_id, address)
return _as_events(await lines_between(db, sources, since, until, AROUND_SEVERITY, AROUND_LINES), sources)
def register_tools(register) -> None:
for fn in (list_syslog_sources, search_syslog):
register(fn, role=UserRole.viewer, read_only=True)
+64 -12
View File
@@ -6,7 +6,7 @@ and the listener's state in memory; nothing here contacts a device (System
and audited."""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
from sqlalchemy import delete, select
@@ -23,7 +23,9 @@ from .parse import SEVERITIES, facility_name, severity_name
from .patterns import (PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, WINDOWS, PatternError, key_for, parse_form,
resource_of)
from .receiver import LISTENER, MAX_SENDERS, settings
from .service import PANEL_LINES, PANEL_SPAN, SEVERITY_CHOICES, apps_of, filtered, line_filter, rates, sources_for
from .service import (AROUND_LINES, AROUND_SEVERITY, MARK_SEVERITY, MAX_MARKS, PANEL_LINES, PANEL_SPAN,
SEVERITY_CHOICES, apps_of, filtered, line_filter, lines_between, place_for, rates, shown_on,
source_name)
syslog_bp = Blueprint("syslog", __name__, template_folder="templates")
@@ -273,19 +275,11 @@ async def lines_panel():
"""The syslog panel on a host's page (?host_id=) or an SNMP device's
(?address=, and ?host_id= when it is bound): the newest lines of the
sources that map to it. Nothing when no added source does."""
from steward.models.hosts import Host
host_id = request.args.get("host_id") or None
address = request.args.get("address") or None
async with current_app.db_sessionmaker() as db:
if host_id and not address:
host = await db.get(Host, host_id)
address = host.address if host is not None else None
matched = sources_for(list((await db.execute(
select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars()), host_id, address)
matched = await shown_on(db, request.args.get("host_id") or None, request.args.get("address") or None)
if not matched:
return ""
names = {s.id: s.name or s.hostname_seen or s.address for s in matched}
names = {s.id: source_name(s) for s in matched}
since = datetime.now(timezone.utc) - PANEL_SPAN
lines = (await db.execute(
select(SyslogLine).where(SyslogLine.source_id.in_(list(names)), SyslogLine.received_at >= since)
@@ -457,3 +451,61 @@ async def delete_pattern(pattern_id: str):
await log_audit(current_app, *_actor(), "syslog.pattern_removed", entity_type="syslog_pattern",
entity_id=pattern_id, detail={"name": name})
return redirect(PATTERNS)
# ── Lines beside the readings (#6002) ─────────────────────────────────────────
AROUND = timedelta(minutes=30)
AROUND_SHOWN = 15
def _iso_arg(name: str) -> datetime | None:
value = request.args.get(name) or ""
try:
at = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return at if at.tzinfo else at.replace(tzinfo=timezone.utc)
@syslog_bp.get("/around")
@require_role(UserRole.viewer)
async def lines_around():
"""An anomaly's syslog: warnings and worse from the sources shown with
its resource (?source=&resource=), from half an hour before ?since= to
half an hour after ?until=, the lines nearest its start first."""
since = _iso_arg("since")
if since is None:
abort(400)
until = _iso_arg("until") or datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
host_id, address = await place_for(db, request.args.get("source", ""), request.args.get("resource", ""))
matched = await shown_on(db, host_id, address) if host_id or address else []
lines = await lines_between(db, matched, since - AROUND, until + AROUND, AROUND_SEVERITY, AROUND_LINES)
names = {s.id: source_name(s) for s in matched}
nearest = sorted(lines, key=lambda ln: abs((ln.received_at - since).total_seconds()))[:AROUND_SHOWN]
rows = [{**line_view(ln), "source": names[ln.source_id], "source_id": ln.source_id}
for ln in sorted(nearest, key=lambda ln: ln.received_at)]
return await render_template("syslog/_around.html", lines=rows, more=len(lines) - len(rows),
sources=[{"id": sid, "name": name} for sid, name in names.items()])
@syslog_bp.get("/marks")
@require_role(UserRole.viewer)
async def chart_marks():
"""Errors and worse from the sources a host's (?host_id=) or a device's
(?address=) page shows, over its chart's span (?range= or ?hours=), for
the chart to mark: {"marks": [{"at": epoch ms, "level", "text"}], "more"}."""
from steward.core.time_range import parse_range
now = datetime.now(timezone.utc)
hours = request.args.get("hours", type=int)
since = now - timedelta(hours=min(max(hours, 1), 24 * 30)) if hours else parse_range(request.args.get("range"))[0]
async with current_app.db_sessionmaker() as db:
matched = await shown_on(db, request.args.get("host_id") or None, request.args.get("address") or None)
lines = await lines_between(db, matched, since, now, MARK_SEVERITY, MAX_MARKS + 1)
names = {s.id: source_name(s) for s in matched}
return {"marks": [{"at": int(ln.received_at.timestamp() * 1000), "level": ln.severity,
"text": f"{names[ln.source_id]} {severity_name(ln.severity)}"
f"{' ' + ln.app if ln.app else ''}: {ln.message[:200]}"}
for ln in lines[:MAX_MARKS]],
"more": len(lines) > MAX_MARKS}
+56
View File
@@ -110,3 +110,59 @@ def sources_for(sources: list[SyslogSource], host_id: str | None, address: str |
address = (address or "").strip().lower()
return [s for s in sources if s.state == "accepted" and (
s.host_id == host_id if s.host_id else bool(address) and s.address.lower() == address)]
# ── Lines beside the readings: an anomaly's circumstances, a chart's marks (#6002)
# What an anomaly's circumstances take from the log: warnings and worse, so a
# chatty device's info lines don't crowd out the line that explains it.
AROUND_SEVERITY = 4
AROUND_LINES = 200
# What a chart marks: errors and worse. Past MAX_MARKS the newest are kept.
MARK_SEVERITY = 3
MAX_MARKS = 300
def source_name(s: SyslogSource) -> str:
return s.name or s.hostname_seen or s.address
async def shown_on(db, host_id: str | None, address: str | None) -> list[SyslogSource]:
"""The added sources a host's page (and its address) or a device's page
shows; the host's own address when only the host is given."""
from steward.models.hosts import Host
if host_id and not address:
host = await db.get(Host, host_id)
address = host.address if host is not None else None
accepted = list((await db.execute(select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars())
return sources_for(accepted, host_id, address)
async def place_for(db, source: str, resource: str) -> tuple[str | None, str | None]:
"""(host id, address) whose sources a metric's resource is shown with:
an SNMP device's own binding and address, else the host the resource
lives on, as get_anomaly finds it."""
if source == "snmp":
from steward.core.plugin_manager import loaded_plugin_submodule
snmp = loaded_plugin_submodule("snmp", "models")
if snmp is None:
return None, None
device = (await db.execute(select(snmp.SnmpDevice).where(
snmp.SnmpDevice.name == resource).limit(1))).scalar_one_or_none()
return (device.host_id, device.address) if device is not None else (None, None)
from steward.mcp.tools._events import place_of
place = await place_of(db, source, resource)
return place.host_id, None
async def lines_between(db, sources: list[SyslogSource], since: datetime, until: datetime,
worst: int, limit: int) -> list[SyslogLine]:
"""Lines of these sources received in [since, until] at `worst` or worse,
newest first, at most `limit`."""
if not sources:
return []
return list((await db.execute(
select(SyslogLine).where(SyslogLine.source_id.in_([s.id for s in sources]),
SyslogLine.received_at >= since, SyslogLine.received_at <= until,
SyslogLine.severity <= worst)
.order_by(SyslogLine.received_at.desc(), SyslogLine.id.desc()).limit(limit))).scalars())
@@ -0,0 +1,25 @@
{# plugins/syslog/templates/syslog/_around.html — an anomaly's syslog: the
warnings and worse nearest its start, from the sources shown with its
resource (#6002). Loaded when its "Syslog around it" is opened. #}
{% from "syslog/_tone.html" import tone %}
{% if not sources %}
<p class="field-hint" style="margin:0.3rem 0 0;">No added syslog source is shown with this resource.</p>
{% elif not lines %}
<p class="field-hint" style="margin:0.3rem 0 0;">No warnings or worse from {% for s in sources %}{% if not loop.first %}, {% endif %}<a href="/plugins/syslog/sources/{{ s.id }}">{{ s.name }}</a>{% endfor %} from half an hour before to half an hour after.</p>
{% else %}
<table class="table" style="font-size:0.78rem;margin-top:0.3rem;">
<thead><tr><th class="nowrap">Received ({{ tz_label() }})</th>{% if sources|length > 1 %}<th>Source</th>{% endif %}<th>Severity</th><th>App</th><th>Message</th></tr></thead>
<tbody>
{% for ln in lines %}
<tr>
<td class="nowrap">{{ ln.received_at|when(seconds=true) }}</td>
{% if sources|length > 1 %}<td class="nowrap"><a href="/plugins/syslog/sources/{{ ln.source_id }}">{{ ln.source }}</a></td>{% endif %}
<td class="nowrap"><span class="{{ tone(ln.level) }}">{{ ln.severity }}</span></td>
<td class="nowrap">{{ ln.app or "—" }}</td>
<td class="mono" style="max-width:32rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ ln.message }}">{{ ln.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if more > 0 %}<p class="field-hint" style="margin:0.3rem 0 0;">{{ more }} more in that hour either side, further from its start: open the source to read them.</p>{% endif %}
{% endif %}
+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>
+39
View File
@@ -364,3 +364,42 @@ def test_patterns_page_adds_edits_and_removes_and_a_rename_moves_counts(app):
await _clear_patterns(app)
asyncio.run(go())
def test_lines_beside_the_readings_around_an_anomaly_and_as_chart_marks(app):
now = datetime.now(timezone.utc)
mcp_tools = _mod("mcp_tools")
async def go():
host_id, host_name, _, src_name = await _seed(app, now)
client = await _client(app, "viewer")
since, until = (now - timedelta(minutes=35)).isoformat(), (now - timedelta(minutes=25)).isoformat()
around = await (await client.get("/plugins/syslog/around", query_string={
"source": "host_agent", "resource": f"{host_name}:/", "since": since, "until": until})).get_data(as_text=True)
# Warnings and worse within half an hour either side; info lines left out.
assert "nfsd: server not responding" in around and "100% of quota used" in around
assert "eth0 link up" not in around and "an old error" not in around
elsewhere = await (await client.get("/plugins/syslog/around", query_string={
"source": "traefik", "resource": "web@docker", "since": since})).get_data(as_text=True)
assert "No added syslog source" in elsewhere
assert (await client.get("/plugins/syslog/around?source=host_agent")).status_code == 400
day = await (await client.get(f"/plugins/syslog/marks?host_id={host_id}&range=24h")).get_json()
assert [m["text"] for m in day["marks"]] == [f"{src_name} err kernel: nfsd: server not responding"]
assert day["more"] is False
week = await (await client.get(f"/plugins/syslog/marks?host_id={host_id}&hours=168")).get_json()
assert len(week["marks"]) == 2
assert (await (await client.get("/plugins/syslog/marks?address=10.1.1.1")).get_json())["marks"] == []
# get_anomaly's circumstances: the host's lines, and nothing for a
# resource syslog can't place.
async with app.db_sessionmaker() as s:
events = await mcp_tools.mcp_events(s, host_id, host_name, now - timedelta(hours=1), now)
assert [(e["kind"], e["subject"]) for e in events] == [("syslog", src_name)] * 2
assert events[0]["what"].startswith("err kernel: nfsd")
assert await mcp_tools.mcp_resource_events(s, "traefik", "web@docker", now - timedelta(hours=1), now) == []
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete_sources())
asyncio.run(go())