ajout du type distance parcourue

This commit is contained in:
Daniel Allaire 2026-03-16 22:14:54 -04:00
parent 69af91ad84
commit 8f36fa8fd3
7 changed files with 3541 additions and 64 deletions

View file

@ -15,7 +15,7 @@ def parse_date(value: str) -> datetime:
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc)
def load_input_value(inputs_file: str, item_key: str) -> str:
def load_input_item(inputs_file: str, item_key: str) -> dict:
path = Path(inputs_file)
if not path.exists():
raise ValueError(f"inputs file introuvable: {inputs_file}")
@ -30,10 +30,13 @@ def load_input_value(inputs_file: str, item_key: str) -> str:
item = data[item_key]
if not isinstance(item, dict):
raise ValueError(f"entrée invalide pour item_key: {item_key}")
return item
def load_input_value(inputs_file: str, item_key: str) -> str:
item = load_input_item(inputs_file, item_key)
if "value" not in item:
raise ValueError(f"champ 'value' absent pour item_key: {item_key}")
return str(item["value"]).strip()
@ -63,21 +66,50 @@ def main() -> int:
args = ap.parse_args()
try:
if args.source_type != "manual_date":
exit_with(2, f"CRITICAL - source_type non supporté: {args.source_type}")
if args.probe_type in {"elapsed_time", "days_until_due"}:
if args.source_type != "manual_date":
exit_with(2, f"CRITICAL - source_type non supporté: {args.source_type}")
if args.unit != "days":
exit_with(2, f"CRITICAL - unité non supportée: {args.unit}")
if args.unit != "days":
exit_with(2, f"CRITICAL - unité non supportée: {args.unit}")
if args.inputs_file and args.item_key:
source_value = load_input_value(args.inputs_file, args.item_key)
elif args.source_value:
source_value = args.source_value
else:
raise ValueError("aucune source fournie: inputs-file/item-key ou source-value requis")
dt = parse_date(source_value)
now = datetime.now(timezone.utc)
elif args.probe_type == "elapsed_distance":
if args.source_type != "manual_counter":
exit_with(2, f"CRITICAL - source_type non supporté: {args.source_type}")
if args.unit != "km":
exit_with(2, f"CRITICAL - unité non supportée: {args.unit}")
if args.inputs_file and args.item_key:
item = load_input_item(args.inputs_file, args.item_key)
current_value = float(str(item.get("value", "")).strip())
reference_value = float(str(item.get("reference_value", "")).strip())
else:
raise ValueError("manual_counter exige inputs-file et item-key")
metric = current_value - reference_value
if args.unknown_lt is not None and metric < args.unknown_lt:
exit_with(3, f"UNKNOWN - {args.label}: écart compteur invalide ({metric:.0f} km)")
if args.critical_gte is not None and metric >= args.critical_gte:
exit_with(2, f"CRITICAL - {args.label}: {metric:.0f} km")
if args.warning_gte is not None and metric >= args.warning_gte:
exit_with(1, f"WARNING - {args.label}: {metric:.0f} km")
if args.ok_gte is not None and metric >= args.ok_gte:
exit_with(0, f"OK - {args.label}: {metric:.0f} km")
exit_with(2, f"CRITICAL - {args.label}: seuils incohérents")
if args.inputs_file and args.item_key:
source_value = load_input_value(args.inputs_file, args.item_key)
elif args.source_value:
source_value = args.source_value
else:
raise ValueError("aucune source fournie: inputs-file/item-key ou source-value requis")
dt = parse_date(source_value)
now = datetime.now(timezone.utc)
exit_with(2, f"CRITICAL - probe_type non supporté: {args.probe_type}")
if args.probe_type == "elapsed_time":
metric = (now - dt).total_seconds() / 86400.0
@ -107,9 +139,6 @@ def main() -> int:
exit_with(2, f"CRITICAL - {args.label}: seuils incohérents")
else:
exit_with(2, f"CRITICAL - probe_type non supporté: {args.probe_type}")
except Exception as exc:
if args.on_error == "critical":
exit_with(2, f"CRITICAL - erreur sonde: {exc}")

View file

