Hot-reload sshHosts/knownDevices from hosts.yaml, no restart needed
CI / web (push) Successful in 16s
CI / api (push) Successful in 22s

Checks the file's mtime on each poll cycle (already running every
30s) rather than adding a separate file-watcher or admin UI. Proxmox
hosts already needed no config (auto-discovered every poll); this
covers the two lists that did. A parse failure logs and keeps the
previous config running instead of crashing the poller.

Closes #14.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:36:37 -06:00
parent 27c83d52b2
commit 1e1fb98afb
2 changed files with 65 additions and 11 deletions
+47 -11
View File
@@ -1,8 +1,9 @@
import "dotenv/config";
import { statSync } from "node:fs";
import Fastify from "fastify";
import fastifyCookie from "@fastify/cookie";
import fastifySession from "@fastify/session";
import { loadConfig } from "./config/index.js";
import { loadConfig, type AppConfig } from "./config/index.js";
import { openDb, upsertSnapshots, pruneOldSnapshots } from "./db/index.js";
import { seedAdminUser } from "./auth/local.js";
import { ProxmoxCollector } from "./collectors/proxmox.js";
@@ -17,15 +18,8 @@ import { initOidc } from "./auth/oidc.js";
const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml";
async function main() {
const cfg = loadConfig(HOSTS_CONFIG_PATH);
const db = openDb(cfg.dbPath);
if (cfg.adminPassword) {
seedAdminUser(db, cfg.adminUsername, cfg.adminPassword);
}
const collectors: Collector[] = [
function buildCollectors(cfg: AppConfig): Collector[] {
return [
new ProxmoxCollector({
host: cfg.proxmox.host,
node: cfg.proxmox.node,
@@ -34,8 +28,50 @@ async function main() {
}),
...cfg.sshHosts.map((h) => new SshHostCollector(h)),
];
}
async function main() {
const cfg = loadConfig(HOSTS_CONFIG_PATH);
const db = openDb(cfg.dbPath);
if (cfg.adminPassword) {
seedAdminUser(db, cfg.adminUsername, cfg.adminPassword);
}
// Proxmox-monitored hosts (the node + every LXC) auto-discover on every poll
// already — no config needed there. sshHosts and knownDevices, though, are
// declared in hosts.yaml and used to need a container restart to pick up
// changes. Re-checking the file's mtime on each poll cycle (already running
// every 30s) avoids that without adding a separate file-watcher/admin UI.
let knownDevices = cfg.knownDevices;
let collectors = buildCollectors(cfg);
let hostsConfigMtimeMs = statSync(HOSTS_CONFIG_PATH).mtimeMs;
function reloadHostsConfigIfChanged() {
let mtimeMs: number;
try {
mtimeMs = statSync(HOSTS_CONFIG_PATH).mtimeMs;
} catch {
return;
}
if (mtimeMs === hostsConfigMtimeMs) return;
hostsConfigMtimeMs = mtimeMs;
try {
const reloaded = loadConfig(HOSTS_CONFIG_PATH);
collectors = buildCollectors(reloaded);
knownDevices = reloaded.knownDevices;
app.log.info(
{ sshHosts: reloaded.sshHosts.length, knownDevices: reloaded.knownDevices.size },
"config/hosts.yaml changed, reloaded"
);
} catch (err) {
app.log.error({ err }, "failed to reload config/hosts.yaml, keeping previous config");
}
}
async function pollOnce() {
reloadHostsConfigIfChanged();
for (const collector of collectors) {
try {
const snapshots = await collector.collect();
@@ -47,7 +83,7 @@ async function main() {
pruneOldSnapshots(db, cfg.snapshotRetentionHours);
try {
await refreshDevices(db, cfg.discoveryFilePath, cfg.knownDevices);
await refreshDevices(db, cfg.discoveryFilePath, knownDevices);
} catch (err) {
app.log.error({ err }, "device discovery refresh failed");
}
+18
View File
@@ -0,0 +1,18 @@
# Editing monitored hosts without a redeploy
`config/hosts.yaml`'s `sshHosts` and `knownDevices` lists are re-read on the next poll
cycle (checks the file's mtime, reloads if changed) — no container restart needed.
- **Proxmox-monitored hosts** (the node + every LXC) need no config at all — they're
auto-discovered from the Proxmox API on every poll already.
- **SSH-monitored hosts** (`sshHosts`): edit `config/hosts.yaml` on the deploy host
directly (`/opt/homelab-monitor/config/hosts.yaml` on CT122), save, wait up to one
poll interval (`POLL_INTERVAL_SECONDS`, default 30s). Adding a *new* SSH host still
needs a one-time manual step first — installing the forced-command key on the target
(see `docs/ssh-collector-key-setup.md`) — that part can't be automated away, so a web
form wouldn't make this fully self-service regardless of hot-reload.
- **`knownDevices`** (device discovery labeling): same file, same reload path, no
manual step needed since it's just a label list.
If the file fails to parse (bad YAML), the error is logged and the **previous**
in-memory config keeps running rather than crashing the poller.