feat(plugins): fold first-party plugins in-tree; bundled + external roots

First-party plugins (host_agent, http, snmp, traefik, unifi, docker) are now
tracked under plugins/ and baked into the image, so they version atomically
with core — ending the cross-repo import drift the roundtable->steward rename
exposed. History for these files is preserved in the archived Roundtable-plugins
repo.

Plugin discovery becomes multi-root: PLUGIN_DIR (single) -> PLUGIN_DIRS
(bundled first, then external) + PLUGIN_INSTALL_DIR. Bundled ships in the image;
third-party plugins still mount at runtime into the external root
(STEWARD_PLUGIN_DIR, default /data/plugins) and downloads/installs land there.
Bundled shadows external on a name collision.

- config.py: load_bootstrap returns plugin_dirs + plugin_install_dir
- app.py: iterate PLUGIN_DIRS at the migration + load sites
- migration_runner.py: discover_all_in() unions every plugin root
- plugin_manager.py: resolve_plugin_path() (pure, first-root-wins); load /
  install / hot-reload span all roots; installs target the external root
- settings/routes.py: _discover_plugins scans all roots, dedup bundled-first
- Dockerfile: COPY plugins/ ; docker-compose: drop host bind, document external
- tests/test_plugin_dirs.py: resolution, multi-root discovery, bootstrap split

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 08:37:24 -04:00
co-authored by Claude Opus 4.8
parent 88ab5b917e
commit a7a281cb11
99 changed files with 7931 additions and 52 deletions
+84
View File
@@ -0,0 +1,84 @@
{# plugins/snmp/templates/snmp/device.html #}
{% extends "base.html" %}
{% block title %}SNMP — {{ device_name }} — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<a href="/plugins/snmp/" style="color:var(--text-muted);font-size:0.85rem;">&#8592; SNMP</a>
<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="btn btn-ghost"
style="font-size:0.78rem;padding:0.2rem 0.6rem;{% if hours == h %}background:var(--accent);color:#fff;border-color:var(--accent);{% endif %}">
{% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %}
</a>
{% endfor %}
</div>
</div>
{% if oid_labels %}
<div class="card">
<canvas id="snmp-chart" style="width:100%;max-height:320px;"></canvas>
</div>
<script>
(function(){
const raw = {{ history_json | safe }};
const labels = {{ oid_labels | tojson }};
const palette = ["#6060c0","#e07040","#40b080","#c040c0","#4080e0","#e0c040","#c08040"];
// Collect all unique timestamps, sorted
const tsSet = new Set();
for (const pts of Object.values(raw)) {
for (const [ts] of pts) tsSet.add(ts);
}
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,
});
});
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: { color: "#aaa", boxWidth: 12 } },
tooltip: { backgroundColor: "#1e1e2e", titleColor: "#ccc", bodyColor: "#aaa" },
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 12 }, grid: { color: "#2a2a3a" } },
y: { ticks: { color: "#888" }, grid: { color: "#2a2a3a" } },
},
},
});
})();
</script>
{% else %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;">
No OIDs configured for this device.
</div>
{% endif %}
{% endblock %}