- /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>
128 lines
6.6 KiB
Python
128 lines
6.6 KiB
Python
"""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)
|