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>
84 lines
4.4 KiB
HTML
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 %}
|