Compare commits

...

6 Commits

Author SHA1 Message Date
jared a34898b8e8 Fix ping-only hosts polled twice per cycle with inconsistent parameters
Lint / Python (flake8) (push) Successful in 57s
Lint / JS (eslint) (push) Successful in 28s
Security / Python Security (bandit) (push) Successful in 1m14s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 7s
Test / Python Tests (pytest) (push) Failing after 13m52s
_collect_snapshot called pulse.ping(count=1) independently from
_process_ping_hosts which called pulse.ping(count=3). This doubled
network load and could show a host as 'up' in the dashboard while
simultaneously firing an 'unreachable' alert, or vice versa.

Now ping_states is computed once in run() using the alert-quality
parameters (count=3) and shared by both snapshot and alert processing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:13:43 -04:00
jared 31747c4bd3 Replace deprecated datetime.utcnow() with datetime.now(timezone.utc)
Lint / Python (flake8) (push) Successful in 1m9s
Lint / JS (eslint) (push) Successful in 11s
Security / Python Security (bandit) (push) Successful in 44s
Test / Python Tests (pytest) (push) Successful in 58s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
datetime.utcnow() is deprecated in Python 3.12 and removed in 3.13.
Replace all four call sites with timezone-aware equivalents so the
codebase is ready for Python 3.12+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:34:41 -04:00
jared faa0707f79 Add ESLint config enforcing no-undef and eqeqeq
Lint / Python (flake8) (push) Successful in 53s
Lint / JS (eslint) (push) Successful in 12s
Security / Python Security (bandit) (push) Successful in 1m44s
Test / Python Tests (pytest) (push) Successful in 59s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
Without a config file, ESLint was running with no-undef disabled, meaning
undefined variable references in static/app.js were silently ignored.
Add .eslintrc.json with no-undef: error and eqeqeq: error so CI actually
catches JS bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:33:26 -04:00
jared 9c52e4ad1a Fix inspector auto-refresh ignoring 'Off' setting on page load
Lint / Python (flake8) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
Same ?? / || issue as the previous fix in index.html and links.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:20:42 -04:00
jared 156ef97667 Fix auto-refresh ignoring 'Off' setting on page load
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Successful in 39s
Test / Python Tests (pytest) (push) Successful in 53s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
Using || 30 / || 60 as a fallback treats refreshInterval=0 (Off) as
falsy and replaces it with the default, causing auto-refresh to start
even when the user saved 'Off'. Replace with nullish coalescing (??)
so only null/undefined triggers the default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:19:44 -04:00
jared 2f74266bd9 Fix monitor loop double-sleep on error; add grep -F regression test
Lint / Python (flake8) (push) Successful in 49s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Successful in 42s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
On exception the monitor slept 30s inside the except block then fell
through to time.sleep(poll_interval), giving a 150s recovery gap instead
of 30s. Adding continue after the error sleep fixes this.

