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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user