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:
@@ -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/{}")}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()])
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user