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>
169 lines
7.2 KiB
Python
169 lines
7.2 KiB
Python
# plugins/syslog/service.py
|
|
"""Reading the kept syslog lines: a source's lines filtered, the sources a
|
|
host's or an SNMP device's page shows, and each source's rate. Shared by the
|
|
pages and the MCP tool, so both filter the same way (#6000)."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from steward.core.like import escape_like
|
|
from steward.core.time_range import RANGES
|
|
|
|
from .models import SyslogLine, SyslogSource
|
|
from .parse import SEVERITIES
|
|
|
|
# The severity filter's choices: a level and everything worse than it.
|
|
SEVERITY_CHOICES = (("3", "Errors and worse"), ("4", "Warnings and worse"),
|
|
("5", "Notices and worse"), ("6", "Info and worse"))
|
|
DEFAULT_RANGE = "24h"
|
|
# Lines a host's or a device's page shows, from how far back.
|
|
PANEL_LINES = 15
|
|
PANEL_SPAN = timedelta(hours=24)
|
|
# How far back the rate on the Syslog page counts.
|
|
RATE_WINDOW = timedelta(hours=1)
|
|
|
|
|
|
@dataclass
|
|
class LineFilter:
|
|
severity: int | None = None # this level and worse (lower numbers)
|
|
app: str = ""
|
|
text: str = ""
|
|
range: str = DEFAULT_RANGE
|
|
|
|
def since(self, now: datetime | None = None) -> datetime:
|
|
return (now or datetime.now(timezone.utc)) - RANGES[self.range]
|
|
|
|
def args(self) -> dict:
|
|
"""The filter as query arguments, the empty ones left out."""
|
|
out = {"severity": "" if self.severity is None else str(self.severity), "app": self.app,
|
|
"q": self.text, "range": self.range if self.range != DEFAULT_RANGE else ""}
|
|
return {k: v for k, v in out.items() if v}
|
|
|
|
|
|
def severity_level(value: str | int | None) -> int | None:
|
|
"""A severity as its level: a number 0-7 or a name ("err", "warning")."""
|
|
if value is None or value == "":
|
|
return None
|
|
text = str(value).strip().lower()
|
|
if text.isdigit() and 0 <= int(text) <= 7:
|
|
return int(text)
|
|
aliases = {"error": "err", "warn": "warning", "critical": "crit", "emergency": "emerg"}
|
|
text = aliases.get(text, text)
|
|
if text in SEVERITIES:
|
|
return SEVERITIES.index(text)
|
|
raise ValueError(f"severity {value!r} is not 0-7 or one of {', '.join(SEVERITIES)}.")
|
|
|
|
|
|
def line_filter(args) -> LineFilter:
|
|
"""A filter from a page's query arguments; a value that doesn't read is
|
|
left out rather than failing the page."""
|
|
try:
|
|
severity = severity_level(args.get("severity"))
|
|
except ValueError:
|
|
severity = None
|
|
range_ = args.get("range") or DEFAULT_RANGE
|
|
return LineFilter(severity=severity, app=(args.get("app") or "").strip()[:128],
|
|
text=(args.get("q") or "").strip()[:200],
|
|
range=range_ if range_ in RANGES else DEFAULT_RANGE)
|
|
|
|
|
|
def filtered(stmt, f: LineFilter, now: datetime | None = None):
|
|
stmt = stmt.where(SyslogLine.received_at >= f.since(now))
|
|
if f.severity is not None:
|
|
stmt = stmt.where(SyslogLine.severity <= f.severity)
|
|
if f.app:
|
|
stmt = stmt.where(SyslogLine.app == f.app)
|
|
if f.text:
|
|
stmt = stmt.where(SyslogLine.message.ilike(f"%{escape_like(f.text)}%", escape="\\"))
|
|
return stmt
|
|
|
|
|
|
async def apps_of(db, source_id: str, since: datetime) -> list[str]:
|
|
"""The apps a source's lines name since `since`, for the filter's choices."""
|
|
rows = await db.execute(
|
|
select(SyslogLine.app).where(SyslogLine.source_id == source_id, SyslogLine.received_at >= since,
|
|
SyslogLine.app != "")
|
|
.group_by(SyslogLine.app).order_by(SyslogLine.app).limit(100))
|
|
return [a for (a,) in rows]
|
|
|
|
|
|
async def rates(db, source_ids: list[str], now: datetime | None = None) -> dict[str, int]:
|
|
"""{source id: lines received in the last hour} for kept sources."""
|
|
if not source_ids:
|
|
return {}
|
|
since = (now or datetime.now(timezone.utc)) - RATE_WINDOW
|
|
rows = await db.execute(
|
|
select(SyslogLine.source_id, func.count()).where(
|
|
SyslogLine.source_id.in_(source_ids), SyslogLine.received_at >= since)
|
|
.group_by(SyslogLine.source_id))
|
|
return {sid: n for sid, n in rows}
|
|
|
|
|
|
def sources_for(sources: list[SyslogSource], host_id: str | None, address: str | None) -> list[SyslogSource]:
|
|
"""The added sources a host's or a device's page shows: those bound to
|
|
the host, and those bound to no host whose address is the one given. A
|
|
binding is exclusive, as for SNMP devices: a source bound elsewhere never
|
|
matches by address."""
|
|
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())
|