Also adds a regression test asserting dmesg filtering uses grep -F --
so a future refactor cannot silently reintroduce the regex wildcard bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:16:43 -04:00
7 changed files with 50 additions and 15 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"env": {
"browser": true,
"es2021": true
},
"globals": {
"lt": "readonly",
"GANDALF_CONFIG": "readonly",
"CSS": "readonly"
},
"rules": {
"no-undef": "error",
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-console": "off",
"eqeqeq": ["error", "always", { "null": "ignore" }]
},
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "script"
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import json
import logging import logging
import threading import threading
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import pymysql import pymysql
@@ -281,7 +281,7 @@ def create_suppression(
) -> int: ) -> int:
expires_at = None expires_at = None
if expires_minutes: if expires_minutes:
expires_at = datetime.utcnow() + timedelta(minutes=int(expires_minutes)) expires_at = datetime.now(timezone.utc) + timedelta(minutes=int(expires_minutes))
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
+18 -10
View File
@@ -12,7 +12,7 @@ import logging
import re import re
import shlex import shlex
import time import time
from datetime import datetime from datetime import datetime, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import requests import requests
@@ -618,7 +618,7 @@ class LinkStatsCollector:
return { return {
'hosts': result_hosts, 'hosts': result_hosts,
'unifi_switches': unifi_switches, 'unifi_switches': unifi_switches,
'updated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'), 'updated': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'),
} }
def _compute_unifi_rates(self, raw: Dict[str, dict], now: float) -> Dict[str, dict]: def _compute_unifi_rates(self, raw: Dict[str, dict], now: float) -> Dict[str, dict]:
@@ -653,7 +653,7 @@ class LinkStatsCollector:
# Helpers # Helpers
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _now_utc() -> str: def _now_utc() -> str:
return datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC') return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -837,10 +837,10 @@ class NetworkMonitor:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Ping-only hosts (no node_exporter) # Ping-only hosts (no node_exporter)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _process_ping_hosts(self, suppressions: list) -> None: def _process_ping_hosts(self, suppressions: list, ping_states: Dict[str, bool]) -> None:
for h in self.cfg.get('monitor', {}).get('ping_hosts', []): for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
name, ip = h['name'], h['ip'] name, ip = h['name'], h['ip']
reachable = self.pulse.ping(ip) reachable = ping_states.get(name, False)
if not reachable: if not reachable:
sup = db.check_suppressed(suppressions, 'host', name) sup = db.check_suppressed(suppressions, 'host', name)
@@ -882,6 +882,7 @@ class NetworkMonitor:
def _collect_snapshot( def _collect_snapshot(
self, iface_states: Dict[str, Dict[str, bool]], self, iface_states: Dict[str, Dict[str, bool]],
unifi_devices: Optional[List[dict]] = None, unifi_devices: Optional[List[dict]] = None,
ping_states: Optional[Dict[str, bool]] = None,
) -> dict: ) -> dict:
# Accept pre-fetched devices; fall back to empty list if unavailable # Accept pre-fetched devices; fall back to empty list if unavailable
display_unifi = unifi_devices if unifi_devices is not None else [] display_unifi = unifi_devices if unifi_devices is not None else []
@@ -910,7 +911,7 @@ class NetworkMonitor:
for h in self.cfg.get('monitor', {}).get('ping_hosts', []): for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
name, ip = h['name'], h['ip'] name, ip = h['name'], h['ip']
reachable = self.pulse.ping(ip, count=1, timeout=2) reachable = (ping_states or {}).get(name, False)
hosts[name] = { hosts[name] = {
'ip': ip, 'ip': ip,
'interfaces': {}, 'interfaces': {},
@@ -921,7 +922,7 @@ class NetworkMonitor:
return { return {
'hosts': hosts, 'hosts': hosts,
'unifi': display_unifi, 'unifi': display_unifi,
'updated': datetime.utcnow().isoformat() + 'Z', 'updated': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -942,8 +943,14 @@ class NetworkMonitor:
# 2. Fetch UniFi devices once — used by both snapshot and alert processing # 2. Fetch UniFi devices once — used by both snapshot and alert processing
unifi_devices = self.unifi.get_devices() unifi_devices = self.unifi.get_devices()
# 3. Collect and store snapshot for dashboard # 3a. Ping-only hosts once — shared by snapshot and alert processing
snapshot = self._collect_snapshot(iface_states, unifi_devices) ping_states: Dict[str, bool] = {
h['name']: self.pulse.ping(h['ip'])
for h in self.cfg.get('monitor', {}).get('ping_hosts', [])
}
# 3b. Collect and store snapshot for dashboard
snapshot = self._collect_snapshot(iface_states, unifi_devices, ping_states)
db.set_state('network_snapshot', snapshot) db.set_state('network_snapshot', snapshot)
db.set_state('last_check', _now_utc()) db.set_state('last_check', _now_utc())
@@ -959,7 +966,7 @@ class NetworkMonitor:
self._process_interfaces(iface_states, suppressions) self._process_interfaces(iface_states, suppressions)
self._process_unifi(unifi_devices, suppressions) self._process_unifi(unifi_devices, suppressions)
self._process_ping_hosts(suppressions) self._process_ping_hosts(suppressions, ping_states)
# Housekeeping: deactivate expired suppressions and purge old resolved events # Housekeeping: deactivate expired suppressions and purge old resolved events
db.cleanup_expired_suppressions() db.cleanup_expired_suppressions()
@@ -970,6 +977,7 @@ class NetworkMonitor:
except Exception as e: except Exception as e:
logger.error(f'Monitor loop error: {e}', exc_info=True) logger.error(f'Monitor loop error: {e}', exc_info=True)
time.sleep(30) time.sleep(30)
continue
time.sleep(self.poll_interval) time.sleep(self.poll_interval)
+1 -1
View File
@@ -469,7 +469,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
// Start auto-refresh using saved settings interval (default 30 s) // Start auto-refresh using saved settings interval (default 30 s)
const _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30; const _savedInterval = window.gandalfSettings?.refreshInterval ?? 30;
if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000); if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000);
// When settings change, restart auto-refresh with new interval // When settings change, restart auto-refresh with new interval
+1 -1
View File
@@ -473,7 +473,7 @@ async function loadInspector() {
} }
loadInspector(); loadInspector();
const _inspInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60; const _inspInterval = window.gandalfSettings?.refreshInterval ?? 60;
if (_inspInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(_inspInterval, 15) * 1000); if (_inspInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(_inspInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) { window.onGandalfSettingsChanged = function(s) {
+1 -1
View File
@@ -571,7 +571,7 @@ async function loadLinks() {
} }
loadLinks(); loadLinks();
const _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60; const _linksInterval = window.gandalfSettings?.refreshInterval ?? 60;
if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000); if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) { window.onGandalfSettingsChanged = function(s) {
+6
View File
@@ -36,6 +36,12 @@ class TestBuildSshCommand:
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0') cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
assert 'ethtool' in cmd assert 'ethtool' in cmd
def test_dmesg_uses_fixed_string_grep(self):
# grep -F prevents iface names with dots (e.g. eth0.1) being treated as
# regex wildcards; -- prevents leading - from being parsed as a flag
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
assert 'grep -F --' in cmd
# ── parse_output ───────────────────────────────────────────────────────────── # ── parse_output ─────────────────────────────────────────────────────────────