feat(syslog): lines beside the readings: around an anomaly, in get_anomaly, and marked on host and SNMP charts (#6002)
get_anomaly's circumstances gain the host's or SNMP device's syslog warnings and worse (mcp_events / mcp_resource_events). An anomaly row opens "Syslog around it", read only when opened. A shared Chart.js plugin (chart-marks.js) draws a dashed line at each syslog error on the host metrics charts and an SNMP device's charts, with a line saying how many. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,11 +14,8 @@ 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
|
||||
from .service import (AROUND_LINES, AROUND_SEVERITY, LineFilter, filtered, lines_between, place_for, rates,
|
||||
severity_level, shown_on, source_name, sources_for)
|
||||
|
||||
|
||||
async def list_syslog_sources(state: str = "") -> dict:
|
||||
@@ -43,7 +40,7 @@ async def list_syslog_sources(state: str = "") -> dict:
|
||||
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,
|
||||
"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),
|
||||
@@ -94,14 +91,14 @@ async def search_syslog(source: str = "", host: str = "", text: str = "", severi
|
||||
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)]
|
||||
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}
|
||||
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)
|
||||
@@ -122,6 +119,33 @@ async def search_syslog(source: str = "", host: str = "", text: str = "", severi
|
||||
return out
|
||||
|
||||
|
||||
def _as_events(lines: list[SyslogLine], sources: list[SyslogSource]) -> list[dict]:
|
||||
from steward.mcp.tools._events import event
|
||||
names = {s.id: source_name(s) for s in sources}
|
||||
return [event(ln.received_at, "syslog",
|
||||
f"{severity_name(ln.severity)}{' ' + ln.app if ln.app else ''}: {cut_middle(ln.message, 300)}",
|
||||
subject=names[ln.source_id]) for ln in reversed(lines)]
|
||||
|
||||
|
||||
async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dict]:
|
||||
"""Warnings and worse in a window (get_anomaly) from the syslog sources
|
||||
shown on a host: bound to it, or at its address."""
|
||||
sources = await shown_on(db, host_id, None)
|
||||
return _as_events(await lines_between(db, sources, since, until, AROUND_SEVERITY, AROUND_LINES), sources)
|
||||
|
||||
|
||||
async def mcp_resource_events(db, source: str, resource: str, since, until) -> list[dict]:
|
||||
"""The same for an SNMP device, which has no host to read events by: the
|
||||
sources at its address, or bound to its host."""
|
||||
if source != "snmp":
|
||||
return []
|
||||
host_id, address = await place_for(db, source, resource)
|
||||
if not (host_id or address):
|
||||
return []
|
||||
sources = await shown_on(db, host_id, address)
|
||||
return _as_events(await lines_between(db, sources, since, until, AROUND_SEVERITY, AROUND_LINES), sources)
|
||||
|
||||
|
||||
def register_tools(register) -> None:
|
||||
for fn in (list_syslog_sources, search_syslog):
|
||||
register(fn, role=UserRole.viewer, read_only=True)
|
||||
|
||||
Reference in New Issue
Block a user