@ -1415,18 +1415,94 @@ domains:
- name: verification-huile-moteur
date: 2026-04-01
notes: Vérification de l'huile moteur
probe:
type: elapsed_distance
source:
type: manual_counter
inputs_file: /opt/life-noc/data/inputs/voiture.yaml
item_key: verification-huile-moteur
metric:
unit: km
thresholds:
unknown_lt: 0
ok_gte: 0
warning_gte: 8000
critical_gte: 10000
policy:
on_error: unknown
ui:
form_mode: counter_pair
allow_complete: false
allow_manual_edit: true
- name: inspection-freins
date: 2026-05-01
notes: Inspection des freins
probe:
type: elapsed_distance
source:
type: manual_counter
inputs_file: /opt/life-noc/data/inputs/voiture.yaml
item_key: inspection-freins
metric:
unit: km
thresholds:
unknown_lt: 0
ok_gte: 0
warning_gte: 15000
critical_gte: 20000
policy:
on_error: unknown
ui:
form_mode: counter_pair
allow_complete: false
allow_manual_edit: true
- name: verification-pneus
date: 2026-04-15
notes: Vérification usure, pression et permutation des pneus
probe:
type: elapsed_distance
source:
type: manual_counter
inputs_file: /opt/life-noc/data/inputs/voiture.yaml
item_key: verification-pneus
metric:
unit: km
thresholds:
unknown_lt: 0
ok_gte: 0
warning_gte: 10000
critical_gte: 12000
policy:
on_error: unknown
ui:
form_mode: counter_pair
allow_complete: false
allow_manual_edit: true
- name: ajustement-valves
date: 2026-08-01
notes: Vérification et ajustement des valves Honda Accord
- name: verification-timing-belt
date: 2027-01-01
notes: Vérification de la courroie de distribution et pompe à eau
probe:
type: elapsed_distance
source:
type: manual_counter
inputs_file: /opt/life-noc/data/inputs/voiture.yaml
item_key: verification-timing-belt
metric:
unit: km
thresholds:
unknown_lt: 0
ok_gte: 0
warning_gte: 150000
critical_gte: 168000
policy:
on_error: unknown
ui:
form_mode: counter_pair
allow_complete: false
allow_manual_edit: true
- name: verification-batterie-voiture
date: 2026-10-01
notes: Vérification de l'état de la batterie automobile

