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
+120
View File
@@ -108,3 +108,123 @@ def test_the_ring_keeps_each_source_within_its_window_and_cap(app):
await s.execute(delete(SyslogSource))
asyncio.run(go())
async def _client(app, role):
import time
import uuid
from steward.models.users import User, UserRole
uid, tag = str(uuid.uuid4()), uuid.uuid4().hex[:8]
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"syslog-{tag}", email=f"syslog-{tag}@test",
password_hash="x", role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=f"syslog-{tag}",
_csrf="test-token", login_at=time.time())
return client
async def _seed(app, now):
"""A host, an added source bound to it with five lines, and an offered
sender. Returns (host_id, host_name, source_id, source_name)."""
import uuid
from steward.models.hosts import Host
SyslogLine, SyslogSource = _mod("models").SyslogLine, _mod("models").SyslogSource
tag = uuid.uuid4().hex[:8]
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete_sources())
host = Host(id=str(uuid.uuid4()), name=f"nas-{tag}", address=f"10.8.{int(tag[:2], 16)}.1")
s.add(host)
await s.flush()
src = SyslogSource(address="10.8.0.9", state="accepted", name=f"SynNAS-{tag}", host_id=host.id,
first_seen_at=now, last_seen_at=now, lines_total=5)
s.add_all([src, SyslogSource(address="10.8.0.10", first_seen_at=now, last_seen_at=now)])
await s.flush()
for minutes, sev, app_, text in ((50, 6, "kernel", "eth0 link up"),
(40, 3, "kernel", "nfsd: server not responding"),
(30, 4, "rsyncd", "100% of quota used"),
(20, 6, "sshd", "Accepted publickey for admin"),
(90 * 60, 3, "kernel", "an old error")):
s.add(SyslogLine(source_id=src.id, received_at=now - timedelta(minutes=minutes),
facility=0, severity=sev, hostname="synnas", app=app_, message=text))
return host.id, host.name, src.id, src.name
def delete_sources():
from sqlalchemy import delete
return delete(_mod("models").SyslogSource)
def test_a_source_page_filters_its_lines_and_hosts_show_them(app):
now = datetime.now(timezone.utc)
async def go():
host_id, _, src_id, src_name = await _seed(app, now)
client = await _client(app, "viewer")
page = await (await client.get(f"/plugins/syslog/sources/{src_id}")).get_data(as_text=True)
# The last day by default: the 90-hour-old error is outside it.
assert "nfsd: server not responding" in page and "an old error" not in page
assert "4 lines in the last hour" in page
errors = await (await client.get(f"/plugins/syslog/sources/{src_id}?severity=3&range=7d",
headers={"HX-Request": "true"})).get_data(as_text=True)
assert "nfsd: server not responding" in errors and "an old error" in errors
assert "eth0 link up" not in errors and "<html" not in errors # the table alone
# % matches literally; the app filter is exact.
pct = await (await client.get(f"/plugins/syslog/sources/{src_id}?q=100%25")).get_data(as_text=True)
assert "100% of quota used" in pct and "eth0 link up" not in pct
sshd = await (await client.get(f"/plugins/syslog/sources/{src_id}?app=sshd")).get_data(as_text=True)
assert "Accepted publickey" in sshd and "nfsd" not in sshd.split("<tbody>", 1)[1]
panel = await (await client.get(f"/plugins/syslog/panel?host_id={host_id}")).get_data(as_text=True)
assert src_name in panel and "Accepted publickey" in panel and "an old error" not in panel
by_address = await (await client.get("/plugins/syslog/panel?address=10.8.0.9")).get_data(as_text=True)
assert by_address == "" # bound to a host: never matched by address
assert await (await client.get("/plugins/syslog/panel?address=10.1.1.1")).get_data(as_text=True) == ""
index = await (await client.get("/plugins/syslog/")).get_data(as_text=True)
assert f'href="/plugins/syslog/sources/{src_id}"' in index
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete_sources())
asyncio.run(go())
def test_search_syslog_filters_and_pages(app):
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.models.users import UserRole
tools = _mod("mcp_tools")
now = datetime.now(timezone.utc)
async def go():
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id="u", username="viewer", role=UserRole.viewer, token_id=0))
_, host_name, _, src_name = await _seed(app, now)
errs = await tools.search_syslog(source=src_name, severity="err", range="7d")
assert [m["message"] for m in errs["matches"]] == ["nfsd: server not responding", "an old error"]
assert errs["matches"][0]["severity"] == "err" and errs["matches"][0]["app"] == "kernel"
# _ and % match literally: as wildcards "1_0%" would match "100% of quota used".
assert (await tools.search_syslog(host=host_name, text="1_0%"))["matches"] == []
assert len((await tools.search_syslog(host=host_name, text="100%"))["matches"]) == 1
page1 = await tools.search_syslog(source="10.8.0.9", limit=2)
assert page1["truncated"] and [m["message"] for m in page1["matches"]] == [
"Accepted publickey for admin", "100% of quota used"]
page2 = await tools.search_syslog(source="10.8.0.9", limit=2, before=page1["next_before"])
assert [m["message"] for m in page2["matches"]] == ["nfsd: server not responding", "eth0 link up"]
listed = await tools.list_syslog_sources()
states = {s["address"]: (s["state"], s["lines_last_hour"]) for s in listed["sources"]}
assert states == {"10.8.0.9": ("accepted", 4), "10.8.0.10": ("offered", None)}
with pytest.raises(ValueError, match="list_syslog_sources"):
await tools.search_syslog(source="no-such-source")
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete_sources())
asyncio.run(go())