feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped

The operator, reviewing SNMP on #5214: "this system looks incomplete like
there's something missing from it". The one device shown was plugin.yaml's
example (core-switch 192.168.1.1, community public), polled every minute;
devices could only be defined by editing YAML in the image; a failed poll was
logged at DEBUG and stored nowhere, so the page said "waiting for the next
poll" forever; v3 ran as v2c; counters showed as lifetime totals.

- Devices are rows (snmp_devices, migration snmp_001): address, port,
  v1/v2c community or a v3 user with auth/priv protocols and keys (secrets
  encrypted), presets plus custom readings, an optional host binding, and
  the newest poll's outcome. The YAML device list and its example are gone.
- The SNMP page adds, edits and deletes them (admin, audited, with
  get_audit_entities so the audit log names and links a device). Readings
  come from presets (system uptime, interface traffic by number, UPS-MIB,
  Linux net-snmp) and custom "OID name [unit]" lines.
- One GET per device with every OID, devices polled at once. The outcome is
  written to the row in words ("No reply from … within 5 s. Check the address
  and port, that SNMP is on, and the community"), with readings it lacks.
  v1's whole-request error drops that OID and asks again. SNMPv3 signs in
  with USM (MD5/SHA-1/SHA-2, DES/3DES/AES).
- Test asks the unsaved form's device for its name, description, uptime and
  interfaces by number. It is an admin's explicit action; every page reads
  only stored rows (System 178's ruling).
- Each reading has a unit; a counter is stored as a per-second rate (bytes as
  bit/s), a reset or wrap skipped. The page, widget and host panel say values
  in units and why a device is silent; the device page draws a chart per
  reading on a dated axis.

Tests: readings and presets, the form and its secrets, v3 checks, rates,
the poller against a stand-in pysnmp (one GET, v1 retry, timeout wording,
USM), the pages, and an integration test of the poll writing outcomes and
rates on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:54:11 -04:00
co-authored by Claude Opus 5.5
parent 14474f9be4
commit 9ca2e38fa1
27 changed files with 1873 additions and 703 deletions
+22
View File
@@ -0,0 +1,22 @@
{# What Test found (milestone 491): who the device says it is and its
interfaces with their numbers, or why it didn't answer. #}
{% if found.error %}
<p class="tone-crit" role="alert" style="margin:0.75rem 0 0;">{{ found.error }}</p>
{% else %}
<div style="margin-top:0.75rem;">
<p class="tone-ok" style="margin:0 0 0.4rem;">{{ address }} answered.</p>
<dl class="test-facts" style="display:grid;grid-template-columns:max-content 1fr;gap:0.2rem 0.9rem;margin:0;font-size:0.85rem;">
<dt class="tone-dim">Name</dt><dd style="margin:0;">{{ found.name or "—" }}</dd>
<dt class="tone-dim">Description</dt><dd style="margin:0;overflow-wrap:anywhere;">{{ found.description or "—" }}</dd>
<dt class="tone-dim">Up for</dt><dd style="margin:0;">{{ (found.uptime_cs / 100)|duration if found.uptime_cs is not none else "—" }}</dd>
</dl>
{% if found.interfaces %}
<p class="field-hint" style="margin:0.6rem 0 0.3rem;">Interfaces, by the number to put in Interface traffic:</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:0.15rem 0.9rem;font-size:0.82rem;">
{% for number, name in found.interfaces %}<span><span class="mono tone-dim">{{ number }}</span> {{ name }}</span>{% endfor %}
</div>
{% else %}
<p class="field-hint" style="margin:0.6rem 0 0;">It lists no interfaces.</p>
{% endif %}
</div>
{% endif %}
+71 -70
View File
@@ -1,82 +1,83 @@
{# plugins/snmp/templates/snmp/device.html #}
{# plugins/snmp/templates/snmp/device.html — one SNMP device (milestone 491):
what its newest poll found, then a small chart per reading in that
reading's own unit, on a dated axis. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}SNMP — {{ device_name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("SNMP", "/plugins/snmp/"), (device_name, "")]) }}{% endblock %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% block title %}SNMP — {{ device.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("SNMP", "/plugins/snmp/"), (device.name, "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin:0;">{{ device_name }}</h1>
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
{% for h in [1, 6, 24, 168] %}
<a href="?hours={{ h }}" class="range-btn{% if hours == h %} active{% endif %}"{% if hours == h %} aria-current="true"{% endif %}>
{% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %}
</a>
{% endfor %}
</div>
</div>
{% call page_header(device.name, device.address ~ ((":" ~ device.port) if device.port != 161 else "") ~ " · SNMP v" ~ device.version) %}
{% for h in ranges %}
<a href="?hours={{ h }}" class="range-btn{% if hours == h %} active{% endif %}"{% if hours == h %} aria-current="true"{% endif %}>{% if h < 168 %}{{ h }}h{% else %}7d{% endif %}</a>
{% endfor %}
{% if session.user_role == 'admin' %}<a href="/plugins/snmp/devices/{{ device.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>{% endif %}
{% endcall %}
{% if oid_labels %}
<div class="card">
<canvas id="snmp-chart" style="width:100%;max-height:320px;"></canvas>
<p class="page-verdict">
{% if device.reach == 'up' %}{{ status_badge('up', 'Answering') }} polled {{ device.polled_at|ago }}
{% elif device.reach == 'down' %}{{ status_badge('down', 'Not answering') }} <span class="tone-crit">{{ device.says }}</span>
{% if device.ok_at %}Last answered {{ device.ok_at|ago }}.{% else %}It has never answered.{% endif %}
{% elif device.reach == 'stale' %}{{ status_badge('stale', 'Not polled') }} {{ device.says }}
{% else %}{{ status_badge('pending', 'Not polled yet') }} The first poll runs within a minute of saving.{% endif %}
</p>
{% if device.missing %}<p class="field-hint">Not answered by this device: {{ device.missing|join(", ") }}.</p>{% endif %}
{% if charts %}
<div class="node-grid">
{% for c in charts %}
<section class="card" aria-labelledby="snmp-chart-{{ loop.index }}" style="margin:0;">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:0.75rem;">
<h2 id="snmp-chart-{{ loop.index }}" class="section-title" style="margin:0;">{{ c.name }}</h2>
<span class="stat-tile-value" style="font-size:1rem;">{{ c.now }}</span>
</div>
<div style="position:relative;height:140px;margin-top:0.5rem;">
<canvas id="snmp-c-{{ loop.index0 }}" role="img" aria-label="{{ c.name }} over the last {{ hours }} hours"></canvas>
</div>
</section>
{% endfor %}
</div>
{% else %}
<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 %}
<script>
(function(){
const raw = {{ history_json | safe }};
const labels = {{ oid_labels | tojson }};
// Series colours from the tokens, in an order that keeps neighbours apart.
const palette = ["--blue", "--yellow", "--green", "--red", "--gold", "--orange", "--text-muted"].map(stewardColor);
// Collect all unique timestamps, sorted
const tsSet = new Set();
for (const pts of Object.values(raw)) {
for (const [ts] of pts) tsSet.add(ts);
(function () {
var charts = {{ charts_json | safe }};
var hours = {{ hours }};
var line = stewardColor("--blue");
function stamp(iso) {
var d = new Date(iso);
return hours > 24
? d.toLocaleDateString([], { weekday: "short", day: "numeric" }) + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false })
: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
const allTs = Array.from(tsSet).sort();
if (allTs.length < 2) {
document.getElementById("snmp-chart").parentElement.innerHTML =
'<p style="color:var(--text-muted);padding:1rem;">Not enough data for a chart yet.</p>';
return;
}
const datasets = [];
labels.forEach((label, i) => {
const pts = raw[label] || [];
const map = Object.fromEntries(pts.map(([ts, v]) => [ts, v]));
datasets.push({
label,
data: allTs.map(ts => map[ts] ?? null),
borderColor: palette[i % palette.length],
backgroundColor: "transparent",
borderWidth: 1.5,
pointRadius: 0,
tension: 0.2,
spanGaps: true,
charts.forEach(function (c, i) {
var el = document.getElementById("snmp-c-" + i);
if (!el) return;
if (c.points.length < 2) {
el.parentElement.innerHTML = '<p class="field-hint" style="margin:0;">Not enough readings yet for a chart.</p>';
return;
}
new Chart(el.getContext("2d"), {
type: "line",
data: { labels: c.points.map(function (p) { return p[0]; }),
datasets: [{ data: c.points.map(function (p) { return p[1]; }), borderColor: line,
backgroundColor: "transparent", borderWidth: 1.5, pointRadius: 0, tension: 0.2 }] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
interaction: { mode: "index", intersect: false },
plugins: { legend: { display: false },
tooltip: { callbacks: { title: function (items) {
return items.length ? new Date(c.points[items[0].dataIndex][0]).toLocaleString([], { hour12: false }) : ""; } } } },
scales: {
x: { ticks: { maxTicksLimit: 6, maxRotation: 0, font: { size: 10 },
callback: function (v) { return stamp(c.points[v][0]); } }, grid: { display: false } },
y: { ticks: { font: { size: 10 }, maxTicksLimit: 4 },
title: { display: !!c.axis, text: c.axis, font: { size: 9 } } }
}
}
});
});
const ctx = document.getElementById("snmp-chart").getContext("2d");
new Chart(ctx, {
type: "line",
data: { labels: allTs.map(ts => ts.substring(11, 16)), datasets },
options: {
responsive: true,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { position: "top", labels: { boxWidth: 12 } },
},
scales: {
x: { ticks: { maxTicksLimit: 12 } },
},
},
});
})();
</script>
{% else %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;">
No OIDs configured for this device.
</div>
{% endif %}
{% endblock %}
+166
View File
@@ -0,0 +1,166 @@
{# plugins/snmp/templates/snmp/form.html — add or edit an SNMP device
(milestone 491): where it is, how to sign in, what to read, and Test to
ask the device before saving. Secrets are never sent back to the browser;
a blank secret field keeps the stored one. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}{{ ("Edit " ~ device.name) if device else "Add SNMP device" }} — Steward{% endblock %}
{% block breadcrumb %}
{%- if device -%}
{{ crumbs([("SNMP", "/plugins/snmp/"), (device.name, "/plugins/snmp/devices/" ~ device.id), ("Edit", "")]) }}
{%- else -%}
{{ crumbs([("SNMP", "/plugins/snmp/"), ("Add device", "")]) }}
{%- endif -%}
{% endblock %}
{% block content %}
{{ page_header(("Edit " ~ device.name) if device else "Add SNMP device") }}
<div class="form-page">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<form method="post" id="snmp-form" action="{{ '/plugins/snmp/devices/' ~ device.id ~ '/edit' if device else '/plugins/snmp/devices/new' }}">{{ csrf_field() }}
{% if device %}<input type="hidden" name="device_id" value="{{ device.id }}">{% endif %}
<section class="card form-section" aria-labelledby="snmp-where">
<h2 id="snmp-where" class="section-title">Device</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="snmp-name">Name</label>
<input class="input" id="snmp-name" name="name" required maxlength="100" value="{{ v.name or '' }}" placeholder="core-switch" autocomplete="off">
</div>
<div class="field">
<label class="field-label" for="snmp-address">Address</label>
<input class="input mono" id="snmp-address" name="address" required value="{{ v.address or '' }}" placeholder="192.168.1.2 or switch.lan" autocomplete="off">
</div>
<div class="field">
<label class="field-label" for="snmp-port">Port</label>
<input class="input" id="snmp-port" name="port" type="number" min="1" max="65535" value="{{ v.port or 161 }}">
</div>
<div class="field">
<label class="field-label" for="snmp-host">Show on host</label>
<select class="input" id="snmp-host" name="host_id">
<option value="">The host with this address, if any</option>
{% for h in hosts %}<option value="{{ h.id }}"{% if v.host_id == h.id %} selected{% endif %}>{{ h.name }}</option>{% endfor %}
</select>
<span class="field-hint">Which host's page shows this device's readings.</span>
</div>
</div>
</section>
<section class="card form-section" aria-labelledby="snmp-auth">
<h2 id="snmp-auth" class="section-title">Sign-in</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="snmp-version">SNMP version</label>
<select class="input" id="snmp-version" name="version">
{% for ver, words in [("2c", "v2c: a community string (most devices)"), ("3", "v3: a user with authentication and encryption"), ("1", "v1: a community string (old devices)")] %}
<option value="{{ ver }}"{% if v.version == ver %} selected{% endif %}>{{ words }}</option>
{% endfor %}
</select>
</div>
<div class="field" data-snmp="community">
<label class="field-label" for="snmp-community">Community</label>
<input class="input" id="snmp-community" name="community" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_community else 'public' }}">
<span class="field-hint">The device's read community. Blank {{ "keeps the saved one" if v.has_community else "means public" }}.</span>
</div>
</div>
<div class="form-pair" data-snmp="v3">
<div class="field">
<label class="field-label" for="snmp-user">User</label>
<input class="input" id="snmp-user" name="v3_user" value="{{ v.v3_user or '' }}" autocomplete="off">
</div>
<div class="field"></div>
<div class="field">
<label class="field-label" for="snmp-auth-proto">Authentication</label>
<select class="input" id="snmp-auth-proto" name="v3_auth">
{% for val, words in [("sha", "SHA-1"), ("sha256", "SHA-256"), ("sha512", "SHA-512"), ("sha224", "SHA-224"), ("sha384", "SHA-384"), ("md5", "MD5"), ("none", "None")] %}
<option value="{{ val }}"{% if v.v3_auth == val %} selected{% endif %}>{{ words }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field-label" for="snmp-auth-key">Authentication key</label>
<input class="input" id="snmp-auth-key" name="v3_auth_key" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_auth_key else 'at least 8 characters' }}">
</div>
<div class="field">
<label class="field-label" for="snmp-priv-proto">Encryption</label>
<select class="input" id="snmp-priv-proto" name="v3_priv">
{% for val, words in [("aes128", "AES-128"), ("aes192", "AES-192"), ("aes256", "AES-256"), ("des", "DES"), ("3des", "3DES"), ("none", "None")] %}
<option value="{{ val }}"{% if v.v3_priv == val %} selected{% endif %}>{{ words }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field-label" for="snmp-priv-key">Encryption key</label>
<input class="input" id="snmp-priv-key" name="v3_priv_key" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_priv_key else 'at least 8 characters' }}">
</div>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-ghost"
hx-post="/plugins/snmp/devices/test" hx-include="#snmp-form" hx-target="#snmp-test"
hx-indicator="#snmp-testing">Test</button>
<span id="snmp-testing" class="field-hint htmx-indicator">Asking the device…</span>
<span class="field-hint">Asks the device for its name, uptime and interfaces, with what's typed here. Nothing is saved.</span>
</div>
<div id="snmp-test" aria-live="polite"></div>
</section>
<section class="card form-section" aria-labelledby="snmp-what">
<h2 id="snmp-what" class="section-title">Readings</h2>
<div class="check-list">
{% for pid, label, what in presets %}
<label style="display:flex;gap:0.5rem;align-items:flex-start;cursor:pointer;">
<input type="checkbox" name="presets" value="{{ pid }}"{% if pid in (v.presets or []) %} checked{% endif %} style="margin-top:0.2rem;">
<span><strong>{{ label }}</strong> <span class="field-hint">{{ what }}</span></span>
</label>
{% if pid == "interfaces" %}
<div class="field" style="margin:0 0 0.4rem 1.6rem;">
<label class="field-label" for="snmp-interfaces">Interface numbers</label>
<input class="input" id="snmp-interfaces" name="interfaces" value="{{ v.interfaces or '' }}" placeholder="1, 2, 5-8" autocomplete="off" style="max-width:20rem;">
</div>
{% endif %}
{% endfor %}
</div>
<div class="field" style="margin-top:0.9rem;">
<label class="field-label" for="snmp-custom">Custom readings</label>
<textarea class="input mono" id="snmp-custom" name="custom" rows="4" placeholder="1.3.6.1.4.1.318.1.1.1.2.2.2.0 battery_temperature celsius">{{ v.custom or '' }}</textarea>
<span class="field-hint">One a line: the OID, a name (letters, digits, underscores), and optionally a unit:
{% for u, words in units.items() %}<span class="mono">{{ u }}</span> ({{ words }}){{ ", " if not loop.last else "." }}{% endfor %}</span>
</div>
</section>
<div class="form-actions">
<button type="submit" class="btn">{{ "Save" if device else "Add device" }}</button>
<a href="{{ '/plugins/snmp/devices/' ~ device.id if device else '/plugins/snmp/' }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% if device %}
<section class="card danger-zone" aria-labelledby="snmp-delete">
<div>
<h2 id="snmp-delete" class="section-title">Delete this device</h2>
<p class="field-hint">Steward stops polling it. Its past readings age out with the rest of the history.</p>
</div>
<form method="post" action="/plugins/snmp/devices/{{ device.id }}/delete"
data-confirm="Delete SNMP device {{ device.name }}? Steward stops polling it.">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Delete device</button>
</form>
</section>
{% endif %}
</div>
<script>
(function () {
var version = document.getElementById("snmp-version");
function show() {
var v3 = version.value === "3";
document.querySelectorAll('[data-snmp="v3"]').forEach(function (el) { el.hidden = !v3; });
document.querySelectorAll('[data-snmp="community"]').forEach(function (el) { el.hidden = v3; });
}
version.addEventListener("change", show);
show();
})();
</script>
{% endblock %}
+17 -42
View File
@@ -1,52 +1,27 @@
{# Per-host SNMP fragment — embedded on the Hosts hub via HTMX. Self-contained.
Shows the SNMP device(s) whose address maps to this host + latest readings. #}
{# plugins/snmp/templates/snmp/host_panel.html — the SNMP panel on a host's
page: the devices bound to this host, or at its address, with their
readings in their units, or why they aren't answering. #}
{% from "_macros.html" import status_badge, stat_tile %}
<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;">SNMP</h3>
<a href="/plugins/snmp/" style="font-size:0.8rem;color:var(--text-muted);">All devices →</a>
<a href="/plugins/snmp/" class="field-hint">All devices</a>
</div>
{% for device in devices %}
<div style="{% if not loop.last %}margin-bottom:1rem;{% endif %}">
{% for d in devices %}
<div{% if not loop.last %} style="margin-bottom:1rem;"{% endif %}>
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem;flex-wrap:wrap;">
<span style="font-weight:500;font-size:0.92rem;">{{ device.name }}</span>
<span style="font-size:0.78rem;color:var(--text-dim);font-family:ui-monospace,monospace;">{{ device.host }}</span>
{% if device.bound %}
<span title="Explicitly bound to this host (host_id / steward_host)"
style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:3px;padding:0.05rem 0.4rem;">bound</span>
{% endif %}
{% if device.reach == 'up' %}
<span style="font-size:0.74rem;" class="tone-ok">&#9679; reachable · read {{ device.at|ago }}</span>
{% elif device.reach == 'stale' %}
<span style="font-size:0.74rem;" class="tone-warn" title="The values below are from then">&#9679; no answer since {{ device.at|ago }}</span>
{% else %}
<span style="font-size:0.74rem;color:var(--text-dim);">&#9675; no data yet</span>
{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-ghost btn-sm"
style="margin-left:auto;font-size:0.76rem;padding:0.15rem 0.55rem;">History</a>
<a href="/plugins/snmp/devices/{{ d.id }}" style="font-weight:500;">{{ d.name }}</a>
<span class="field-hint mono">{{ d.address }}</span>
{% if d.reach == 'up' %}{{ status_badge('up', 'Answering') }}
{% elif d.reach == 'down' %}{{ status_badge('down', 'Not answering', title=d.says) }}
{% elif d.reach == 'stale' %}{{ status_badge('stale', 'Not polled', title=d.says) }}
{% else %}{{ status_badge('pending', 'Not polled yet') }}{% endif %}
</div>
{% if device.readings %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.5rem;">
{% for oid_cfg in device.oids %}
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
{% set val = device.readings.get(label) %}
<div style="background:var(--bg-elevated);border-radius:5px;padding:0.5rem 0.7rem;border:1px solid var(--border);">
<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</div>
{% if val is not none %}
<div style="font-size:1rem;font-weight:500;font-variant-numeric:tabular-nums;">
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.72rem;color:var(--text-muted);">M</span>
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.72rem;color:var(--text-muted);">K</span>
{% else %}{{ "%.4g"|format(val) }}{% endif %}
</div>
{% else %}
<div style="font-size:0.85rem;color:var(--text-dim);">—</div>
{% endif %}
</div>
{% endfor %}
{% if d.reach == 'down' %}<p class="field-hint" style="margin:0 0 0.5rem;">{{ d.says }}</p>{% endif %}
{% if d.reach != 'pending' and d.readings %}
<div class="stat-strip" style="margin-bottom:0;">
{% for r in d.readings %}{{ stat_tile(r.name, r.text) }}{% endfor %}
</div>
{% else %}
<p style="font-size:0.8rem;color:var(--text-dim);margin:0;">No readings yet — waiting for the next poll.</p>
{% endif %}
</div>
{% endfor %}
+39 -29
View File
@@ -1,45 +1,55 @@
{# plugins/snmp/templates/snmp/index.html #}
{# plugins/snmp/templates/snmp/index.html — every SNMP device, those not
answering first, each saying what its newest poll found: its readings in
their units, or why it didn't answer (milestone 491). #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
{% block title %}SNMP — Steward{% endblock %}
{% block content %}
{{ page_header("SNMP devices", devices|length ~ " device" ~ ("s" if devices|length != 1 else "") ~ " configured") }}
{% set admin = session.user_role == 'admin' %}
{% call page_header("SNMP devices", (devices|length ~ " device" ~ ("s" if devices|length != 1 else "")) if devices else none) %}
{% if admin %}<a class="btn btn-sm" href="/plugins/snmp/devices/new">Add device</a>{% endif %}
{% endcall %}
{% if not devices %}
<div class="card">{{ empty_state("No SNMP devices configured. Add them to the snmp plugin's plugin.yaml.") }}</div>
{% else %}
<div class="card">{{ empty_state("No SNMP devices yet. Add a switch, a UPS, a printer or a server that answers SNMP, and Steward polls it every minute.",
"Add device" if admin else none, "/plugins/snmp/devices/new") }}</div>
{% endif %}
{% for device in devices %}
<section class="card" aria-labelledby="snmp-{{ loop.index }}">
{% for d in devices %}
<section class="card" aria-labelledby="snmp-{{ d.id }}">
<div class="host-health-head">
{#- reach: answered within the last few polls (up), only long ago (stale),
or never (pending); routes._device_state. #}
{% if device.reach == 'up' %}{{ status_badge('up', 'Reachable') }}
{% elif device.reach == 'stale' %}{{ status_badge('stale', 'No answer', title="The last reading is from " ~ device.at.strftime('%Y-%m-%d %H:%M UTC') ~ "; the values below are from then") }}
{% else %}{{ status_badge('pending', 'No data yet') }}{% endif %}
<h2 id="snmp-{{ loop.index }}" class="section-title" style="margin:0;">{{ device.name }}</h2>
<span class="field-hint mono">{{ device.host }}</span>
{% if device.at %}<span class="field-hint">read {{ device.at|ago }}</span>{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-sm btn-ghost" style="margin-left:auto;">History</a>
{% if d.reach == 'up' %}{{ status_badge('up', 'Answering') }}
{% elif d.reach == 'down' %}{{ status_badge('down', 'Not answering') }}
{% elif d.reach == 'stale' %}{{ status_badge('stale', 'Not polled') }}
{% else %}{{ status_badge('pending', 'Not polled yet') }}{% endif %}
<h2 id="snmp-{{ d.id }}" class="section-title" style="margin:0;"><a href="/plugins/snmp/devices/{{ d.id }}">{{ d.name }}</a></h2>
<span class="field-hint mono">{{ d.address }}{% if d.port != 161 %}:{{ d.port }}{% endif %}</span>
<span class="field-hint">SNMP v{{ d.version }}</span>
<span class="page-actions" style="margin-left:auto;">
<a href="/plugins/snmp/devices/{{ d.id }}" class="btn btn-sm btn-ghost">History</a>
{% if admin %}<a href="/plugins/snmp/devices/{{ d.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>{% endif %}
</span>
</div>
{% if device.readings %}
{% if d.reach == 'down' %}
<p class="tone-crit" style="margin:0 0 0.4rem;">{{ d.says }}</p>
<p class="field-hint" style="margin:0 0 0.6rem;">
{%- if d.ok_at %}Last answered {{ d.ok_at|ago }}; the readings below are from then.{% else %}It has never answered.{% endif %}
Tried {{ d.polled_at|ago }}.</p>
{% elif d.reach == 'stale' %}
<p class="field-hint" style="margin:0 0 0.6rem;">{{ d.says }} Last polled {{ d.polled_at|ago }}.</p>
{% elif d.reach == 'pending' %}
<p class="field-hint" style="margin:0 0 0.6rem;">Not polled yet: the first poll runs within a minute of saving.</p>
{% endif %}
{% if d.reach != 'pending' and d.readings %}
<div class="stat-strip" style="margin-bottom:0;">
{% for oid_cfg in device.oids %}
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
{% set val = device.readings.get(label) %}
{{ stat_tile(label,
"—" if val is none else
("%.2fM"|format(val / 1000000)) if val >= 1000000 else
("%.1fK"|format(val / 1000)) if val >= 1000 else
"%.4g"|format(val)) }}
{% endfor %}
{% for r in d.readings %}{{ stat_tile(r.name, r.text, hint="from the second poll" if r.counter and r.text == "—" else none) }}{% endfor %}
</div>
{% else %}
<p class="field-hint">No readings yet; waiting for the next poll.</p>
{% endif %}
{% if d.missing %}
<p class="field-hint" style="margin:0.6rem 0 0;">Not answered by this device: {{ d.missing|join(", ") }}. It may not support them; Edit to remove them.</p>
{% endif %}
</section>
{% endfor %}
{% endif %}
{% endblock %}
+17 -16
View File
@@ -1,15 +1,15 @@
{# plugins/snmp/templates/snmp/widget.html — the dashboard SNMP widget (#5255):
a verdict, devices that stopped answering first with since when, then the
rest, each with its status in words and its key readings, named and with
units. The device name links to its page. #}
{# plugins/snmp/templates/snmp/widget.html — the dashboard SNMP widget (#5255,
milestone 491): a verdict, devices not answering first with why, then the
rest, each with two readings in their units. The name links to its page. #}
{% from "_macros.html" import status_dot %}
{% if not total %}
<div class="w-verdict" style="color:var(--text-muted);">No SNMP devices configured.</div>
<div class="w-verdict" style="color:var(--text-muted);">No SNMP devices yet.{% if session.user_role == 'admin' %} <a href="/plugins/snmp/devices/new">Add one</a>{% endif %}</div>
{% else %}
{% set stale = devices | selectattr("reach", "equalto", "stale") | list %}
<div class="w-verdict {{ 'tone-crit' if counts.stale else ('tone-warn' if counts.pending else 'tone-ok') }}">
{%- if counts.stale == 1 and stale %}{{ stale[0].name }}: no reply since {{ stale[0].at|ago }}.
{%- elif counts.stale %}{{ counts.stale }} of {{ total }} devices not answering.
{% set down = devices | selectattr("reach", "equalto", "down") | list %}
<div class="w-verdict {{ 'tone-crit' if counts.down else ('tone-warn' if counts.stale or counts.pending else 'tone-ok') }}">
{%- if counts.down == 1 and down %}{{ down[0].name }} isn't answering.
{%- elif counts.down %}{{ counts.down }} of {{ total }} devices not answering.
{%- elif counts.stale %}{{ counts.stale }} not polled recently.
{%- elif counts.pending %}{{ counts.pending }} not polled yet, {{ counts.up }} answering.
{%- else %}All {{ total }} device{{ 's' if total != 1 }} answering.{% endif -%}
</div>
@@ -20,15 +20,16 @@
{% for d in devices %}
<tr>
<td class="w-name" title="{{ d.name }}">
{{- status_dot('down' if d.reach == 'stale' else ('pending' if d.reach == 'pending' else 'up')) -}}
{% if session.user_id %}<a href="/plugins/snmp/device/{{ d.name }}">{{ d.name }}</a>{% else %}{{ d.name }}{% endif %}
{%- if d.reach == 'stale' %}<span class="w-sub tone-crit">no reply since {{ d.at|ago }}; readings are from then</span>
{%- elif d.reach == 'pending' %}<span class="w-sub">no data yet</span>{% endif %}
{{- status_dot({'down': 'down', 'stale': 'stale', 'pending': 'pending'}.get(d.reach, 'up')) -}}
{% if session.user_id %}<a href="/plugins/snmp/devices/{{ d.id }}">{{ d.name }}</a>{% else %}{{ d.name }}{% endif %}
{%- if d.reach == 'down' %}<span class="w-sub tone-crit" title="{{ d.says }}">{% if d.ok_at %}no reply since {{ d.ok_at|ago }}{% else %}never answered{% endif %}</span>
{%- elif d.reach == 'stale' %}<span class="w-sub">not polled since {{ d.polled_at|ago }}</span>
{%- elif d.reach == 'pending' %}<span class="w-sub">not polled yet</span>{% endif %}
</td>
<td title="{% for n, v in d.key %}{{ n }} {{ v }}{% if not loop.last %} · {% endif %}{% endfor %}">
{%- for n, v in d.key %}<span style="color:var(--text-muted);">{{ n }}</span> {{ v }}{% if not loop.last %} · {% endif %}{% else %}—{% endfor -%}
<td title="{% for r in d.key %}{{ r.name }} {{ r.text }}{% if not loop.last %} · {% endif %}{% endfor %}">
{%- for r in d.key %}<span style="color:var(--text-muted);">{{ r.name }}</span> {{ r.text }}{% if not loop.last %} · {% endif %}{% else %}—{% endfor -%}
</td>
<td class="w-wide-only">{% if d.at %}{{ d.at|ago }}{% else %}—{% endif %}</td>
<td class="w-wide-only">{% if d.polled_at %}{{ d.polled_at|ago }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>