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
+5 -3
View File
@@ -128,18 +128,20 @@ def ago(when: datetime | None, now: datetime | None = None, tz: tzinfo | None =
return _time(when, when.astimezone(tz or timezone.utc), text)
def when(moment: datetime | None, tz: tzinfo | None = None, now: datetime | None = None) -> Markup:
def when(moment: datetime | None, tz: tzinfo | None = None, now: datetime | None = None,
seconds: bool = False) -> Markup:
"""The exact moment in the reader's zone on a 24-hour clock, always with
its date: 'Thu 8 Oct 06:58' this year, '6 Oct 2025 14:12' before it. A
list that groups by recency says Today or Yesterday in a divider
(day_group), never in place of the date (#5214)."""
(day_group), never in place of the date (#5214). `seconds` adds them
('Thu 8 Oct 06:58:12'), for log lines a second apart."""
if moment is None:
return Markup(NONE)
tz = tz or timezone.utc
moment = _utc(moment)
local = moment.astimezone(tz)
this_year = (now or datetime.now(timezone.utc)).astimezone(tz).year
day, hm = local.date(), f"{local:%H:%M}"
day, hm = local.date(), f"{local:%H:%M:%S}" if seconds else f"{local:%H:%M}"
if day.year == this_year:
text = f"{local:%a} {day.day} {local:%b} {hm}"
else:
+10
View File
@@ -0,0 +1,10 @@
"""Text matched literally inside a LIKE / ILIKE pattern.
stmt.where(Line.message.ilike(f"%{escape_like(text)}%", escape="\\"))
"""
from __future__ import annotations
def escape_like(text: str) -> str:
"""Make `text` match literally inside a LIKE/ILIKE pattern (escape char \\)."""
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+2 -1
View File
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, or_, select, text
from steward.core.like import escape_like
from steward.core.time_range import bucket_seconds
from steward.models.metrics import PluginMetric, PluginMetricHourly, PluginMetricLatest
@@ -51,7 +52,7 @@ async def resources(db, source_module: str) -> list[str]:
def _resource_match(resource: str, include_children: bool):
match = PluginMetricLatest.resource_name == resource
if include_children:
like = resource.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + ":%"
like = escape_like(resource) + ":%"
match = or_(match, PluginMetricLatest.resource_name.like(like, escape="\\"))
return match