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>
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">● 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">● no answer since {{ device.at|ago }}</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.74rem;color:var(--text-dim);">○ 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user