feat(syslog): a source's lines to read and search, on its page, its host's and its SNMP device's, and over MCP (#6000)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m36s
CI / publish (push) Successful in 1m6s

- /plugins/syslog/sources/<id>: the lines newest first, loading as you
  scroll, filtered by severity and worse, app, text and time, with day
  dividers and times to the second in the reader's zone.
- The Syslog page links each source and shows its lines in the last hour.
- A Syslog panel on a host's page and an SNMP device's: the newest lines of
  the sources bound to it or at its address.
- MCP: list_syslog_sources and search_syslog (source, host, text, severity
  and worse, app, range, paging by next_before).
- Shared: escape_like moves to steward/core/like.py and cut_middle to the
  MCP shaping module, replacing four inline copies; |when takes seconds=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 06:05:52 -04:00
co-authored by Claude Opus 5.5
parent bf6019a2af
commit dadec4e355
27 changed files with 720 additions and 40 deletions
+10
View File
@@ -25,6 +25,8 @@ from steward.mcp._context import mcp_app
T = TypeVar("T")
SERIES_POINTS = 60 # max points in any returned series
# The longest log line a tool returns whole (container logs, syslog).
LOG_LINE_CHARS = 2000
def db_session():
@@ -134,6 +136,14 @@ def pick(want: str, rows: Iterable[T], *, kind: str, list_tool: str,
raise ValueError(f"no {kind} named or with id {want!r}.{hint} {list_tool} lists them.")
def cut_middle(text: str, limit: int = LOG_LINE_CHARS) -> str:
"""Keep both ends of an over-long line and say how much of the middle went."""
if text is None or len(text) <= limit:
return text
keep = limit // 2
return f"{text[:keep]} [… {len(text) - 2 * keep} characters omitted …] {text[-keep:]}"
def redact_url(url: str) -> str:
"""Drop a password from a URL's userinfo; the rest stays readable."""
try:
+2 -2
View File
@@ -5,6 +5,7 @@ import json
from sqlalchemy import or_, select
from steward.core.like import escape_like
from steward.models.audit import AuditEvent
from steward.models.users import UserRole
@@ -32,8 +33,7 @@ async def list_audit_events(action: str = "", user: str = "", entity_type: str =
stmt = (select(AuditEvent).where(AuditEvent.timestamp >= since)
.order_by(AuditEvent.timestamp.desc()).limit(limit + 1))
if action:
esc = action.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
stmt = stmt.where(AuditEvent.action.like(esc + "%", escape="\\"))
stmt = stmt.where(AuditEvent.action.like(escape_like(action) + "%", escape="\\"))
if user:
stmt = stmt.where(or_(AuditEvent.username == user,
AuditEvent.username == mcp_username(user)))
+2 -2
View File
@@ -23,6 +23,7 @@ from sqlalchemy.orm import selectinload
from steward.ansible import executor, guards, sources as src_module
from steward.ansible.inventory_gen import scope_labels
from steward.ansible.runner import trigger_run
from steward.core.like import escape_like
from steward.mcp._context import current_caller, current_caller_or_none, mcp_app, public_url
from steward.models.alerts import AlertRule
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
@@ -484,8 +485,7 @@ async def list_ansible_runs(status: str = "", playbook: str = "", range: str = "
raise ValueError(f"status {status!r} is not one of "
f"{', '.join(s.value for s in AnsibleRunStatus)}.") from None
if playbook:
esc = playbook.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
stmt = stmt.where(AnsibleRun.playbook_path.ilike(f"%{esc}%", escape="\\"))
stmt = stmt.where(AnsibleRun.playbook_path.ilike(f"%{escape_like(playbook)}%", escape="\\"))
async with db_session() as db:
runs = list((await db.execute(stmt)).scalars())
users = await _users(db, [r.triggered_by for r in runs])