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) | | 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) | | 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 | | 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 | | 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 ### 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 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 it". Host metrics charts and SNMP device charts mark each error with a
dashed red line. dashed red line.
- **MCP.** `list_syslog_sources` and `search_syslog`. - **MCP.** `list_syslog_sources`, `search_syslog`, `list_syslog_patterns` and `add_syslog_preset`.
## Deploying ## Deploying
+52 -2
View File
@@ -8,12 +8,14 @@ from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from steward.core.paging import keyset 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.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.hosts import Host
from steward.models.users import UserRole 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 .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, 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) 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 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]: def _as_events(lines: list[SyslogLine], sources: list[SyslogSource]) -> list[dict]:
from steward.mcp.tools._events import event from steward.mcp.tools._events import event
names = {s.id: source_name(s) for s in sources} 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: 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(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" 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: def parse_form(form) -> dict:
"""A pattern's fields from its form, or PatternError saying what to fix.""" """A pattern's fields from its form, or PatternError saying what to fix."""
name = (form.get("name") or "").strip()[:100] 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 .models import SyslogLine, SyslogPattern, SyslogSource
from .network import on_own_network from .network import on_own_network
from .parse import SEVERITIES, facility_name, severity_name 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) resource_of)
from .receiver import LISTENER, MAX_SENDERS, send_to, settings from .receiver import LISTENER, MAX_SENDERS, send_to, settings
from .service import (AROUND_LINES, AROUND_SEVERITY, MARK_SEVERITY, MAX_MARKS, PANEL_LINES, PANEL_SPAN, 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())) 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: async def _accepted_sources(db) -> list:
return list((await db.execute(select(SyslogSource).where(SyslogSource.state == "accepted") return list((await db.execute(select(SyslogSource).where(SyslogSource.state == "accepted")
.order_by(SyslogSource.name))).scalars()) .order_by(SyslogSource.name))).scalars())
@@ -368,7 +359,7 @@ async def add_preset(key: str):
abort(404) abort(404)
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): 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) phrases=list(preset.phrases), max_severity=preset.max_severity)
db.add(pattern) db.add(pattern)
await db.flush() await db.flush()
@@ -402,7 +393,7 @@ async def create_pattern():
async with db.begin(): async with db.begin():
if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None: if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None:
values["source_id"] = 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) db.add(pattern)
await db.flush() await db.flush()
pattern_id = pattern.id 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()) 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): def test_search_syslog_filters_and_pages(app):
from steward.core.api_tokens import Caller from steward.core.api_tokens import Caller
from steward.mcp import _context from steward.mcp import _context