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
+1 -1
View File
@@ -85,7 +85,7 @@ Each role includes the tools of the roles above it in this list.
| Traefik plugin | `list_routers` (each router graded as the Traefik page grades it, with the status codes behind a problem, what they mean and the swarm service behind it), `get_router` (one router's history, codes, normal and anomalies), `list_certificates` (served certificates, days left, the routers they cover), `get_traffic` (the access log's clients, countries and routers, and Traefik's own figures) |
| UniFi plugin | `get_network` (the WAN, clients, Wi-Fi networks, speed tests and devices by status), `list_devices`, `list_clients` (everyone seen, or the newest poll's top talkers), `list_network_events`, `list_alarms`, `get_network_config` (networks and VLANs, port forwards, firewall rules) |
| SNMP plugin | `list_snmp_devices` (each device's state, newest readings in their units and the readings it didn't return), `get_snmp_device` (one device's readings over a range). Never the community or v3 keys |
| Syslog plugin | `list_syslog_sources` (the devices sending syslog: added, offered or ignored, each with its rate, last heard and newest line), `search_syslog` (an added source's kept lines, or a host's, filtered by text, severity and worse, app and time, newest first) |
| Syslog plugin | `list_syslog_sources` (the devices sending syslog: added, offered or ignored, each with its rate, last heard and newest line), `search_syslog` (an added source's kept lines, or a host's, filtered by text, severity and worse, app and time, newest first), `list_syslog_patterns` (the phrases and severities counted, the metric each writes and the ready-made ones not added), `add_syslog_preset` (admin: add a ready-made one) |
| Prometheus plugin | `list_prometheus_targets` (each target's state and its newest scrape: series kept, dropped and over the cap), `get_prometheus_target` (a target's stored series by metric, with labels and newest values). Never the token, and a password in a URL is masked |
### Operator: run playbooks, change monitoring
+1 -1
View File
@@ -26,7 +26,7 @@ puts them beside Steward's other readings.
by the anomaly's host or SNMP device. Each anomaly row has "Syslog around
it". Host metrics charts and SNMP device charts mark each error with a
dashed red line.
- **MCP.** `list_syslog_sources` and `search_syslog`.
- **MCP.** `list_syslog_sources`, `search_syslog`, `list_syslog_patterns` and `add_syslog_preset`.
## Deploying
+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)
+10
View File
@@ -87,6 +87,16 @@ def key_for(name: str) -> str:
return _KEY.sub("_", name.lower()).strip("_")[:48] or "pattern"
async def free_key(db, wanted: str) -> str:
"""`wanted`, or that with a number where another pattern has it."""
taken = set((await db.execute(select(SyslogPattern.key))).scalars())
key, n = wanted, 2
while key in taken:
key = f"{wanted[:44]}_{n}"
n += 1
return key
def parse_form(form) -> dict:
"""A pattern's fields from its form, or PatternError saying what to fix."""
name = (form.get("name") or "").strip()[:100]
+3 -12
View File
@@ -21,7 +21,7 @@ from steward.models.users import UserRole
from .models import SyslogLine, SyslogPattern, SyslogSource
from .network import on_own_network
from .parse import SEVERITIES, facility_name, severity_name
from .patterns import (PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, WINDOWS, PatternError, key_for, parse_form,
from .patterns import (PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, WINDOWS, PatternError, free_key, key_for, parse_form,
resource_of)
from .receiver import LISTENER, MAX_SENDERS, send_to, settings
from .service import (AROUND_LINES, AROUND_SEVERITY, MARK_SEVERITY, MAX_MARKS, PANEL_LINES, PANEL_SPAN,
@@ -339,15 +339,6 @@ async def patterns_index():
any_sources=any(s.state == "accepted" for s in sources.values()))
async def _free_key(db, wanted: str) -> str:
taken = set((await db.execute(select(SyslogPattern.key))).scalars())
key, n = wanted, 2
while key in taken:
key = f"{wanted[:44]}_{n}"
n += 1
return key
async def _accepted_sources(db) -> list:
return list((await db.execute(select(SyslogSource).where(SyslogSource.state == "accepted")
.order_by(SyslogSource.name))).scalars())
@@ -368,7 +359,7 @@ async def add_preset(key: str):
abort(404)
async with current_app.db_sessionmaker() as db:
async with db.begin():
pattern = SyslogPattern(key=await _free_key(db, preset.key), name=preset.name,
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()
@@ -402,7 +393,7 @@ async def create_pattern():
async with db.begin():
if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None:
values["source_id"] = None
pattern = SyslogPattern(key=await _free_key(db, key_for(values["name"])), **values)
pattern = SyslogPattern(key=await free_key(db, key_for(values["name"])), **values)
db.add(pattern)
await db.flush()
pattern_id = pattern.id
+32
View File
@@ -290,6 +290,38 @@ def test_a_source_page_filters_its_lines_and_hosts_show_them(app):
asyncio.run(go())
def test_a_preset_is_added_once_through_mcp(app):
from sqlalchemy import delete
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.models.users import UserRole
tools = _mod("mcp_tools")
SyslogPattern = _mod("models").SyslogPattern
async def go():
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=None, username="admin", role=UserRole.admin, token_id=0))
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(SyslogPattern).where(SyslogPattern.key == "nfs"))
with pytest.raises(ValueError, match="No preset"):
await tools.add_syslog_preset("no-such-preset")
added = await tools.add_syslog_preset("nfs")
assert added["metric"] == "lines_matching_nfs"
listed = await tools.list_syslog_patterns()
assert "nfs" in {p["key"] for p in listed["patterns"]}
assert "nfs" not in {p["key"] for p in listed["presets_not_added"]}
with pytest.raises(ValueError, match="already added"):
await tools.add_syslog_preset("nfs")
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(SyslogPattern).where(SyslogPattern.key == "nfs"))
asyncio.run(go())
def test_search_syslog_filters_and_pages(app):
from steward.core.api_tokens import Caller
from steward.mcp import _context