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:
@@ -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())
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Reading the kept syslog lines (#6000): the filter read from a page's
|
||||
arguments, severity by name or number, and which sources a host's or a
|
||||
device's page shows."""
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import pytest
|
||||
from werkzeug.datastructures import MultiDict
|
||||
|
||||
from plugins.syslog.service import DEFAULT_RANGE, line_filter, severity_level, sources_for
|
||||
|
||||
|
||||
def test_a_severity_reads_by_name_alias_or_number():
|
||||
assert severity_level("err") == 3 and severity_level("error") == 3 and severity_level("3") == 3
|
||||
assert severity_level("Warning") == 4 and severity_level("warn") == 4
|
||||
assert severity_level("") is None and severity_level(None) is None
|
||||
with pytest.raises(ValueError, match="0-7"):
|
||||
severity_level("loud")
|
||||
|
||||
|
||||
def test_a_page_filter_drops_what_does_not_read():
|
||||
f = line_filter(MultiDict({"severity": "loud", "range": "9y", "q": " nfsd ", "app": "kernel"}))
|
||||
assert (f.severity, f.range, f.text, f.app) == (None, DEFAULT_RANGE, "nfsd", "kernel")
|
||||
assert f.args() == {"q": "nfsd", "app": "kernel"}
|
||||
assert line_filter(MultiDict({"severity": "4", "range": "7d"})).args() == {"severity": "4", "range": "7d"}
|
||||
|
||||
|
||||
def _src(sid, state="accepted", host_id=None, address="10.0.0.5"):
|
||||
return NS(id=sid, state=state, host_id=host_id, address=address)
|
||||
|
||||
|
||||
def test_a_page_shows_sources_bound_to_its_host_or_unbound_at_its_address():
|
||||
sources = [_src("bound", host_id="h1", address="10.9.9.9"), _src("by-address"),
|
||||
_src("elsewhere", host_id="h2"), _src("offered", state="offered"),
|
||||
_src("other", address="10.0.0.6")]
|
||||
assert [s.id for s in sources_for(sources, "h1", "10.0.0.5")] == ["bound", "by-address"]
|
||||
# An SNMP device bound to no host matches by its address alone.
|
||||
assert [s.id for s in sources_for(sources, None, "10.0.0.5")] == ["by-address"]
|
||||
assert sources_for(sources, None, None) == []
|
||||
@@ -20,7 +20,7 @@ def _source(name, state, level=6, line="nfsd: server not responding", host=None)
|
||||
"hostname_seen": "synnas", "host": host, "first_seen_at": now - timedelta(days=1),
|
||||
"last_seen_at": now - timedelta(minutes=3), "lines_total": 1204,
|
||||
"severity": "err" if level == 3 else "info", "level": level, "last_line": line,
|
||||
"suggested_host": "h1"}
|
||||
"suggested_host": "h1", "rate": 12 if state == "accepted" else None}
|
||||
|
||||
|
||||
async def _render(app, role, **ctx):
|
||||
@@ -49,7 +49,8 @@ async def test_an_offered_sender_can_be_added_or_ignored_by_an_admin(app):
|
||||
async def test_a_viewer_sees_sources_but_no_controls(app):
|
||||
page = await _render(app, "viewer", sources=[_source("SynNAS", "accepted", host=NS(id="h1", name="SynNAS"))],
|
||||
offered=[_source("router", "offered")])
|
||||
assert 'href="/hosts/h1"' in page and "/plugins/syslog/sources/" not in page
|
||||
assert 'href="/hosts/h1"' in page and 'href="/plugins/syslog/sources/id-SynNAS"' in page
|
||||
assert "/add" not in page and "/ignore" not in page and "/edit" not in page
|
||||
assert "Sending, not added" in page
|
||||
|
||||
|
||||
@@ -71,3 +72,62 @@ async def test_the_edit_page_has_its_trail_and_a_remove(app):
|
||||
page = " ".join(page.split())
|
||||
assert 'href="/plugins/syslog/">Syslog</a>' in page
|
||||
assert 'action="/plugins/syslog/sources/s1/delete"' in page and "data-confirm=" in page
|
||||
|
||||
|
||||
def _line(i, level=6, app="kernel", message="nfsd: server not responding", minutes=1):
|
||||
now = datetime.now(timezone.utc)
|
||||
return {"id": f"l{i}", "received_at": now - timedelta(minutes=minutes), "sent_at": None,
|
||||
"level": level, "severity": {3: "err", 4: "warning"}.get(level, "info"),
|
||||
"facility": "kern", "hostname": "synnas", "app": app, "message": message,
|
||||
"group": "Today", "opens_group": i == 0}
|
||||
|
||||
|
||||
async def test_a_source_page_filters_its_lines_and_scrolls_on(app):
|
||||
from quart import session
|
||||
from plugins.syslog.service import SEVERITY_CHOICES, LineFilter
|
||||
src = {**_source("SynNAS", "accepted"), "rate": 42}
|
||||
async with app.test_request_context("/plugins/syslog/sources/id-SynNAS"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_plugin_template(
|
||||
app, "syslog", "syslog/source.html", source=src, f=LineFilter(severity=3), filtered=True,
|
||||
lines=[_line(0, level=3), _line(1, app="", message="a <b> tag")],
|
||||
more_url="/plugins/syslog/sources/id-SynNAS?severity=3&before=x", severities=SEVERITY_CHOICES,
|
||||
apps=["kernel", "nfsd"], ranges=["1h", "24h"])
|
||||
page = " ".join(page.split())
|
||||
assert 'href="/plugins/syslog/">Syslog</a>' in page # its trail
|
||||
assert "42 lines in the last hour" in page
|
||||
assert '<option value="3" selected>Errors and worse</option>' in page
|
||||
assert '<tr class="table-divider"><th colspan="4" scope="rowgroup">Today</th></tr>' in page
|
||||
assert '<span class="tone-crit">err</span>' in page and "a <b> tag" in page
|
||||
assert 'hx-get="/plugins/syslog/sources/id-SynNAS?severity=3&before=x"' in page
|
||||
assert "/edit" not in page # a viewer
|
||||
main = page[page.index("<main"):]
|
||||
assert "font-weight:600" not in main and not hex_colours(main), hex_colours(main)
|
||||
|
||||
|
||||
async def test_a_filtered_source_with_no_lines_offers_them_all_back(app):
|
||||
from quart import session
|
||||
from plugins.syslog.service import LineFilter
|
||||
async with app.test_request_context("/plugins/syslog/sources/id-SynNAS"):
|
||||
session["user_role"] = "viewer"
|
||||
table = await render_plugin_template(
|
||||
app, "syslog", "syslog/_lines_table.html", source=_source("SynNAS", "accepted"),
|
||||
f=LineFilter(text="nope"), filtered=True, lines=[], more_url=None)
|
||||
assert "No lines match these filters." in table and 'href="/plugins/syslog/sources/id-SynNAS"' in table
|
||||
|
||||
|
||||
async def test_the_panel_names_each_source_when_there_are_several(app):
|
||||
from quart import session
|
||||
lines = [{**_line(0, level=4), "source": "SynNAS", "source_id": "s1"},
|
||||
{**_line(1), "source": "router", "source_id": "s2"}]
|
||||
async with app.test_request_context("/plugins/syslog/panel"):
|
||||
session["user_role"] = "viewer"
|
||||
one = await render_plugin_template(app, "syslog", "syslog/panel.html", lines=lines[:1],
|
||||
sources=[{"id": "s1", "name": "SynNAS"}])
|
||||
two = await render_plugin_template(app, "syslog", "syslog/panel.html", lines=lines,
|
||||
sources=[{"id": "s1", "name": "SynNAS"}, {"id": "s2", "name": "router"}])
|
||||
none = await render_plugin_template(app, "syslog", "syslog/panel.html", lines=[],
|
||||
sources=[{"id": "s1", "name": "SynNAS"}])
|
||||
assert "<th>Source</th>" not in one and '<span class="tone-warn">warning</span>' in one
|
||||
assert "<th>Source</th>" in two and 'href="/plugins/syslog/sources/s2">router</a>' in two
|
||||
assert "No lines in the last day." in none
|
||||
|
||||
Reference in New Issue
Block a user