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)
- /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:
@@ -13,11 +13,13 @@ from sqlalchemy import select
|
||||
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.core.audit import log_audit
|
||||
from steward.core.time_range import RANGES
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from .models import SyslogSource
|
||||
from .parse import severity_name
|
||||
from .models import SyslogLine, SyslogSource
|
||||
from .parse import facility_name, severity_name
|
||||
from .receiver import LISTENER, MAX_SENDERS, settings
|
||||
from .service import PANEL_LINES, PANEL_SPAN, SEVERITY_CHOICES, apps_of, filtered, line_filter, rates, sources_for
|
||||
|
||||
syslog_bp = Blueprint("syslog", __name__, template_folder="templates")
|
||||
|
||||
@@ -44,9 +46,9 @@ def listener_view(port: int, udp: bool, tcp: bool) -> dict:
|
||||
"senders_full": False, "max_senders": MAX_SENDERS}
|
||||
|
||||
|
||||
def source_view(s: SyslogSource, hosts: dict) -> dict:
|
||||
def source_view(s: SyslogSource, hosts: dict, rate: int | None = None) -> dict:
|
||||
host = hosts.get(s.host_id) if s.host_id else None
|
||||
return {"id": s.id, "address": s.address, "state": s.state,
|
||||
return {"id": s.id, "address": s.address, "state": s.state, "rate": rate,
|
||||
"name": s.name or s.hostname_seen or s.address,
|
||||
"hostname_seen": s.hostname_seen, "host": host,
|
||||
"first_seen_at": s.first_seen_at, "last_seen_at": s.last_seen_at,
|
||||
@@ -67,10 +69,11 @@ async def index():
|
||||
rows = list((await db.execute(
|
||||
select(SyslogSource).order_by(SyslogSource.last_seen_at.desc()))).scalars())
|
||||
hosts = await _hosts(db)
|
||||
per_hour = await rates(db, [r.id for r in rows if r.state == "accepted"])
|
||||
by_id = {h.id: h for h in hosts}
|
||||
views = {state: [] for state in ("accepted", "offered", "ignored")}
|
||||
for row in rows:
|
||||
v = source_view(row, by_id)
|
||||
v = source_view(row, by_id, per_hour.get(row.id, 0) if row.state == "accepted" else None)
|
||||
if row.state == "offered":
|
||||
v["suggested_host"] = suggested_host(row, hosts)
|
||||
views.setdefault(row.state, []).append(v)
|
||||
@@ -164,7 +167,7 @@ async def save_source(source_id: str):
|
||||
detail["old_name"] = old_name
|
||||
await log_audit(current_app, *_actor(), "syslog.source_updated", entity_type="syslog_source",
|
||||
entity_id=source_id, detail=detail)
|
||||
return redirect(INDEX)
|
||||
return redirect(f"/plugins/syslog/sources/{source_id}")
|
||||
|
||||
|
||||
@syslog_bp.post("/sources/<source_id>/delete")
|
||||
@@ -180,3 +183,81 @@ async def delete_source(source_id: str):
|
||||
await log_audit(current_app, *_actor(), "syslog.source_removed", entity_type="syslog_source",
|
||||
entity_id=source_id, detail=detail)
|
||||
return redirect(INDEX)
|
||||
|
||||
|
||||
# ── A source's lines, and the panel on a host's or an SNMP device's page ─────
|
||||
|
||||
def line_view(line) -> dict:
|
||||
return {"id": line.id, "received_at": line.received_at, "sent_at": line.sent_at,
|
||||
"level": line.severity, "severity": severity_name(line.severity),
|
||||
"facility": facility_name(line.facility), "hostname": line.hostname,
|
||||
"app": line.app, "message": line.message}
|
||||
|
||||
|
||||
@syslog_bp.get("/sources/<source_id>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def source_detail(source_id: str):
|
||||
"""One source's kept lines, newest first, a page at a time as the reader
|
||||
scrolls (core.paging), under a filter bar: severity and worse, app, text
|
||||
and time. ?before=<cursor> is the next page's rows alone; a request from
|
||||
the filter bar is the table alone."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from steward.audit.service import mark_day_groups
|
||||
from steward.core import formatting as fmt
|
||||
from steward.core.paging import keyset, next_page
|
||||
|
||||
f = line_filter(request.args)
|
||||
before = request.args.get("before")
|
||||
now = datetime.now(timezone.utc)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
source = await _source_or_404(db, source_id)
|
||||
stmt = keyset(filtered(select(SyslogLine).where(SyslogLine.source_id == source.id), f, now),
|
||||
SyslogLine.received_at, SyslogLine.id, before)
|
||||
lines, after = next_page((await db.execute(stmt)).scalars().all(), lambda ln: (ln.received_at, ln.id))
|
||||
page_only = bool(before) or bool(request.headers.get("HX-Request"))
|
||||
hosts = {} if page_only else {h.id: h for h in await _hosts(db)}
|
||||
apps = [] if page_only else await apps_of(db, source.id, f.since(now))
|
||||
per_hour = {} if page_only else await rates(db, [source.id], now)
|
||||
rows = [line_view(ln) for ln in lines]
|
||||
last_group = mark_day_groups(rows, fmt.zone(session.get("tz")), request.args.get("group", ""),
|
||||
at=lambda r: r["received_at"])
|
||||
base = f"/plugins/syslog/sources/{source_id}?"
|
||||
ctx = {"source": source_view(source, hosts, per_hour.get(source.id, 0)), "lines": rows,
|
||||
"more_url": base + urlencode({**f.args(), "before": after, "group": last_group}) if after else None,
|
||||
"f": f, "filtered": bool({k: v for k, v in f.args().items() if k != "range"}),
|
||||
"severities": SEVERITY_CHOICES, "apps": apps, "ranges": list(RANGES)}
|
||||
if before:
|
||||
return await render_template("syslog/_lines_page.html", **ctx)
|
||||
template = "syslog/_lines_table.html" if request.headers.get("HX-Request") else "syslog/source.html"
|
||||
resp = await current_app.make_response(await render_template(template, **ctx))
|
||||
resp.vary.add("HX-Request")
|
||||
return resp
|
||||
|
||||
|
||||
@syslog_bp.get("/panel")
|
||||
@require_role(UserRole.viewer)
|
||||
async def lines_panel():
|
||||
"""The syslog panel on a host's page (?host_id=) or an SNMP device's
|
||||
(?address=, and ?host_id= when it is bound): the newest lines of the
|
||||
sources that map to it. Nothing when no added source does."""
|
||||
from steward.models.hosts import Host
|
||||
|
||||
host_id = request.args.get("host_id") or None
|
||||
address = request.args.get("address") or None
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
if host_id and not address:
|
||||
host = await db.get(Host, host_id)
|
||||
address = host.address if host is not None else None
|
||||
matched = sources_for(list((await db.execute(
|
||||
select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars()), host_id, address)
|
||||
if not matched:
|
||||
return ""
|
||||
names = {s.id: s.name or s.hostname_seen or s.address for s in matched}
|
||||
since = datetime.now(timezone.utc) - PANEL_SPAN
|
||||
lines = (await db.execute(
|
||||
select(SyslogLine).where(SyslogLine.source_id.in_(list(names)), SyslogLine.received_at >= since)
|
||||
.order_by(SyslogLine.received_at.desc(), SyslogLine.id.desc()).limit(PANEL_LINES))).scalars().all()
|
||||
rows = [{**line_view(ln), "source": names[ln.source_id], "source_id": ln.source_id} for ln in lines]
|
||||
return await render_template("syslog/panel.html", lines=rows,
|
||||
sources=[{"id": sid, "name": name} for sid, name in names.items()])
|
||||
|
||||
Reference in New Issue
Block a user