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
+1
View File
@@ -83,6 +83,7 @@ Each role includes the tools of the roles above it in this list.
| Traefik plugin | `list_routers` (each router graded as the Traefik page grades it, with the status codes behind a problem, what they mean and the swarm service behind it), `get_router` (one router's history, codes, normal and anomalies), `list_certificates` (served certificates, days left, the routers they cover), `get_traffic` (the access log's clients, countries and routers, and Traefik's own figures) |
| UniFi plugin | `get_network` (the WAN, clients, Wi-Fi networks, speed tests and devices by status), `list_devices`, `list_clients` (everyone seen, or the newest poll's top talkers), `list_network_events`, `list_alarms`, `get_network_config` (networks and VLANs, port forwards, firewall rules) |
| SNMP plugin | `list_snmp_devices` (each device's state, newest readings in their units and the readings it didn't return), `get_snmp_device` (one device's readings over a range). Never the community or v3 keys |
| Syslog plugin | `list_syslog_sources` (the devices sending syslog: added, offered or ignored, each with its rate, last heard and newest line), `search_syslog` (an added source's kept lines, or a host's, filtered by text, severity and worse, app and time, newest first) |
| Prometheus plugin | `list_prometheus_targets` (each target's state and its newest scrape: series kept, dropped and over the cap), `get_prometheus_target` (a target's stored series by metric, with labels and newest values). Never the token, and a password in a URL is masked |
### Operator: run playbooks, change monitoring
+1 -10
View File
@@ -17,7 +17,7 @@ from steward.ansible.sources import BUILTIN_SOURCE_NAME
from steward.mcp._context import current_caller, mcp_app
from steward.mcp.tools._audit import audit_mcp
from steward.mcp.tools._shape import (
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd, summarize,
age_s, bounded, clamp_limit, cut_middle, db_session, iso, lookback, now_utc, pick, rnd, summarize,
)
from steward.mcp.tools.overview import finding
from steward.models.ansible_inventory import AnsibleTarget
@@ -31,19 +31,10 @@ from .models import DockerContainer, DockerDiskUsage, DockerEvent, DockerSwarmNo
from .swarm_view import build_swarm_services, replaced_tasks, task_time
CRASH_LOOP_DEATHS = docker_svc.CRASH_LOOP_DEATHS
LOG_LINE_CHARS = 2000
# ── pure helpers ─────────────────────────────────────────────────────────────
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 container_row(c, host_names: dict, problems: list[str], now: datetime,
replaced: bool = False, since: datetime | None = None) -> dict:
running = (c.status or "").lower() == "running"
+1 -5
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from steward.core import metric_series
from steward.core.like import escape_like
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
@@ -274,11 +275,6 @@ async def host_map(db, host_ids: set[str]) -> dict[str, Host]:
}
def escape_like(text: str) -> str:
"""Make `text` match literally inside a LIKE/ILIKE pattern (escape char \\)."""
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
async def list_containers(db, host_id: str | None = None) -> list[DockerContainer]:
"""Containers, running first then by name; one host's when host_id is given."""
stmt = select(DockerContainer)
+1
View File
@@ -50,6 +50,7 @@ def view(device: SnmpDevice, values: dict, now: datetime) -> dict:
state = device_state(device, _poll_seconds(), now)
names = {r.key: r.name for r in readings_for(device)}
return {"id": device.id, "name": device.name, "address": device.address, "port": device.port,
"host_id": device.host_id,
"version": device.version, "reach": state.reach, "says": state.says,
"polled_at": device.last_polled_at, "ok_at": device.last_ok_at,
"readings": shown_readings(device, values),
+5
View File
@@ -40,6 +40,11 @@
<div class="card field-hint">This device has no readings chosen{% if session.user_role == 'admin' %}; <a href="/plugins/snmp/devices/{{ device.id }}/edit">Edit</a> to choose some{% endif %}.</div>
{% endif %}
{# The device's own log, when it sends syslog to an added source (#6000). #}
{% if "syslog" in enabled_plugins %}
<div hx-get="/plugins/syslog/panel?address={{ device.address|urlencode }}{% if device.host_id %}&amp;host_id={{ device.host_id }}{% endif %}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %}
<script>
(function () {
var charts = {{ charts_json | safe }};
+6 -1
View File
@@ -34,7 +34,12 @@ def get_nav() -> list[dict]:
return [{"label": "Syslog", "href": "/plugins/syslog/"}]
def register_mcp_tools(register) -> None:
from .mcp_tools import register_tools
register_tools(register)
def get_audit_entities() -> dict:
"""Audit rows about a source name it and link to the Syslog page."""
from .models import SyslogSource
return {"syslog_source": (SyslogSource, "/plugins/syslog/")}
return {"syslog_source": (SyslogSource, "/plugins/syslog/sources/{}")}
+127
View File
@@ -0,0 +1,127 @@
"""The syslog plugin's MCP tools (#6000): the sources and the lines kept from
them. Registered through register_mcp_tools() in __init__.py, so they exist
only while the plugin is enabled. They read what the receiver stored."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from steward.core.paging import keyset
from steward.mcp.tools._shape import age_s, clamp_limit, cut_middle, db_session, iso, lookback, now_utc, pick
from steward.models.hosts import Host
from steward.models.users import UserRole
from .models import SyslogLine, SyslogSource
from .parse import facility_name, severity_name
from .service import LineFilter, filtered, rates, severity_level, sources_for
def _source_name(s: SyslogSource) -> str:
return s.name or s.hostname_seen or s.address
async def list_syslog_sources(state: str = "") -> dict:
"""The devices sending syslog to Steward.
state: "accepted" (added: their lines are kept and searchable),
"offered" (sending but not added: counted, not kept), "ignored";
empty for all.
Each gives its address, the host it is shown on, lines received in all
(`lines_total`) and, for an added source, in the last hour, when it was
last heard and its newest line. search_syslog searches the kept lines.
"""
if state and state not in ("accepted", "offered", "ignored"):
raise ValueError("state must be accepted, offered or ignored, or empty for all.")
now = now_utc()
async with db_session() as db:
stmt = select(SyslogSource).order_by(SyslogSource.last_seen_at.desc())
if state:
stmt = stmt.where(SyslogSource.state == state)
sources = list((await db.execute(stmt)).scalars())
per_hour = await rates(db, [s.id for s in sources if s.state == "accepted"], now)
hosts = {h.id: h.name for h in (await db.execute(select(Host))).scalars()}
return {"sources": [{
"source_id": s.id, "name": _source_name(s), "address": s.address, "state": s.state,
"host_id": s.host_id, "host_name": hosts.get(s.host_id), "calls_itself": s.hostname_seen or None,
"lines_total": s.lines_total, "lines_last_hour": per_hour.get(s.id, 0) if s.state == "accepted" else None,
"last_heard": iso(s.last_seen_at), "last_heard_age_s": age_s(s.last_seen_at, now),
"newest_line": {"severity": severity_name(s.last_severity) if s.last_severity is not None else None,
"text": cut_middle(s.last_line)},
} for s in sources]}
async def search_syslog(source: str = "", host: str = "", text: str = "", severity: str = "",
app: str = "", range: str = "1h", before: str = "", limit: int = 50) -> dict:
"""Search the syslog lines Steward has kept from added sources, newest
first: a NAS's, a router's or another device's own log.
source: a source's name, address or id (list_syslog_sources lists them);
empty searches every added source.
host: a host's name or id: the sources shown on that host (bound to it,
or at its address). Empty for all.
text: case-insensitive substring of the message; empty matches every line.
severity: this level and worse: "err" (errors, crit, alert, emerg),
"warning", "notice", "info", "debug", or 0-7. Empty for all.
app: the app or tag that wrote the line (e.g. "kernel", "sshd"), exact.
range: how far back — "1h" (default), "6h", "24h", "7d" or "30d". Each
source keeps its lines up to a size and age limit, so older ones may be gone.
before: an ISO time; only lines received before it. Pass a response's
`next_before` to page further back.
limit: lines to return, default 50, max 200.
`at` is when Steward received the line, the time to trust; `sent_at` is
the device's own clock, given only when the line carried its zone.
"""
_, key = lookback(range)
limit = clamp_limit(limit, 50, 200)
f = LineFilter(severity=severity_level(severity), app=app.strip(), text=text.strip(), range=key)
before_dt = None
if before:
try:
before_dt = datetime.fromisoformat(before.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"before {before!r} is not an ISO-8601 time; pass a response's next_before.") from exc
async with db_session() as db:
accepted = list((await db.execute(
select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars())
if not accepted:
return {"range": key, "matches": [], "returned": 0, "truncated": False,
"note": "No syslog source is added yet; list_syslog_sources shows who is sending."}
chosen = accepted
if source:
want = source.strip()
by_address = [s for s in accepted if s.address == want]
chosen = by_address or [pick(want, accepted, kind="syslog source",
list_tool="list_syslog_sources", name=_source_name)]
if host:
h = pick(host, list((await db.execute(select(Host))).scalars()), kind="host", list_tool="list_hosts")
chosen = sources_for(chosen, h.id, h.address)
if not chosen:
return {"range": key, "matches": [], "returned": 0, "truncated": False,
"note": f"No added syslog source is shown on {h.name}."}
names = {s.id: _source_name(s) for s in chosen}
stmt = filtered(select(SyslogLine).where(SyslogLine.source_id.in_(list(names))), f)
if before_dt is not None:
stmt = stmt.where(SyslogLine.received_at < before_dt)
rows = list((await db.execute(
keyset(stmt, SyslogLine.received_at, SyslogLine.id, None, size=limit))).scalars())
more = len(rows) > limit
rows = rows[:limit]
out = {"range": key, "returned": len(rows), "truncated": more, "matches": [{
"at": iso(r.received_at), "sent_at": iso(r.sent_at), "source": names[r.source_id],
"severity": severity_name(r.severity), "facility": facility_name(r.facility),
"app": r.app or None, "hostname": r.hostname or None, "message": cut_middle(r.message),
} for r in rows]}
if more:
# Full precision: `at` is cut to the second, and a cursor cut the same
# way would skip lines received earlier in that second.
out["next_before"] = rows[-1].received_at.isoformat()
out["more"] = "More lines are older; pass next_before as `before`, or narrow with `text` or `severity`."
return out
def register_tools(register) -> None:
for fn in (list_syslog_sources, search_syslog):
register(fn, role=UserRole.viewer, read_only=True)
+87 -6
View File
@@ -13,11 +13,13 @@ from sqlalchemy import select
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.core.time_range import RANGES
from steward.models.users import UserRole
from .models import SyslogSource
from .parse import severity_name
from .models import SyslogLine, SyslogSource
from .parse import facility_name, severity_name
from .receiver import LISTENER, MAX_SENDERS, settings
from .service import PANEL_LINES, PANEL_SPAN, SEVERITY_CHOICES, apps_of, filtered, line_filter, rates, sources_for
syslog_bp = Blueprint("syslog", __name__, template_folder="templates")
@@ -44,9 +46,9 @@ def listener_view(port: int, udp: bool, tcp: bool) -> dict:
"senders_full": False, "max_senders": MAX_SENDERS}
def source_view(s: SyslogSource, hosts: dict) -> dict:
def source_view(s: SyslogSource, hosts: dict, rate: int | None = None) -> dict:
host = hosts.get(s.host_id) if s.host_id else None
return {"id": s.id, "address": s.address, "state": s.state,
return {"id": s.id, "address": s.address, "state": s.state, "rate": rate,
"name": s.name or s.hostname_seen or s.address,
"hostname_seen": s.hostname_seen, "host": host,
"first_seen_at": s.first_seen_at, "last_seen_at": s.last_seen_at,
@@ -67,10 +69,11 @@ async def index():
rows = list((await db.execute(
select(SyslogSource).order_by(SyslogSource.last_seen_at.desc()))).scalars())
hosts = await _hosts(db)
per_hour = await rates(db, [r.id for r in rows if r.state == "accepted"])
by_id = {h.id: h for h in hosts}
views = {state: [] for state in ("accepted", "offered", "ignored")}
for row in rows:
v = source_view(row, by_id)
v = source_view(row, by_id, per_hour.get(row.id, 0) if row.state == "accepted" else None)
if row.state == "offered":
v["suggested_host"] = suggested_host(row, hosts)
views.setdefault(row.state, []).append(v)
@@ -164,7 +167,7 @@ async def save_source(source_id: str):
detail["old_name"] = old_name
await log_audit(current_app, *_actor(), "syslog.source_updated", entity_type="syslog_source",
entity_id=source_id, detail=detail)
return redirect(INDEX)
return redirect(f"/plugins/syslog/sources/{source_id}")
@syslog_bp.post("/sources/<source_id>/delete")
@@ -180,3 +183,81 @@ async def delete_source(source_id: str):
await log_audit(current_app, *_actor(), "syslog.source_removed", entity_type="syslog_source",
entity_id=source_id, detail=detail)
return redirect(INDEX)
# ── A source's lines, and the panel on a host's or an SNMP device's page ─────
def line_view(line) -> dict:
return {"id": line.id, "received_at": line.received_at, "sent_at": line.sent_at,
"level": line.severity, "severity": severity_name(line.severity),
"facility": facility_name(line.facility), "hostname": line.hostname,
"app": line.app, "message": line.message}
@syslog_bp.get("/sources/<source_id>")
@require_role(UserRole.viewer)
async def source_detail(source_id: str):
"""One source's kept lines, newest first, a page at a time as the reader
scrolls (core.paging), under a filter bar: severity and worse, app, text
and time. ?before=<cursor> is the next page's rows alone; a request from
the filter bar is the table alone."""
from urllib.parse import urlencode
from steward.audit.service import mark_day_groups
from steward.core import formatting as fmt
from steward.core.paging import keyset, next_page
f = line_filter(request.args)
before = request.args.get("before")
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
source = await _source_or_404(db, source_id)
stmt = keyset(filtered(select(SyslogLine).where(SyslogLine.source_id == source.id), f, now),
SyslogLine.received_at, SyslogLine.id, before)
lines, after = next_page((await db.execute(stmt)).scalars().all(), lambda ln: (ln.received_at, ln.id))
page_only = bool(before) or bool(request.headers.get("HX-Request"))
hosts = {} if page_only else {h.id: h for h in await _hosts(db)}
apps = [] if page_only else await apps_of(db, source.id, f.since(now))
per_hour = {} if page_only else await rates(db, [source.id], now)
rows = [line_view(ln) for ln in lines]
last_group = mark_day_groups(rows, fmt.zone(session.get("tz")), request.args.get("group", ""),
at=lambda r: r["received_at"])
base = f"/plugins/syslog/sources/{source_id}?"
ctx = {"source": source_view(source, hosts, per_hour.get(source.id, 0)), "lines": rows,
"more_url": base + urlencode({**f.args(), "before": after, "group": last_group}) if after else None,
"f": f, "filtered": bool({k: v for k, v in f.args().items() if k != "range"}),
"severities": SEVERITY_CHOICES, "apps": apps, "ranges": list(RANGES)}
if before:
return await render_template("syslog/_lines_page.html", **ctx)
template = "syslog/_lines_table.html" if request.headers.get("HX-Request") else "syslog/source.html"
resp = await current_app.make_response(await render_template(template, **ctx))
resp.vary.add("HX-Request")
return resp
@syslog_bp.get("/panel")
@require_role(UserRole.viewer)
async def lines_panel():
"""The syslog panel on a host's page (?host_id=) or an SNMP device's
(?address=, and ?host_id= when it is bound): the newest lines of the
sources that map to it. Nothing when no added source does."""
from steward.models.hosts import Host
host_id = request.args.get("host_id") or None
address = request.args.get("address") or None
async with current_app.db_sessionmaker() as db:
if host_id and not address:
host = await db.get(Host, host_id)
address = host.address if host is not None else None
matched = sources_for(list((await db.execute(
select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars()), host_id, address)
if not matched:
return ""
names = {s.id: s.name or s.hostname_seen or s.address for s in matched}
since = datetime.now(timezone.utc) - PANEL_SPAN
lines = (await db.execute(
select(SyslogLine).where(SyslogLine.source_id.in_(list(names)), SyslogLine.received_at >= since)
.order_by(SyslogLine.received_at.desc(), SyslogLine.id.desc()).limit(PANEL_LINES))).scalars().all()
rows = [{**line_view(ln), "source": names[ln.source_id], "source_id": ln.source_id} for ln in lines]
return await render_template("syslog/panel.html", lines=rows,
sources=[{"id": sid, "name": name} for sid, name in names.items()])
+112
View File
@@ -0,0 +1,112 @@
# plugins/syslog/service.py
"""Reading the kept syslog lines: a source's lines filtered, the sources a
host's or an SNMP device's page shows, and each source's rate. Shared by the
pages and the MCP tool, so both filter the same way (#6000)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from steward.core.like import escape_like
from steward.core.time_range import RANGES
from .models import SyslogLine, SyslogSource
from .parse import SEVERITIES
# The severity filter's choices: a level and everything worse than it.
SEVERITY_CHOICES = (("3", "Errors and worse"), ("4", "Warnings and worse"),
("5", "Notices and worse"), ("6", "Info and worse"))
DEFAULT_RANGE = "24h"
# Lines a host's or a device's page shows, from how far back.
PANEL_LINES = 15
PANEL_SPAN = timedelta(hours=24)
# How far back the rate on the Syslog page counts.
RATE_WINDOW = timedelta(hours=1)
@dataclass
class LineFilter:
severity: int | None = None # this level and worse (lower numbers)
app: str = ""
text: str = ""
range: str = DEFAULT_RANGE
def since(self, now: datetime | None = None) -> datetime:
return (now or datetime.now(timezone.utc)) - RANGES[self.range]
def args(self) -> dict:
"""The filter as query arguments, the empty ones left out."""
out = {"severity": "" if self.severity is None else str(self.severity), "app": self.app,
"q": self.text, "range": self.range if self.range != DEFAULT_RANGE else ""}
return {k: v for k, v in out.items() if v}
def severity_level(value: str | int | None) -> int | None:
"""A severity as its level: a number 0-7 or a name ("err", "warning")."""
if value is None or value == "":
return None
text = str(value).strip().lower()
if text.isdigit() and 0 <= int(text) <= 7:
return int(text)
aliases = {"error": "err", "warn": "warning", "critical": "crit", "emergency": "emerg"}
text = aliases.get(text, text)
if text in SEVERITIES:
return SEVERITIES.index(text)
raise ValueError(f"severity {value!r} is not 0-7 or one of {', '.join(SEVERITIES)}.")
def line_filter(args) -> LineFilter:
"""A filter from a page's query arguments; a value that doesn't read is
left out rather than failing the page."""
try:
severity = severity_level(args.get("severity"))
except ValueError:
severity = None
range_ = args.get("range") or DEFAULT_RANGE
return LineFilter(severity=severity, app=(args.get("app") or "").strip()[:128],
text=(args.get("q") or "").strip()[:200],
range=range_ if range_ in RANGES else DEFAULT_RANGE)
def filtered(stmt, f: LineFilter, now: datetime | None = None):
stmt = stmt.where(SyslogLine.received_at >= f.since(now))
if f.severity is not None:
stmt = stmt.where(SyslogLine.severity <= f.severity)
if f.app:
stmt = stmt.where(SyslogLine.app == f.app)
if f.text:
stmt = stmt.where(SyslogLine.message.ilike(f"%{escape_like(f.text)}%", escape="\\"))
return stmt
async def apps_of(db, source_id: str, since: datetime) -> list[str]:
"""The apps a source's lines name since `since`, for the filter's choices."""
rows = await db.execute(
select(SyslogLine.app).where(SyslogLine.source_id == source_id, SyslogLine.received_at >= since,
SyslogLine.app != "")
.group_by(SyslogLine.app).order_by(SyslogLine.app).limit(100))
return [a for (a,) in rows]
async def rates(db, source_ids: list[str], now: datetime | None = None) -> dict[str, int]:
"""{source id: lines received in the last hour} for kept sources."""
if not source_ids:
return {}
since = (now or datetime.now(timezone.utc)) - RATE_WINDOW
rows = await db.execute(
select(SyslogLine.source_id, func.count()).where(
SyslogLine.source_id.in_(source_ids), SyslogLine.received_at >= since)
.group_by(SyslogLine.source_id))
return {sid: n for sid, n in rows}
def sources_for(sources: list[SyslogSource], host_id: str | None, address: str | None) -> list[SyslogSource]:
"""The added sources a host's or a device's page shows: those bound to
the host, and those bound to no host whose address is the one given. A
binding is exclusive, as for SNMP devices: a source bound elsewhere never
matches by address."""
address = (address or "").strip().lower()
return [s for s in sources if s.state == "accepted" and (
s.host_id == host_id if s.host_id else bool(address) and s.address.lower() == address)]
@@ -0,0 +1,19 @@
{# plugins/syslog/templates/syslog/_lines_page.html — one page of lines, a
divider opening each recency group, then the sentinel that loads the next
page as the reader nears it (core.paging). #}
{% from "_macros.html" import scroll_more, scroll_end %}
{% from "syslog/_tone.html" import tone %}
{% for ln in lines %}
{% if ln.opens_group %}<tr class="table-divider"><th colspan="4" scope="rowgroup">{{ ln.group }}</th></tr>{% endif %}
<tr>
<td class="nowrap">{{ ln.received_at|when(seconds=true) }}</td>
<td class="nowrap"><span class="{{ tone(ln.level) }}">{{ ln.severity }}</span></td>
<td class="nowrap">{{ ln.app or "—" }}</td>
<td class="mono" style="white-space:pre-wrap;overflow-wrap:anywhere;">{{ ln.message }}</td>
</tr>
{% endfor %}
{% if more_url %}
{{ scroll_more(more_url, cols=4) }}
{% elif lines %}
{{ scroll_end(cols=4) }}
{% endif %}
@@ -0,0 +1,22 @@
{# plugins/syslog/templates/syslog/_lines_table.html — a source's lines under
the filter bar; the bar's requests swap this alone. Times are the reader's
own zone, with seconds; the header says which. #}
{% from "_macros.html" import empty_state %}
{% if not lines %}
<div class="card">
{%- if filtered %}{{ empty_state("No lines match these filters.", "Show every line", "/plugins/syslog/sources/" ~ source.id) }}
{%- elif source.state != 'accepted' %}{{ empty_state("This sender isn't added, so its lines aren't kept. Add it on the Syslog page.", "Syslog", "/plugins/syslog/") }}
{%- else %}{{ empty_state("No lines in the last " ~ f.range ~ ".") }}{% endif -%}
</div>
{% else %}
<div class="card-flush">
<table class="table syslog-lines" style="font-size:0.82rem;">
<thead>
<tr><th class="nowrap">Received ({{ tz_label() }})</th><th>Severity</th><th>App</th><th>Message</th></tr>
</thead>
<tbody>
{% include "syslog/_lines_page.html" %}
</tbody>
</table>
</div>
{% endif %}
@@ -0,0 +1,3 @@
{# plugins/syslog/templates/syslog/_tone.html — a syslog severity's tone: err
and worse critical, warning a warning, the rest plain. #}
{% macro tone(level) -%}{% if level is not none and level <= 3 %}tone-crit{% elif level == 4 %}tone-warn{% endif %}{%- endmacro %}
+2 -2
View File
@@ -3,7 +3,7 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}Edit {{ label }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Syslog", "/plugins/syslog/"), (label, ""), ("Edit", "")]) }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Syslog", "/plugins/syslog/"), (label, "/plugins/syslog/sources/" ~ source.id), ("Edit", "")]) }}{% endblock %}
{% block content %}
{{ page_header("Edit " ~ label) }}
@@ -33,7 +33,7 @@
</section>
<div class="page-actions">
<button type="submit" class="btn">Save</button>
<a href="/plugins/syslog/" class="btn btn-ghost">Cancel</a>
<a href="/plugins/syslog/sources/{{ source.id }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
+4 -5
View File
@@ -3,8 +3,8 @@
added: counted, never stored, until an admin adds one (#5999). #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, empty_state %}
{% from "syslog/_tone.html" import tone %}
{% block title %}Syslog — Steward{% endblock %}
{% macro tone(level) -%}{% if level is not none and level <= 3 %}tone-crit{% elif level == 4 %}tone-warn{% endif %}{%- endmacro %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{{ page_header("Syslog", (sources|length ~ " source" ~ ("s" if sources|length != 1 else "")) if sources else none) }}
@@ -75,13 +75,13 @@
{{ empty_state("No sources yet. Point a NAS, router or other device at this server's syslog port: it shows above as sending, and Add keeps its lines.") }}
{% else %}
<table class="table" style="width:100%;font-size:0.82rem;">
<thead><tr><th>Source</th><th>Host</th><th>Lines</th><th>Last heard</th><th>Newest line</th>{% if admin %}<th></th>{% endif %}</tr></thead>
<thead><tr><th>Source</th><th>Host</th><th class="num" title="Lines kept in the last hour">Last hour</th><th>Last heard</th><th>Newest line</th>{% if admin %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for s in sources %}
<tr>
<td>{{ s.name }}<div class="field-hint mono">{{ s.address }}</div></td>
<td><a href="/plugins/syslog/sources/{{ s.id }}">{{ s.name }}</a><div class="field-hint mono">{{ s.address }}</div></td>
<td>{% if s.host %}<a href="/hosts/{{ s.host.id }}">{{ s.host.name }}</a>{% else %}—{% endif %}</td>
<td>{{ "{:,}".format(s.lines_total) }}</td>
<td class="num">{{ "{:,}".format(s.rate or 0) }}</td>
<td>{{ s.last_seen_at|ago }}</td>
<td class="mono" style="max-width:28rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ s.last_line }}"><span class="{{ tone(s.level) }}">{{ s.severity or "" }}</span> {{ s.last_line or "—" }}</td>
{% if admin %}<td><a href="/plugins/syslog/sources/{{ s.id }}/edit" class="btn btn-sm btn-ghost">Edit</a></td>{% endif %}
@@ -89,7 +89,6 @@
{% endfor %}
</tbody>
</table>
<p class="field-hint" style="margin:0.6rem 0 0;">Lines counts every line since the device was first heard, added or not.</p>
{% endif %}
</section>
@@ -0,0 +1,30 @@
{# plugins/syslog/templates/syslog/panel.html — the syslog panel on a host's
or an SNMP device's page: the newest lines of the sources that map to it,
each linking to its source's page (#6000). #}
{% from "syslog/_tone.html" import tone %}
<div class="card">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.6rem;gap:1rem;flex-wrap:wrap;">
<h3 class="section-title" style="margin-bottom:0;">Syslog</h3>
<span class="field-hint">
{%- for s in sources %}{% if not loop.first %} · {% endif %}<a href="/plugins/syslog/sources/{{ s.id }}">{{ s.name }}</a>{% endfor -%}
</span>
</div>
{% if not lines %}
<p class="field-hint" style="margin:0;">No lines in the last day.</p>
{% else %}
<table class="table" style="font-size:0.8rem;">
<thead><tr><th class="nowrap">Received ({{ tz_label() }})</th>{% if sources|length > 1 %}<th>Source</th>{% endif %}<th>Severity</th><th>App</th><th>Message</th></tr></thead>
<tbody>
{% for ln in lines %}
<tr>
<td class="nowrap">{{ ln.received_at|when(seconds=true) }}</td>
{% if sources|length > 1 %}<td class="nowrap"><a href="/plugins/syslog/sources/{{ ln.source_id }}">{{ ln.source }}</a></td>{% endif %}
<td class="nowrap"><span class="{{ tone(ln.level) }}">{{ ln.severity }}</span></td>
<td class="nowrap">{{ ln.app or "—" }}</td>
<td class="mono" style="max-width:32rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ ln.message }}">{{ ln.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
@@ -0,0 +1,41 @@
{# plugins/syslog/templates/syslog/source.html — one source's kept lines,
newest first, under a filter bar: severity and worse, app, text, time
(#6000). The table loads further pages as the reader scrolls. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}{{ source.name }} — Syslog — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Syslog", "/plugins/syslog/"), (source.name, "")]) }}{% endblock %}
{% block content %}
{% call page_header(source.name, source.address) %}
{% if session.user_role == 'admin' %}<a href="/plugins/syslog/sources/{{ source.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>{% endif %}
{% endcall %}
<p class="page-verdict">
{%- if source.state != 'accepted' %}<span class="tone-warn">Not added: its lines are counted, not kept.</span>
{%- else %}{{ "{:,}".format(source.rate or 0) }} line{{ "s" if source.rate != 1 }} in the last hour{% endif %}
· last heard {{ source.last_seen_at|ago }}
{%- if source.host %} · on <a href="/hosts/{{ source.host.id }}">{{ source.host.name }}</a>{% endif %}
{%- if source.hostname_seen and source.hostname_seen != source.name %} · calls itself {{ source.hostname_seen }}{% endif %}
</p>
<form class="filter-bar" method="get" action="/plugins/syslog/sources/{{ source.id }}" role="search"
hx-target="#syslog-lines" hx-push-url="true" hx-include="closest form">
<input type="search" name="q" value="{{ f.text }}" class="input" placeholder="Search lines"
aria-label="Search the lines' text" autocomplete="off"
hx-get="/plugins/syslog/sources/{{ source.id }}" hx-trigger="input changed delay:250ms, search">
<select name="severity" aria-label="Severity" hx-get="/plugins/syslog/sources/{{ source.id }}" hx-trigger="change">
<option value="">Any severity</option>
{% for value, label in severities %}<option value="{{ value }}"{% if f.severity is not none and f.severity|string == value %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<select name="app" aria-label="App" hx-get="/plugins/syslog/sources/{{ source.id }}" hx-trigger="change">
<option value="">Any app</option>
{% for a in apps %}<option value="{{ a }}"{% if f.app == a %} selected{% endif %}>{{ a }}</option>{% endfor %}
{% if f.app and f.app not in apps %}<option value="{{ f.app }}" selected>{{ f.app }}</option>{% endif %}
</select>
<select name="range" aria-label="How far back" hx-get="/plugins/syslog/sources/{{ source.id }}" hx-trigger="change">
{% for r in ranges %}<option value="{{ r }}"{% if f.range == r %} selected{% endif %}>Last {{ r }}</option>{% endfor %}
</select>
<noscript><button type="submit" class="btn btn-sm">Filter</button></noscript>
</form>
<div id="syslog-lines">{% include "syslog/_lines_table.html" %}</div>
{% endblock %}
+2 -1
View File
@@ -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", {}))
+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
+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])
+5
View File
@@ -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 %}
+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())
+38
View File
@@ -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) == []
+62 -2
View File
@@ -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 &lt;b&gt; tag" in page
assert 'hx-get="/plugins/syslog/sources/id-SynNAS?severity=3&amp;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