Skip to content

Commit 6940ebb

Browse files
committed
feat(monitoring): temperature history + temperature alert metric (#601)
Live per-host temperature already shipped via the lm-sensors Sensors panel. This adds the two pieces #601 asked for on top: History / chart over time: - metrics.py collector now records each online node hottest lm-sensors temp into the persisted 5-min metrics_history snapshot. SSH fan-out uses the SSH-aware run_per_node (max_concurrent=8/cluster) so we never open 30+ simultaneous sessions; a 1h per-node backoff skips hosts without lm-sensors, and the per-manager temp caches are pruned to known nodes. - new GET /clusters/<id>/nodes/<node>/temperature-history (off-hub, cached). - node Sensors panel renders a temperature-over-time sparkline + min/max/avg. Alerting: - new "temperature" alert metric for node (this node) and cluster (hottest node) targets, read from a manager temp-cache the 60s alert loop never SSHes. Alerts render in °C (not %); auto-severity uses absolute thresholds (>=85 crit, >=75 warn). Alert form gains the metric + a dynamic °C/% unit. - i18n for all 7 languages.
1 parent 7970cbd commit 6940ebb

8 files changed

Lines changed: 227 additions & 22 deletions

File tree

pegaprox/api/nodes.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,35 @@ def get_node_sensors_api(cluster_id, node):
194194
return jsonify(result)
195195

196196

197+
# #601 — per-node hottest-temperature history for the chart-over-time ask. Reads the
198+
# background collector's persisted 5-min metrics_history snapshots (off-hub, cached);
199+
# no live SSH here, so it stays cheap even at fleet scale.
200+
@bp.route('/api/clusters/<cluster_id>/nodes/<node>/temperature-history', methods=['GET'])
201+
@require_auth(perms=['node.view'])
202+
def get_node_temperature_history_api(cluster_id, node):
203+
ok, err = check_cluster_access(cluster_id)
204+
if not ok: return err
205+
bad, code = _reject_bad_node(node)
206+
if bad is not None: return bad, code
207+
from pegaprox.background.metrics import load_metrics_history
208+
series = []
209+
try:
210+
hist = load_metrics_history() or {}
211+
for snap in (hist.get('snapshots') or []):
212+
try:
213+
nodes = ((snap.get('clusters') or {}).get(cluster_id) or {}).get('nodes') or {}
214+
temp = (nodes.get(node) or {}).get('temp')
215+
ts = snap.get('timestamp')
216+
if temp is not None and ts:
217+
series.append({'ts': ts, 'temp': temp})
218+
except Exception:
219+
continue
220+
except Exception as e:
221+
return jsonify({'error': f'history read failed: {e}'}), 500
222+
series.sort(key=lambda x: x['ts'])
223+
return jsonify({'series': series, 'unit': '°C', 'count': len(series)})
224+
225+
197226
@bp.route('/api/clusters/<cluster_id>/nodes/<node>/network/<iface>', methods=['PUT'])
198227
@require_auth(perms=['node.network'])
199228
def update_node_network_api(cluster_id, node, iface):

pegaprox/background/alerts.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ def check_and_send_alerts():
203203
total = sum(n.get('disk_total', 0) for n in online)
204204
if total > 0:
205205
current_value = used / total * 100
206+
elif metric == 'temperature':
207+
# #601 — hottest cached node temperature (°C) across the cluster.
208+
# Populated by the 5-min metrics collector; we never SSH here.
209+
temps = []
210+
for nname in list(getattr(manager, '_node_temp_cache', {}) or {}):
211+
tv = manager.get_cached_node_temp(nname)
212+
if tv is not None:
213+
temps.append(tv)
214+
if temps:
215+
current_value = max(temps)
206216
elif metric in ('backup_sla_breached_pct', 'backup_sla_compliance_pct'):
207217
# MK May 2026 — Backup SLA-aware alerts. Run the same
208218
# eval as the /backup-sla endpoint and feed the % into
@@ -323,6 +333,9 @@ def _scan_node(nd):
323333
rootfs = node_summary.get('rootfs', {}) or {}
324334
if rootfs.get('total', 0) > 0:
325335
current_value = (rootfs.get('used', 0) / rootfs.get('total', 1)) * 100
336+
elif metric == 'temperature':
337+
# #601 — cached hottest-sensor temp (°C) for this node.
338+
current_value = manager.get_cached_node_temp(target_id)
326339

327340
elif target_type == 'vm':
328341
# MK: was `manager.get_resources()` which doesn't exist; the
@@ -364,8 +377,11 @@ def _scan_node(nd):
364377
elif operator == '<=' and current_value <= threshold:
365378
triggered = True
366379

380+
# #601 — temperature is an absolute °C reading, every other metric is a %.
381+
unit = '°C' if metric == 'temperature' else '%'
382+
367383
if not triggered:
368-
_record_eval(alert_id, reason=f'below threshold ({metric}={current_value:.1f}% {operator} {threshold}% → false)',
384+
_record_eval(alert_id, reason=f'below threshold ({metric}={current_value:.1f}{unit} {operator} {threshold}{unit} → false)',
369385
cluster_id=cluster_id, metric=metric, current_value=round(current_value, 1),
370386
threshold=threshold, operator=operator, triggered=False)
371387

@@ -377,8 +393,8 @@ def _scan_node(nd):
377393
Alert: {alert_name}
378394
Target: {target_type.capitalize()} - {target_name}
379395
Metric: {metric.upper()}
380-
Condition: {metric} {operator} {threshold}%
381-
Current Value: {current_value:.1f}%
396+
Condition: {metric} {operator} {threshold}{unit}
397+
Current Value: {current_value:.1f}{unit}
382398
Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
383399
Cluster: {cluster_id}
384400
@@ -394,8 +410,8 @@ def _scan_node(nd):
394410
<table style="border-collapse: collapse; width: 100%; max-width: 500px;">
395411
<tr><td style="padding: 8px; border: 1px solid #ddd;"><strong>Target</strong></td><td style="padding: 8px; border: 1px solid #ddd;">{_e(str(target_type).capitalize())} - {_e(str(target_name))}</td></tr>
396412
<tr><td style="padding: 8px; border: 1px solid #ddd;"><strong>Metric</strong></td><td style="padding: 8px; border: 1px solid #ddd;">{_e(str(metric).upper())}</td></tr>
397-
<tr><td style="padding: 8px; border: 1px solid #ddd;"><strong>Condition</strong></td><td style="padding: 8px; border: 1px solid #ddd;">{_e(str(metric))} {_e(str(operator))} {threshold}%</td></tr>
398-
<tr style="background-color: #fee2e2;"><td style="padding: 8px; border: 1px solid #ddd;"><strong>Current Value</strong></td><td style="padding: 8px; border: 1px solid #ddd;"><strong>{current_value:.1f}%</strong></td></tr>
413+
<tr><td style="padding: 8px; border: 1px solid #ddd;"><strong>Condition</strong></td><td style="padding: 8px; border: 1px solid #ddd;">{_e(str(metric))} {_e(str(operator))} {threshold}{unit}</td></tr>
414+
<tr style="background-color: #fee2e2;"><td style="padding: 8px; border: 1px solid #ddd;"><strong>Current Value</strong></td><td style="padding: 8px; border: 1px solid #ddd;"><strong>{current_value:.1f}{unit}</strong></td></tr>
399415
<tr><td style="padding: 8px; border: 1px solid #ddd;"><strong>Time</strong></td><td style="padding: 8px; border: 1px solid #ddd;">{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</td></tr>
400416
</table>
401417
<p style="color: #666; font-size: 12px; margin-top: 20px;">This is an automated alert from PegaProx.</p>
@@ -437,7 +453,13 @@ def _scan_node(nd):
437453

438454
# NS #501: a rule may pin an explicit severity; otherwise derive it.
439455
_rule_sev = alert.get('severity')
440-
severity = _rule_sev if _rule_sev and _rule_sev != 'auto' else ('critical' if current_value > 90 else 'warning' if current_value > 70 else 'info')
456+
if _rule_sev and _rule_sev != 'auto':
457+
severity = _rule_sev
458+
elif metric == 'temperature':
459+
# #601 — absolute °C thresholds for auto-severity (not the %-based ladder)
460+
severity = 'critical' if current_value >= 85 else 'warning' if current_value >= 75 else 'info'
461+
else:
462+
severity = 'critical' if current_value > 90 else 'warning' if current_value > 70 else 'info'
441463
alert_data = {
442464
'alert_name': alert_name,
443465
'metric': metric,
@@ -449,7 +471,7 @@ def _scan_node(nd):
449471
'cluster_id': cluster_id,
450472
'severity': severity,
451473
'timestamp': datetime.now().isoformat(),
452-
'message': f"{target_type.capitalize()} {target_name}: {metric} is {current_value:.1f}% (threshold: {operator} {threshold}%)",
474+
'message': f"{target_type.capitalize()} {target_name}: {metric} is {current_value:.1f}{unit} (threshold: {operator} {threshold}{unit})",
453475
}
454476
if _notification_handlers:
455477
for handler in _notification_handlers:

pegaprox/background/metrics.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,38 @@
1616

1717
from pegaprox.globals import cluster_managers
1818
from pegaprox.core.db import get_db
19+
from pegaprox.utils.concurrent import run_per_node # #601: SSH-aware bounded fan-out for per-node temp reads
20+
21+
22+
def _node_hottest_temp(mgr, node):
23+
"""#601 — SSH lm-sensors → hottest temperature reading (°C) for one node, or None.
24+
25+
Honours a per-node backoff so installs WITHOUT lm-sensors/SSH (or non-PVE hosts)
26+
aren't re-probed every 5-min cycle — an error parks the node for ~1h. Nodes that
27+
do report temps clear their backoff, so a freshly-installed lm-sensors is picked
28+
up on the next successful probe.
29+
"""
30+
import time as _t
31+
backoff = getattr(mgr, '_node_temp_probe_backoff', None)
32+
if backoff is None:
33+
backoff = mgr._node_temp_probe_backoff = {}
34+
until = backoff.get(node, 0)
35+
if until and _t.time() < until:
36+
return None
37+
try:
38+
res = mgr.get_node_sensors(node)
39+
except Exception:
40+
res = {'error': 'exception'}
41+
if not isinstance(res, dict) or res.get('error'):
42+
backoff[node] = _t.time() + 3600 # no sensors/SSH here → don't storm SSH
43+
return None
44+
temps = [s.get('value') for s in (res.get('sensors') or [])
45+
if s.get('kind') == 'temp' and isinstance(s.get('value'), (int, float))]
46+
backoff.pop(node, None) # works here — clear any prior backoff
47+
if not temps:
48+
return None
49+
return round(max(temps), 1)
50+
1951

2052
def load_metrics_history():
2153
"""Load historical metrics from SQLite database.
@@ -173,7 +205,51 @@ def collect_metrics_snapshot():
173205
cluster_data['totals']['cpu_used'] += node_data.get('cpu', 0) * node_data.get('maxcpu', 0)
174206
cluster_data['totals']['mem_total'] += node_data.get('maxmem', 0)
175207
cluster_data['totals']['mem_used'] += node_data.get('mem', 0)
176-
208+
209+
# #601 — per-node hottest temperature (°C) for the history chart + the
210+
# temperature alert metric. lm-sensors is SSH-side, so fan out over ALL
211+
# online nodes with the SSH-AWARE primitive: run_per_node caps concurrency
212+
# at 8/cluster so we never open 30+ simultaneous SSH sessions (which trips
213+
# AccountLockFailures on hardened nodes). Real PVE (corosync) clusters are
214+
# <=~32 nodes, so 90s comfortably covers a whole cluster each cycle. Runs on
215+
# the 5-min collector greenlet, off the broadcast hot-path. Writes both the
216+
# snapshot (→ persisted history) and the manager temp-cache (→ read by the
217+
# 60s alert loop, which must not SSH).
218+
try:
219+
online_node_names = list(cluster_data['nodes'].keys())
220+
if online_node_names:
221+
node_calls = {name: (lambda nm: _node_hottest_temp(mgr, nm))
222+
for name in online_node_names}
223+
temp_by_node = run_per_node(node_calls, max_concurrent=8, timeout=90)
224+
now_ts = time.time()
225+
tcache = getattr(mgr, '_node_temp_cache', None)
226+
tlock = getattr(mgr, '_node_temp_lock', None)
227+
got = 0
228+
for nm, temp_c in (temp_by_node or {}).items():
229+
if temp_c is None:
230+
continue
231+
cluster_data['nodes'][nm]['temp'] = temp_c
232+
got += 1
233+
if tcache is not None and tlock is not None:
234+
with tlock:
235+
tcache[nm] = {'temp': temp_c, 'ts': now_ts}
236+
# keep the per-manager caches bounded: drop keys for decommissioned/
237+
# renamed nodes so they don't accrete over the process lifetime.
238+
known = set(mgr.nodes or {})
239+
if known:
240+
if tcache is not None and tlock is not None:
241+
with tlock:
242+
for k in [k for k in tcache if k not in known]:
243+
tcache.pop(k, None)
244+
bo = getattr(mgr, '_node_temp_probe_backoff', None)
245+
if isinstance(bo, dict):
246+
for k in [k for k in bo if k not in known]:
247+
bo.pop(k, None)
248+
if got:
249+
logging.debug(f"[metrics] {cluster_id}: temp for {got}/{len(online_node_names)} node(s)")
250+
except Exception as _te:
251+
logging.debug(f"[metrics] {cluster_id}: temp collection skipped: {_te}")
252+
177253
# Count VMs + per-VM samples for right-sizing (MK May 2026)
178254
cluster_data['vms'] = {} # vmid -> {cpu_pct, mem_pct, maxmem, maxcpu, status}
179255
try:

pegaprox/core/manager.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ def __init__(self, cluster_id: str, config: PegaProxConfig):
231231
self._vm_migration_cooldown = {} # {vmid: timestamp} — prevent ping-pong
232232
self._node_metrics_history = {} # {node_name: [{timestamp, cpu, mem_pct, ...}]} ring buffer for predictive LB
233233
self._metrics_history_lock = threading.Lock() # guard concurrent access from API routes
234+
# #601 — hottest lm-sensors reading per node (°C), refreshed by the 5-min
235+
# metrics collector and READ by the 60s alert loop (which must never SSH
236+
# per-tick). _node_temp_probe_backoff skips nodes without lm-sensors/SSH.
237+
self._node_temp_cache = {} # {node_name: {'temp': float_c, 'ts': epoch}}
238+
self._node_temp_lock = threading.Lock()
239+
self._node_temp_probe_backoff = {} # {node_name: reprobe_after_epoch}
234240

235241
# maintenance mode
236242
self.nodes_in_maintenance = {}
@@ -13761,6 +13767,19 @@ def get_node_sensors(self, node: str) -> Dict[str, Any]:
1376113767
out.append(row)
1376213768
return {'sensors': out, 'count': len(out)}
1376313769

13770+
def get_cached_node_temp(self, node: str, max_age: float = 900):
13771+
"""#601 — last cached hottest-sensor temperature (°C) for a node, or None if
13772+
missing/stale. Populated by the 5-min metrics collector (see background/
13773+
metrics.py); read by the alert loop so temperature alerts never SSH per-tick."""
13774+
import time as _t
13775+
with self._node_temp_lock:
13776+
ent = self._node_temp_cache.get(node)
13777+
if not ent:
13778+
return None
13779+
if max_age and (_t.time() - ent.get('ts', 0)) > max_age:
13780+
return None
13781+
return ent.get('temp')
13782+
1376413783
def get_node_cluster_health(self, node: str) -> Dict[str, Any]:
1376513784
# MK May 2026 — SSH-side cluster health: corosync ring status,
1376613785
# pvecm quorum + per-service uptime. PVE's REST `/cluster/status`

0 commit comments

Comments
 (0)