#!/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))