feat(syslog): list patterns and add a ready-made one through MCP
CI / lint (push) Successful in 1s
CI / unit (push) Failing after 1m35s
CI / integration (push) Successful in 1m47s
CI / publish (push) Skipped

Patterns could only be added on the Syslog page, so an agent asked to map
them on the NASes couldn't. list_syslog_patterns gives each pattern's metric
and the ready-made ones not added; add_syslog_preset (admin, audited) adds one.
The key-picking helper moves beside the patterns for both callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 19:04:34 -04:00
co-authored by Claude Opus 5.5
parent 4cce3f0d12
commit 45aae6d585
6 changed files with 99 additions and 16 deletions
+52 -2
View File
@@ -8,12 +8,14 @@ from datetime import datetime
from sqlalchemy import select
from steward.core.paging import keyset
from steward.mcp.tools._audit import audit_mcp
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 .models import SyslogLine, SyslogPattern, SyslogSource
from .parse import facility_name, severity_name
from .patterns import PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, free_key, resource_of
from .service import (AROUND_LINES, AROUND_SEVERITY, LineFilter, addresses_by_name, device_address, filtered,
lines_between, place_for, rates, severity_level, shown_on, source_name, sources_for)
@@ -127,6 +129,53 @@ async def search_syslog(source: str = "", host: str = "", text: str = "", severi
return out
async def list_syslog_patterns() -> dict:
"""The phrases and severities counted in the kept syslog lines, and the
ready-made ones not added yet.
Each pattern's count of matching lines over its window is the metric
`metric` of source module `source_module`, per added source (`resources`,
empty until a source is added), written every minute with zeros included.
An alert rule on it ("> 0") fires on the first match and clears once the
window has passed. add_syslog_preset adds a ready-made one.
"""
async with db_session() as db:
patterns = list((await db.execute(select(SyslogPattern).order_by(SyslogPattern.name))).scalars())
accepted = list((await db.execute(
select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars())
taken = {p.key for p in patterns}
return {"source_module": SOURCE_MODULE, "patterns": [{
"key": p.key, "name": p.name, "metric": p.metric, "phrases": p.phrases or [],
"severity_and_worse": severity_name(p.max_severity) if p.max_severity is not None else None,
"window_minutes": p.window_minutes, "enabled": p.enabled,
"resources": [resource_of(s) for s in accepted if p.source_id in (None, s.id)],
} for p in patterns], "presets_not_added": [{
"key": p.key, "name": p.name, "about": p.about,
} for p in PRESETS if p.key not in taken]}
async def add_syslog_preset(key: str) -> dict:
"""Add a ready-made pattern, counted for every added source. The same as
"Add" under Syslog → Patterns. Disk I/O errors, disk resets, NFS server
trouble, filesystem errors, degraded arrays and errors and worse:
list_syslog_patterns gives their keys. Recorded in the audit log as
"<you> via MCP".
"""
preset = PRESETS_BY_KEY.get((key or "").strip())
if preset is None:
raise ValueError(f"No preset {key!r}. Presets: {', '.join(p.key for p in PRESETS)}.")
async with db_session() as db, db.begin():
if (await db.execute(select(SyslogPattern).where(SyslogPattern.key == preset.key))).scalars().first():
raise ValueError(f"{preset.name!r} is already added.")
pattern = SyslogPattern(key=await free_key(db, preset.key), name=preset.name,
phrases=list(preset.phrases), max_severity=preset.max_severity)
db.add(pattern)
await db.flush()
added = {"pattern_id": pattern.id, "key": pattern.key, "name": pattern.name, "metric": pattern.metric}
await audit_mcp("syslog.pattern_added", "syslog_pattern", preset.name, {"preset": preset.key})
return added
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}
@@ -155,5 +204,6 @@ async def mcp_resource_events(db, source: str, resource: str, since, until) -> l
def register_tools(register) -> None:
for fn in (list_syslog_sources, search_syslog):
for fn in (list_syslog_sources, search_syslog, list_syslog_patterns):
register(fn, role=UserRole.viewer, read_only=True)
register(add_syslog_preset, role=UserRole.admin, read_only=False)