feat(syslog): a source's lines to read and search, on its page, its host's and its SNMP device's, and over MCP (#6000)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m36s
CI / publish (push) Successful in 1m6s

- /plugins/syslog/sources/<id>: the lines newest first, loading as you
  scroll, filtered by severity and worse, app, text and time, with day
  dividers and times to the second in the reader's zone.
- The Syslog page links each source and shows its lines in the last hour.
- A Syslog panel on a host's page and an SNMP device's: the newest lines of
  the sources bound to it or at its address.
- MCP: list_syslog_sources and search_syslog (source, host, text, severity
  and worse, app, range, paging by next_before).
- Shared: escape_like moves to steward/core/like.py and cut_middle to the
  MCP shaping module, replacing four inline copies; |when takes seconds=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 06:05:52 -04:00
co-authored by Claude Opus 5.5
parent bf6019a2af
commit dadec4e355
27 changed files with 720 additions and 40 deletions
+112
View File
@@ -0,0 +1,112 @@
# 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)]