# 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)]