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
+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}