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
+127
View File
@@ -0,0 +1,127 @@
"""The syslog plugin's MCP tools (#6000): the sources and the lines kept from
them. Registered through register_mcp_tools() in __init__.py, so they exist
only while the plugin is enabled. They read what the receiver stored."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from steward.core.paging import keyset
from steward.mcp.tools._shape import age_s, clamp_limit, cut_middle, db_session, iso, lookback, now_utc, pick
from steward.models.hosts import Host
from steward.models.users import UserRole
from .models import SyslogLine, SyslogSource
from .parse import facility_name, severity_name
from .service import LineFilter, filtered, rates, severity_level, sources_for
def _source_name(s: SyslogSource) -> str:
return s.name or s.hostname_seen or s.address
async def list_syslog_sources(state: str = "") -> dict:
"""The devices sending syslog to Steward.
state: "accepted" (added: their lines are kept and searchable),
"offered" (sending but not added: counted, not kept), "ignored";
empty for all.
Each gives its address, the host it is shown on, lines received in all
(`lines_total`) and, for an added source, in the last hour, when it was
last heard and its newest line. search_syslog searches the kept lines.
"""
if state and state not in ("accepted", "offered", "ignored"):
raise ValueError("state must be accepted, offered or ignored, or empty for all.")
now = now_utc()
async with db_session() as db:
stmt = select(SyslogSource).order_by(SyslogSource.last_seen_at.desc())
if state:
stmt = stmt.where(SyslogSource.state == state)
sources = list((await db.execute(stmt)).scalars())
per_hour = await rates(db, [s.id for s in sources if s.state == "accepted"], now)
hosts = {h.id: h.name for h in (await db.execute(select(Host))).scalars()}
return {"sources": [{
"source_id": s.id, "name": _source_name(s), "address": s.address, "state": s.state,
"host_id": s.host_id, "host_name": hosts.get(s.host_id), "calls_itself": s.hostname_seen or None,
"lines_total": s.lines_total, "lines_last_hour": per_hour.get(s.id, 0) if s.state == "accepted" else None,
"last_heard": iso(s.last_seen_at), "last_heard_age_s": age_s(s.last_seen_at, now),
"newest_line": {"severity": severity_name(s.last_severity) if s.last_severity is not None else None,
"text": cut_middle(s.last_line)},
} for s in sources]}
async def search_syslog(source: str = "", host: str = "", text: str = "", severity: str = "",
app: str = "", range: str = "1h", before: str = "", limit: int = 50) -> dict:
"""Search the syslog lines Steward has kept from added sources, newest
first: a NAS's, a router's or another device's own log.
source: a source's name, address or id (list_syslog_sources lists them);
empty searches every added source.
host: a host's name or id: the sources shown on that host (bound to it,
or at its address). Empty for all.
text: case-insensitive substring of the message; empty matches every line.
severity: this level and worse: "err" (errors, crit, alert, emerg),
"warning", "notice", "info", "debug", or 0-7. Empty for all.
app: the app or tag that wrote the line (e.g. "kernel", "sshd"), exact.
range: how far back — "1h" (default), "6h", "24h", "7d" or "30d". Each
source keeps its lines up to a size and age limit, so older ones may be gone.
before: an ISO time; only lines received before it. Pass a response's
`next_before` to page further back.
limit: lines to return, default 50, max 200.
`at` is when Steward received the line, the time to trust; `sent_at` is
the device's own clock, given only when the line carried its zone.
"""
_, key = lookback(range)
limit = clamp_limit(limit, 50, 200)
f = LineFilter(severity=severity_level(severity), app=app.strip(), text=text.strip(), range=key)
before_dt = None
if before:
try:
before_dt = datetime.fromisoformat(before.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"before {before!r} is not an ISO-8601 time; pass a response's next_before.") from exc
async with db_session() as db:
accepted = list((await db.execute(
select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars())
if not accepted:
return {"range": key, "matches": [], "returned": 0, "truncated": False,
"note": "No syslog source is added yet; list_syslog_sources shows who is sending."}
chosen = accepted
if source:
want = source.strip()
by_address = [s for s in accepted if s.address == want]
chosen = by_address or [pick(want, accepted, kind="syslog source",
list_tool="list_syslog_sources", name=_source_name)]
if host:
h = pick(host, list((await db.execute(select(Host))).scalars()), kind="host", list_tool="list_hosts")
chosen = sources_for(chosen, h.id, h.address)
if not chosen:
return {"range": key, "matches": [], "returned": 0, "truncated": False,
"note": f"No added syslog source is shown on {h.name}."}
names = {s.id: _source_name(s) for s in chosen}
stmt = filtered(select(SyslogLine).where(SyslogLine.source_id.in_(list(names))), f)
if before_dt is not None:
stmt = stmt.where(SyslogLine.received_at < before_dt)
rows = list((await db.execute(
keyset(stmt, SyslogLine.received_at, SyslogLine.id, None, size=limit))).scalars())
more = len(rows) > limit
rows = rows[:limit]
out = {"range": key, "returned": len(rows), "truncated": more, "matches": [{
"at": iso(r.received_at), "sent_at": iso(r.sent_at), "source": names[r.source_id],
"severity": severity_name(r.severity), "facility": facility_name(r.facility),
"app": r.app or None, "hostname": r.hostname or None, "message": cut_middle(r.message),
} for r in rows]}
if more:
# Full precision: `at` is cut to the second, and a cursor cut the same
# way would skip lines received earlier in that second.
out["next_before"] = rows[-1].received_at.isoformat()
out["more"] = "More lines are older; pass next_before as `before`, or narrow with `text` or `severity`."
return out
def register_tools(register) -> None:
for fn in (list_syslog_sources, search_syslog):
register(fn, role=UserRole.viewer, read_only=True)