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:
+64
-12
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user