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 -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 %}