feat(syslog): list patterns and add a ready-made one through MCP
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user