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