3234
domains.yaml.orig Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,33 +5,60 @@
*/
apply Service "voiture-verification-huile-moteur" {
import "service_echeance"
vars.date_echeance = "2026-04-01"
vars.mock_state = "OK"
vars.mock_message = "Sous contrôle"
import "service_life_noc_probe"
vars.life_noc_probe_type = "elapsed_distance"
vars.life_noc_source_type = "manual_counter"
vars.life_noc_source_value = ""
vars.life_noc_inputs_file = "/opt/life-noc/data/inputs/voiture.yaml"
vars.life_noc_item_key = "verification-huile-moteur"
vars.life_noc_metric_unit = "km"
vars.life_noc_label = "verification-huile-moteur"
groups = [ "VOITURE" ]
vars.life_noc_threshold_unknown_lt = "0"
vars.life_noc_threshold_ok_gte = "0"
vars.life_noc_threshold_warning_gte = "8000"
vars.life_noc_threshold_critical_gte = "10000"
vars.life_noc_on_error = "unknown"
notes = "Vérification de l'huile moteur"
vars.instructions_url = "/life-noc/item/voiture/verification-huile-moteur"
assign where host.name == "life-noc"
}
apply Service "voiture-inspection-freins" {
import "service_echeance"
vars.date_echeance = "2026-05-01"
vars.mock_state = "OK"
vars.mock_message = "Sous contrôle"
import "service_life_noc_probe"
vars.life_noc_probe_type = "elapsed_distance"
vars.life_noc_source_type = "manual_counter"
vars.life_noc_source_value = ""
vars.life_noc_inputs_file = "/opt/life-noc/data/inputs/voiture.yaml"
vars.life_noc_item_key = "inspection-freins"
vars.life_noc_metric_unit = "km"
vars.life_noc_label = "inspection-freins"
groups = [ "VOITURE" ]
vars.life_noc_threshold_unknown_lt = "0"
vars.life_noc_threshold_ok_gte = "0"
vars.life_noc_threshold_warning_gte = "15000"
vars.life_noc_threshold_critical_gte = "20000"
vars.life_noc_on_error = "unknown"
notes = "Inspection des freins"
vars.instructions_url = "/life-noc/item/voiture/inspection-freins"
assign where host.name == "life-noc"
}
apply Service "voiture-verification-pneus" {
import "service_echeance"
vars.date_echeance = "2026-04-15"
vars.mock_state = "OK"
vars.mock_message = "Sous contrôle"
import "service_life_noc_probe"
vars.life_noc_probe_type = "elapsed_distance"
vars.life_noc_source_type = "manual_counter"
vars.life_noc_source_value = ""
vars.life_noc_inputs_file = "/opt/life-noc/data/inputs/voiture.yaml"
vars.life_noc_item_key = "verification-pneus"
vars.life_noc_metric_unit = "km"
vars.life_noc_label = "verification-pneus"
groups = [ "VOITURE" ]
vars.life_noc_threshold_unknown_lt = "0"
vars.life_noc_threshold_ok_gte = "0"
vars.life_noc_threshold_warning_gte = "10000"
vars.life_noc_threshold_critical_gte = "12000"
vars.life_noc_on_error = "unknown"
notes = "Vérification usure, pression et permutation des pneus"
vars.instructions_url = "/life-noc/item/voiture/verification-pneus"
assign where host.name == "life-noc"
@ -49,11 +76,20 @@ apply Service "voiture-ajustement-valves" {
}
apply Service "voiture-verification-timing-belt" {
import "service_echeance"
vars.date_echeance = "2027-01-01"
vars.mock_state = "OK"
vars.mock_message = "Sous contrôle"
import "service_life_noc_probe"
vars.life_noc_probe_type = "elapsed_distance"
vars.life_noc_source_type = "manual_counter"
vars.life_noc_source_value = ""
vars.life_noc_inputs_file = "/opt/life-noc/data/inputs/voiture.yaml"
vars.life_noc_item_key = "verification-timing-belt"
vars.life_noc_metric_unit = "km"
vars.life_noc_label = "verification-timing-belt"
groups = [ "VOITURE" ]
vars.life_noc_threshold_unknown_lt = "0"
vars.life_noc_threshold_ok_gte = "0"
vars.life_noc_threshold_warning_gte = "150000"
vars.life_noc_threshold_critical_gte = "168000"
vars.life_noc_on_error = "unknown"
notes = "Vérification de la courroie de distribution et pompe à eau"
vars.instructions_url = "/life-noc/item/voiture/verification-timing-belt"
assign where host.name == "life-noc"

View file

@ -67,6 +67,18 @@ def set_item(domain: str, item_key: str, value: str, origin: str = "manual") ->
return data[item_key]
def set_counter_pair(domain: str, item_key: str, current_value: str, reference_value: str, origin: str = "manual") -> dict:
data = load_domain(domain)
data[item_key] = {
"value": current_value,
"reference_value": reference_value,
"captured_at": utc_now_iso(),
"origin": origin,
}
save_domain(domain, data)
return data[item_key]
def complete_item(domain: str, item_key: str, origin: str = "manual") -> dict:
return set_item(domain, item_key, utc_today_date(), origin=origin)
@ -106,30 +118,33 @@ def compute_state_for_item(domain: str, item_key: str) -> str:
source = probe.get("source", {})
thresholds = probe.get("thresholds", {})
metric = probe.get("metric", {})
if str(source.get("type", "")).strip() != "manual_date":
return "CRITICAL"
if str(metric.get("unit", "")).strip() != "days":
return "CRITICAL"
source_type = str(source.get("type", "")).strip()
metric_unit = str(metric.get("unit", "")).strip()
item = get_item(domain, item_key)
value = str(item.get("value", "")).strip()
dt = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
if probe_type == "elapsed_time":
days = (now - dt).total_seconds() / 86400.0
if "critical_gte" in thresholds and days >= float(thresholds["critical_gte"]):
if probe_type in {"elapsed_time", "days_until_due"}:
if source_type != "manual_date":
return "CRITICAL"
if metric_unit != "days":
return "CRITICAL"
value = str(item.get("value", "")).strip()
dt = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
if probe_type == "elapsed_time":
days = (now - dt).total_seconds() / 86400.0
if "critical_gte" in thresholds and days >= float(thresholds["critical_gte"]):
return "CRITICAL"
if "warning_gte" in thresholds and days >= float(thresholds["warning_gte"]):
return "WARNING"
if "ok_gte" in thresholds and days >= float(thresholds["ok_gte"]):
return "OK"
if "unknown_lt" in thresholds and days < float(thresholds["unknown_lt"]):
return "UNKNOWN"
return "CRITICAL"
if "warning_gte" in thresholds and days >= float(thresholds["warning_gte"]):
return "WARNING"
if "ok_gte" in thresholds and days >= float(thresholds["ok_gte"]):
return "OK"
if "unknown_lt" in thresholds and days < float(thresholds["unknown_lt"]):
return "UNKNOWN"
return "CRITICAL"
if probe_type == "days_until_due":
days = (dt - now).total_seconds() / 86400.0
if "critical_lt" in thresholds and days < float(thresholds["critical_lt"]):
return "CRITICAL"
@ -141,4 +156,24 @@ def compute_state_for_item(domain: str, item_key: str) -> str:
return "UNKNOWN"
return "CRITICAL"
if probe_type == "elapsed_distance":
if source_type != "manual_counter":
return "CRITICAL"
if metric_unit != "km":
return "CRITICAL"
current_value = float(str(item.get("value", "")).strip())
reference_value = float(str(item.get("reference_value", "")).strip())
distance = current_value - reference_value
if "unknown_lt" in thresholds and distance < float(thresholds["unknown_lt"]):
return "UNKNOWN"
if "critical_gte" in thresholds and distance >= float(thresholds["critical_gte"]):
return "CRITICAL"
if "warning_gte" in thresholds and distance >= float(thresholds["warning_gte"]):
return "WARNING"
if "ok_gte" in thresholds and distance >= float(thresholds["ok_gte"]):
return "OK"
return "CRITICAL"
return "CRITICAL"

View file

@ -202,26 +202,36 @@ def validate_probe(item: dict) -> None:
raise ValueError("probe doit être un objet")
probe_type = str(probe.get("type", "")).strip()
if probe_type not in {"elapsed_time", "days_until_due"}:
raise ValueError("seuls probe.type=elapsed_time et days_until_due sont supportés")
if probe_type not in {"elapsed_time", "days_until_due", "elapsed_distance"}:
raise ValueError("seuls probe.type=elapsed_time, days_until_due et elapsed_distance sont supportés")
source = probe.get("source")
if not isinstance(source, dict):
raise ValueError("probe.source doit être un objet")
if str(source.get("type", "")).strip() != "manual_date":
raise ValueError("seul probe.source.type=manual_date est supporté en v1")
source_type = str(source.get("type", "")).strip()
has_inline_value = "value" in source
has_store_ref = "inputs_file" in source and "item_key" in source
if not has_inline_value and not has_store_ref:
raise ValueError("probe.source.value ou probe.source.inputs_file + item_key requis")
if probe_type in {"elapsed_time", "days_until_due"}:
if source_type != "manual_date":
raise ValueError("seul probe.source.type=manual_date est supporté pour les sondes de date")
if not has_inline_value and not has_store_ref:
raise ValueError("probe.source.value ou probe.source.inputs_file + item_key requis")
elif probe_type == "elapsed_distance":
if source_type != "manual_counter":
raise ValueError("seul probe.source.type=manual_counter est supporté pour elapsed_distance")
if not has_store_ref:
raise ValueError("probe.source.inputs_file + item_key requis pour elapsed_distance")
metric = probe.get("metric")
if not isinstance(metric, dict):
raise ValueError("probe.metric doit être un objet")
if str(metric.get("unit", "")).strip() != "days":
raise ValueError("seul probe.metric.unit=days est supporté en v1")
metric_unit = str(metric.get("unit", "")).strip()
if probe_type in {"elapsed_time", "days_until_due"} and metric_unit != "days":
raise ValueError("seul probe.metric.unit=days est supporté pour les sondes de date")
if probe_type == "elapsed_distance" and metric_unit != "km":
raise ValueError("seul probe.metric.unit=km est supporté pour elapsed_distance")
thresholds = probe.get("thresholds")
if not isinstance(thresholds, dict):

View file

@ -37,6 +37,7 @@ from lib.life_noc_inputs import ( # noqa: E402
list_items,
get_item,
set_item,
set_counter_pair,
complete_item,
get_defined_service,
compute_state_for_item,
@ -52,6 +53,12 @@ class CompleteInputRequest(BaseModel):
origin: str = "manual"
class CounterPairInputRequest(BaseModel):
current_value: str
reference_value: str
origin: str = "manual"
app = FastAPI(title="Life-NOC Input API", version="1.1.0")
@ -537,16 +544,34 @@ def api_complete_input(domain: str, item_key: str, req: CompleteInputRequest | N
return {"domain": domain, "item_key": item_key, **item}
@app.post("/inputs/{domain}/{item_key}/counter")
def api_set_counter_pair(domain: str, item_key: str, req: CounterPairInputRequest) -> dict:
item = set_counter_pair(
domain,
item_key,
current_value=req.current_value,
reference_value=req.reference_value,
origin=req.origin or "manual",
)
return {"domain": domain, "item_key": item_key, **item}
@app.get("/life-noc/item/{domain}/{item_key}", response_class=HTMLResponse)
def page_item(domain: str, item_key: str) -> HTMLResponse:
try:
service = get_defined_service(domain, item_key)
current_input = get_item(domain, item_key)
state = compute_state_for_item(domain, item_key)
except KeyError:
raise HTTPException(status_code=404, detail="item introuvable")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
try:
current_input = get_item(domain, item_key)
except KeyError:
current_input = {}
try:
state = compute_state_for_item(domain, item_key)
except (KeyError, ValueError):
state = "UNKNOWN"
title = service.get("title") or service.get("display_name") or item_key.replace("-", " ").capitalize()
summary = service.get("summary", "")
@ -601,6 +626,25 @@ def page_item(domain: str, item_key: str) -> HTMLResponse:
</form>
</section>
"""
elif allow_manual_edit and form_mode == "counter_pair":
manual_form_html = f"""
<section class="card">
<h2>Mettre à jour les compteurs</h2>
<p class="muted">Saisir le compteur courant et le compteur au dernier entretien.</p>
<form method="post" action="/life-noc/item/{html_escape(domain)}/{html_escape(item_key)}/counter" class="form-grid two">
<label>Compteur courant
<input type="text" inputmode="decimal" name="current_value" value="{html_escape(current_input.get('value', ''))}" required>
</label>
<label>Compteur au dernier entretien
<input type="text" inputmode="decimal" name="reference_value" value="{html_escape(current_input.get('reference_value', ''))}" required>
</label>
<input type="hidden" name="origin" value="manual">
<div class="actions">
<button type="submit">Enregistrer</button>
</div>
</form>
</section>
"""
complete_form_html = ""
if allow_complete:
@ -632,6 +676,7 @@ def page_item(domain: str, item_key: str) -> HTMLResponse:
<section class="card">
<h2>Dernier intrant</h2>
<p><strong>Valeur :</strong> {html_escape(current_input.get('value', ''))}</p>
{f'<p><strong>Valeur de référence :</strong> {html_escape(current_input.get("reference_value", ""))}</p>' if 'reference_value' in current_input else ''}
<p><strong>Capturé le :</strong> {html_escape(current_input.get('captured_at', ''))}</p>
<p><strong>Origine :</strong> {html_escape(current_input.get('origin', ''))}</p>
</section>
@ -668,6 +713,18 @@ def page_set_item(domain: str, item_key: str, value: str = Form(...), origin: st
return RedirectResponse(url=f"/life-noc/item/{domain}/{item_key}", status_code=303)
@app.post("/life-noc/item/{domain}/{item_key}/counter")
def page_set_counter(domain: str, item_key: str, current_value: str = Form(...), reference_value: str = Form(...), origin: str = Form(default="manual")) -> RedirectResponse:
try:
set_counter_pair(domain, item_key, current_value=current_value, reference_value=reference_value, origin=origin)
except KeyError:
raise HTTPException(status_code=404, detail="item introuvable")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/life-noc/item/{domain}/{item_key}", status_code=303)
@app.post("/life-noc/item/{domain}/{item_key}/complete")
def page_complete_item(domain: str, item_key: str, origin: str = Form(default="manual")) -> RedirectResponse:
try: