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)
- /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:
+2
-1
@@ -147,7 +147,8 @@ def create_app(
|
||||
from quart import has_request_context, session
|
||||
return _fmt.zone(session.get("tz")) if has_request_context() else _fmt.zone(None)
|
||||
app.jinja_env.filters["ago"] = lambda moment, now=None: _fmt.ago(moment, now, tz=_viewer_zone())
|
||||
app.jinja_env.filters["when"] = lambda moment: _fmt.when(moment, _viewer_zone())
|
||||
app.jinja_env.filters["when"] = lambda moment, seconds=False: _fmt.when(moment, _viewer_zone(),
|
||||
seconds=seconds)
|
||||
app.jinja_env.globals["tz_label"] = lambda: _fmt.tz_label(_viewer_zone())
|
||||
app.jinja_env.globals["status_class"] = (
|
||||
lambda value, kind: _fmt.status_class(value, kind, app.config.get("THRESHOLDS", {}))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("_", "\\_")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -214,4 +214,9 @@
|
||||
<div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Syslog (renders nothing unless an added syslog source maps to this host) ── #}
|
||||
{% if "syslog" in enabled_plugins %}
|
||||
<div hx-get="/plugins/syslog/panel?host_id={{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user