Les hyperviseurs exposaient temperatures, ventilateurs, SMART et usure NVMe sans que rien les regarde. scripts/materiel.py porte capteurs et seuils ; Grafana en tire le tableau Materiel, Icinga un service materiel par hyperviseur. Premier verdict : le disque sda de gandalf (OSD Ceph) a 56 secteurs en attente, stables. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223 lines
12 KiB
Python
223 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Le catalogue des capteurs materiels — une source, deux lecteurs : Grafana et Icinga.
|
|
|
|
POURQUOI UN CATALOGUE (2026-09-17). Les hyperviseurs exposaient deja 68 temperatures, leurs
|
|
ventilateurs, leurs disques SMART et l'usure de leurs NVMe — collectes par Prometheus depuis
|
|
le 2026-09-10, regardes par personne. Le tableau de bord et la sonde doivent lire les MEMES
|
|
capteurs avec les MEMES seuils : ecrits deux fois, ils divergeraient, et un graphe rouge
|
|
cohabiterait avec une supervision verte.
|
|
|
|
CE QUE LE MATERIEL DIT DE LUI-MEME, ET POURQUOI ON NE LE CROIT PAS. Les puces annoncent
|
|
leurs propres limites (`temp_max`, `temp_crit`). Sur ces cartes ASUS elles valent 80 et 125 °C
|
|
pour la carte mere — des valeurs d'usine du Super I/O, pas des limites du systeme. Les seuils
|
|
sont donc DECLARES ici, avec leur raison.
|
|
|
|
CE QU'ON IGNORE, ET POURQUOI (releve du 2026-09-17 sur asgard, gandalf, vishnu) :
|
|
- `AUXTIN0..5` du nct6799 : entrees non cablees (-62, 0, 12 °C) ;
|
|
- `PCH_*` : lisent 0 sur ces cartes ;
|
|
- `TSI0_TEMP` et `amdgpu edge` : le meme silicium que `Tctl`, vu autrement ;
|
|
- la puce Wi-Fi : sans interet pour un hyperviseur ;
|
|
- les disques iSCSI (`type="scsi"`) : des volumes distants, dont le materiel n'est pas ici.
|
|
|
|
Les familles sans capteur sur un hote (DDR5 et cartes reseau n'existent que sur le noyau 6.14
|
|
de vishnu) ne rendent simplement aucune serie : ce n'est ni une panne ni un oubli.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# Les jobs Prometheus qui portent du materiel. `fabric` : les hyperviseurs ; `frontiere` :
|
|
# le boitier OPNsense, quand son exportateur est en service.
|
|
JOBS = 'job=~"fabric|frontiere"'
|
|
|
|
|
|
# LE COTE DROIT D'UNE JOINTURE EST TOUJOURS AGREGE (eprouve le 2026-09-17). Le jour ou l'on a
|
|
# ajoute l'etiquette `hote` aux cibles, Prometheus a garde cinq minutes la serie sans elle ET
|
|
# la serie avec : deux series pour une meme cle de jointure, et la requete refusee (« many-to-
|
|
# many matching not allowed »). Le tableau se vidait, la sonde rendait INCONNU. `group by` /
|
|
# `max by` ramene la cle a une seule serie, quel que soit l'historique des etiquettes.
|
|
def _hwmon(chip_name: str, etiquettes: str | None = None) -> str:
|
|
"""Temperatures hwmon d'une puce, avec le libelle du capteur quand la puce en donne un."""
|
|
expr = f"node_hwmon_temp_celsius{{{JOBS}}}"
|
|
if etiquettes:
|
|
expr = (f"({expr} * on(instance, chip, sensor) group_left(label) "
|
|
f'group by (instance, chip, sensor, label) '
|
|
f'(node_hwmon_sensor_label{{{JOBS},label=~"{etiquettes}"}}))')
|
|
return (f"({expr} * on(instance, chip) group_left(chip_name) "
|
|
f'group by (instance, chip, chip_name) '
|
|
f'(node_hwmon_chip_names{{{JOBS},chip_name=~"{chip_name}"}}))')
|
|
|
|
|
|
# UNE FAMILLE = une question qu'on se pose devant un equipement chaud.
|
|
# expr : les series (au moins les etiquettes `hote` et `capteur`)
|
|
# avert/crit: °C ; `None` = regardee, jamais jugee
|
|
# jugee_sur : la fenetre de lissage du verdict — un pic d'une seconde n'est pas une panne
|
|
TEMPERATURES = [
|
|
{
|
|
"cle": "processeur",
|
|
"titre": "Processeur",
|
|
"expr": f'label_replace({_hwmon("k10temp", "Tctl|Tccd1|Tccd2")}, "capteur", "$1", "label", "(.*)")',
|
|
"juge": 'capteur="Tctl"',
|
|
# Le verdict ne regarde que Tctl : les Tccd sont des sondes de coeur, plus bruitees.
|
|
"expr_juge": f'label_replace({_hwmon("k10temp", "Tctl")}, "capteur", "$1", "label", "(.*)")',
|
|
"avert": 90, "crit": 95, "jugee_sur": "5m",
|
|
"raison": ("Ryzen 9 7900X : il est CONCU pour monter a 95 °C sous charge soutenue et s'y "
|
|
"brider. Un Tctl a 95 pendant cinq minutes veut dire qu'il bride — la machine "
|
|
"rend moins que ce qu'on croit. Au-dessus, c'est le refroidissement."),
|
|
},
|
|
{
|
|
"cle": "carte_mere",
|
|
"titre": "Carte mère",
|
|
"expr": (f'label_replace({_hwmon("nct6799", "SYSTIN|CPUTIN")}, "capteur", "$1", "label", '
|
|
f'"(.*)")'),
|
|
"juge": 'capteur="SYSTIN"',
|
|
"expr_juge": f'label_replace({_hwmon("nct6799", "SYSTIN")}, "capteur", "$1", "label", "(.*)")',
|
|
"avert": 50, "crit": 60, "jugee_sur": "10m",
|
|
"raison": ("SYSTIN est l'air du boitier. Il monte avec la piece, pas avec la charge : un "
|
|
"SYSTIN qui grimpe sur les trois machines a la fois parle de la salle, pas "
|
|
"d'une machine."),
|
|
},
|
|
{
|
|
"cle": "nvme",
|
|
"titre": "NVMe",
|
|
"expr": (f'label_replace({_hwmon("nvme", "Composite")}, "capteur", "$1", "chip", '
|
|
f'"nvme_(.*)")'),
|
|
"juge": "",
|
|
"avert": 70, "crit": 80, "jugee_sur": "5m",
|
|
"raison": ("WD_BLACK SN850X : il ralentit ses ecritures vers 80-85 °C pour se proteger. "
|
|
"Ceph et les VM le vivent comme une latence, sans aucune erreur."),
|
|
},
|
|
{
|
|
"cle": "disques",
|
|
"titre": "Disques SATA",
|
|
# LES SSD SAMSUNG NE PORTENT PAS L'ATTRIBUT 194 : leur temperature est la 190
|
|
# (« airflow »). Sans le repli, les deux 850 disparaissaient du tableau sans un mot.
|
|
"expr": (f'label_replace((smartmon_temperature_celsius_raw_value{{{JOBS},type="sat"}} or '
|
|
f'smartmon_airflow_temperature_cel_raw_value{{{JOBS},type="sat"}}), '
|
|
f'"capteur", "$1", "disk", "/dev/(.*)")'),
|
|
"juge": "",
|
|
"avert": 50, "crit": 60, "jugee_sur": "15m",
|
|
"raison": ("WD Red Plus 10 To (OSD HDD de Ceph) et SSD Samsung 850 : la vie d'un disque "
|
|
"mecanique chute au-dessus de 50 °C, bien avant sa limite de 65. Celui de "
|
|
"gandalf a deja vu 63 °C (maximum a vie, releve SMART du 2026-09-17)."),
|
|
},
|
|
{
|
|
"cle": "memoire",
|
|
"titre": "Mémoire DDR5",
|
|
"expr": (f'label_replace({_hwmon("spd5118")}, "capteur", "barrette $1", "chip", '
|
|
f'"i2c_(.*)")'),
|
|
"juge": "",
|
|
"avert": 70, "crit": 80, "jugee_sur": "5m",
|
|
"raison": ("Capteur JEDEC des barrettes (noyau 6.14, vishnu seulement). La DDR5 "
|
|
"commence a corriger des erreurs au-dela de 85 °C."),
|
|
},
|
|
{
|
|
"cle": "reseau",
|
|
"titre": "Cartes réseau",
|
|
"expr": (f'label_replace({_hwmon("r8169.*")}, "capteur", "$1", "chip_name", '
|
|
f'"(.*)")'),
|
|
"juge": "",
|
|
"avert": 95, "crit": 110, "jugee_sur": "5m",
|
|
"raison": "Realtek RTL8125 (noyau 6.14, vishnu seulement) ; limite annoncee 120 °C.",
|
|
},
|
|
{
|
|
"cle": "frontiere",
|
|
"titre": "Frontière (cœurs)",
|
|
"expr": (f'label_replace(node_cpu_temperature_celsius{{{JOBS}}}, "capteur", "cœur $1", '
|
|
f'"cpu", "(.*)")'),
|
|
"juge": "",
|
|
"avert": 80, "crit": 90, "jugee_sur": "5m",
|
|
"raison": ("Intel i3-5020U, Tjunction 100 °C. A 90, il bride — et c'est tout le trafic du "
|
|
"site qui ralentit."),
|
|
},
|
|
]
|
|
|
|
# CE QUI NE SE MESURE PAS EN DEGRES. Chaque controle rend une expression dont TOUTE serie
|
|
# presente est un defaut (etiquettes `hote` et `capteur`). Vide = sain.
|
|
CONTROLES = [
|
|
{
|
|
"cle": "ventilateur_arrete",
|
|
"titre": "Ventilateur arrêté",
|
|
"niveau": "crit",
|
|
# UN CONNECTEUR VIDE N'EST PAS UN VENTILATEUR ARRETE. fan4, fan6 et fan7 lisent 0
|
|
# depuis toujours : on ne crie que pour un ventilateur qui A TOURNE dans les 24 h.
|
|
"expr": (f'label_replace((node_hwmon_fan_rpm{{{JOBS}}} == 0) and on(instance, chip, sensor) '
|
|
f'(max_over_time(node_hwmon_fan_rpm{{{JOBS}}}[1d]) > 300), "capteur", "$1", '
|
|
f'"sensor", "(.*)")'),
|
|
"raison": "Un ventilateur qui tournait hier et plus maintenant : la chaleur suit dans l'heure.",
|
|
},
|
|
{
|
|
"cle": "smart_echec",
|
|
"titre": "SMART : disque en échec",
|
|
"niveau": "crit",
|
|
"expr": (f'label_replace(smartmon_device_smart_healthy{{{JOBS},type=~"sat|nvme"}} == 0, '
|
|
f'"capteur", "$1", "disk", "/dev/(.*)")'),
|
|
"raison": "Le disque declare lui-meme qu'il va lacher. Il faut le remplacer, pas l'observer.",
|
|
},
|
|
{
|
|
"cle": "secteurs_degradation",
|
|
"titre": "Disque : secteurs en progression",
|
|
"niveau": "crit",
|
|
# LA PROGRESSION, PAS LE COMPTE. Le disque de gandalf porte 56 secteurs en attente,
|
|
# stables depuis au moins une semaine, Ceph `HEALTH_OK` (releve du 2026-09-17). Un
|
|
# verdict critique sur le compte serait rouge pour toujours — et une alarme toujours
|
|
# rouge est une alarme qu'on cesse de lire. Ce qui presse, c'est qu'il AUGMENTE.
|
|
"expr": (f'label_replace((delta(smartmon_current_pending_sector_raw_value{{{JOBS}}}[1d]) > 0) or '
|
|
f'(delta(smartmon_reallocated_sector_ct_raw_value{{{JOBS}}}[1d]) > 0) or '
|
|
f'(delta(smartmon_offline_uncorrectable_raw_value{{{JOBS}}}[1d]) > 0), '
|
|
f'"capteur", "$1", "disk", "/dev/(.*)")'),
|
|
"raison": "Le disque perd des secteurs EN CE MOMENT : sa degradation s'accelere, le remplacer.",
|
|
},
|
|
{
|
|
"cle": "secteurs_defaillants",
|
|
"titre": "Disque : secteurs défaillants",
|
|
"niveau": "avert",
|
|
"expr": (f'label_replace((smartmon_current_pending_sector_raw_value{{{JOBS}}} > 0) or '
|
|
f'(smartmon_offline_uncorrectable_raw_value{{{JOBS}}} > 0) or '
|
|
f'(smartmon_reallocated_sector_ct_raw_value{{{JOBS}}} > 0), "capteur", "$1", '
|
|
f'"disk", "/dev/(.*)")'),
|
|
"raison": ("Le disque a des secteurs illisibles ou deja remplaces. Stable, il tient ; "
|
|
"a remplacer a la prochaine occasion."),
|
|
},
|
|
{
|
|
"cle": "nvme_alerte",
|
|
"titre": "NVMe : alerte du contrôleur",
|
|
"niveau": "crit",
|
|
"expr": (f'label_replace((nvme_critical_warning_total{{{JOBS}}} > 0) or '
|
|
f'(nvme_available_spare_ratio{{{JOBS}}} < on(instance, device) group_left() '
|
|
f'max by (instance, device) (nvme_available_spare_threshold_ratio{{{JOBS}}})), '
|
|
f'"capteur", "$1", "device", "(.*)")'),
|
|
"raison": "Le controleur NVMe signale un avertissement critique ou a epuise sa reserve.",
|
|
},
|
|
{
|
|
"cle": "nvme_usure",
|
|
"titre": "NVMe : usure > 80 %",
|
|
"niveau": "avert",
|
|
"expr": (f'label_replace(nvme_percentage_used_ratio{{{JOBS}}} > 0.8, "capteur", "$1", '
|
|
f'"device", "(.*)")'),
|
|
"raison": "L'endurance annoncee est presque consommee : a commander, pas encore a remplacer.",
|
|
},
|
|
{
|
|
"cle": "donnees_perimees",
|
|
"titre": "Relevés SMART périmés",
|
|
"niveau": "avert",
|
|
# UNE SONDE MUETTE N'EST PAS UNE SONDE SAINE. Les releves SMART et NVMe viennent de
|
|
# minuteries (15 min) : si elles s'arretent, toutes les regles ci-dessus restent
|
|
# vertes sur des chiffres figes.
|
|
"expr": (f'label_replace(time() - node_textfile_mtime_seconds{{{JOBS},'
|
|
f'file=~".*(smartmon|nvme)[.]prom"}} > 3600, "capteur", "$1", "file", '
|
|
f'".*/(.*)[.]prom")'),
|
|
"raison": "Les releves SMART/NVMe n'ont pas ete rafraichis depuis une heure : les verdicts ci-dessus sont figes.",
|
|
},
|
|
]
|
|
|
|
|
|
def catalogue() -> dict:
|
|
"""Ce que les roles recoivent (Grafana et Icinga). `expr_juge` vaut `expr` par defaut."""
|
|
temperatures = [{**f, "expr_juge": f.get("expr_juge") or f["expr"]} for f in TEMPERATURES]
|
|
for f in temperatures:
|
|
f.pop("juge", None)
|
|
return {"temperatures": temperatures, "controles": CONTROLES}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import json
|
|
print(json.dumps(catalogue(), ensure_ascii=False, indent=1))
|