Files
FabledSteward/plugins/snmp/templates/snmp/device.html
T
bvandeusenandClaude Opus 5.5 9ca2e38fa1
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
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>
2026-10-08 10:54:11 -04:00

84 lines
4.4 KiB
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, page_header, status_badge %}
{% block title %}SNMP — {{ device.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("SNMP", "/plugins/snmp/"), (device.name, "")]) }}{% endblock %}
{% block content %}
{% 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 %}
<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 () {
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 });
}
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 } } }
}
}
});
});
})();
</script>
{% endblock %}