2026-03-14 19:21:54 -04:00
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
2026-03-16 18:05:17 -04:00
from urllib . parse import quote
import os
2026-03-14 19:21:54 -04:00
import sys
2026-03-15 14:43:50 -04:00
from fastapi import FastAPI , HTTPException , Form
from fastapi . responses import HTMLResponse , RedirectResponse
2026-03-14 19:21:54 -04:00
from pydantic import BaseModel
ROOT = Path ( __file__ ) . resolve ( ) . parents [ 1 ]
if str ( ROOT ) not in sys . path :
sys . path . insert ( 0 , str ( ROOT ) )
2026-03-16 18:05:17 -04:00
from lib . life_noc_domains import ( # noqa: E402
ALLOWED_FORM_MODES ,
ALLOWED_METRIC_UNITS ,
ALLOWED_ON_ERROR ,
ALLOWED_PROBE_TYPES ,
ALLOWED_SOURCE_TYPES ,
THRESHOLD_FIELDS ,
DomainsConflictError ,
DomainsError ,
delete_item ,
get_domain_item ,
item_to_form_values ,
list_domain_items ,
list_domains ,
normalize_item_payload ,
reorder_item ,
upsert_item ,
)
2026-03-15 14:43:50 -04:00
from lib . life_noc_inputs import ( # noqa: E402
list_items ,
get_item ,
set_item ,
2026-03-16 22:14:54 -04:00
set_counter_pair ,
2026-03-15 14:43:50 -04:00
complete_item ,
get_defined_service ,
compute_state_for_item ,
)
2026-03-14 19:21:54 -04:00
class SetInputRequest ( BaseModel ) :
value : str
origin : str = " manual "
class CompleteInputRequest ( BaseModel ) :
origin : str = " manual "
2026-03-16 22:14:54 -04:00
class CounterPairInputRequest ( BaseModel ) :
current_value : str
reference_value : str
origin : str = " manual "
2026-03-16 18:05:17 -04:00
app = FastAPI ( title = " Life-NOC Input API " , version = " 1.1.0 " )
ADMIN_UI_ENABLED = os . environ . get ( " LIFE_NOC_ADMIN_UI_ENABLED " , " 0 " ) . strip ( ) . lower ( ) in {
" 1 " ,
" true " ,
" yes " ,
" on " ,
}
BASE_CSS = """
: root {
color - scheme : light ;
- - bg : #f5f7fb;
- - card : #ffffff;
- - border : #d9e0ea;
- - text : #112033;
- - muted : #5a697b;
- - accent : #164c96;
- - accent - soft : #eaf2ff;
- - danger : #8b1e1e;
- - ok : #0f6b3d;
}
* { box - sizing : border - box ; }
body {
margin : 0 ;
background : var ( - - bg ) ;
color : var ( - - text ) ;
font - family : Arial , sans - serif ;
line - height : 1.45 ;
}
main {
max - width : 920 px ;
margin : 0 auto ;
padding : 1 rem ;
}
. stack { display : grid ; gap : 1 rem ; }
. card {
background : var ( - - card ) ;
border : 1 px solid var ( - - border ) ;
border - radius : 16 px ;
padding : 1 rem ;
box - shadow : 0 1 px 2 px rgba ( 0 , 0 , 0 , 0.03 ) ;
}
. hero { padding : 1 rem ; }
. muted { color : var ( - - muted ) ; }
. state { font - size : 1.15 rem ; font - weight : bold ; }
h1 , h2 , h3 { margin - top : 0 ; }
ul { padding - left : 1.2 rem ; }
code {
background : #eef2f7;
padding : 0.15 rem 0.35 rem ;
border - radius : 6 px ;
}
a { color : var ( - - accent ) ; text - decoration : none ; }
a : hover { text - decoration : underline ; }
. toolbar , . actions {
display : flex ;
flex - wrap : wrap ;
gap : 0.6 rem ;
align - items : center ;
}
. actions form , . inline - form { display : inline - flex ; margin : 0 ; }
. pill {
display : inline - block ;
padding : 0.25 rem 0.55 rem ;
border - radius : 999 px ;
background : var ( - - accent - soft ) ;
color : var ( - - accent ) ;
font - size : 0.9 rem ;
}
. button , button {
display : inline - flex ;
align - items : center ;
justify - content : center ;
gap : 0.35 rem ;
min - height : 44 px ;
padding : 0.75 rem 1 rem ;
border - radius : 12 px ;
border : 1 px solid #335b94;
background : #ffffff;
color : #163454;
font - size : 1 rem ;
cursor : pointer ;
text - decoration : none ;
}
. button . primary , button . primary {
background : var ( - - accent ) ;
color : #fff;
border - color : var ( - - accent ) ;
}
. button . danger , button . danger {
border - color : #b64b4b;
color : var ( - - danger ) ;
}
. grid - cards {
display : grid ;
gap : 0.9 rem ;
}
. row - between {
display : flex ;
justify - content : space - between ;
align - items : center ;
gap : 1 rem ;
}
. item - title {
font - size : 1.05 rem ;
font - weight : bold ;
margin : 0 0 0.35 rem 0 ;
}
. form - grid {
display : grid ;
gap : 0.85 rem ;
}
. form - grid . two { grid - template - columns : 1 fr ; }
label {
display : grid ;
gap : 0.35 rem ;
font - size : 0.95 rem ;
font - weight : bold ;
}
input [ type = " text " ] , input [ type = " date " ] , textarea , select {
width : 100 % ;
min - height : 44 px ;
padding : 0.75 rem 0.8 rem ;
border - radius : 12 px ;
border : 1 px solid #aebacc;
font : inherit ;
background : #fff;
color : var ( - - text ) ;
}
textarea { min - height : 100 px ; resize : vertical ; }
. checks {
display : grid ;
gap : 0.55 rem ;
}
. check {
display : flex ;
align - items : center ;
gap : 0.6 rem ;
font - weight : normal ;
}
. check input { width : 20 px ; height : 20 px ; }
. section - title {
margin - bottom : 0.5 rem ;
font - size : 1.02 rem ;
}
details {
border : 1 px solid var ( - - border ) ;
border - radius : 14 px ;
background : #fcfdff;
padding : 0.15 rem 0.85 rem 0.85 rem 0.85 rem ;
}
details + details { margin - top : 0.8 rem ; }
summary {
cursor : pointer ;
font - weight : bold ;
list - style : none ;
padding : 0.9 rem 0 0.6 rem 0 ;
}
summary : : - webkit - details - marker { display : none ; }
. alert {
border : 1 px solid #f2c5c5;
background : #fff4f4;
color : #7a1a1a;
border - radius : 12 px ;
padding : 0.85 rem 1 rem ;
}
pre . yaml - preview {
white - space : pre - wrap ;
word - break : break - word ;
background : #0f1720;
color : #e8eef6;
padding : 1 rem ;
border - radius : 14 px ;
overflow - x : auto ;
}
. mobile - sticky {
position : sticky ;
bottom : 0 ;
background : rgba ( 245 , 247 , 251 , 0.96 ) ;
padding - top : 0.7 rem ;
}
@media ( min - width : 760 px ) {
. form - grid . two { grid - template - columns : 1 fr 1 fr ; }
. grid - cards { grid - template - columns : repeat ( auto - fit , minmax ( 260 px , 1 fr ) ) ; }
}
"""
2026-03-14 19:21:54 -04:00
2026-03-16 18:05:17 -04:00
def html_escape ( value : object ) - > str :
2026-03-15 14:43:50 -04:00
return (
str ( value )
. replace ( " & " , " & " )
. replace ( " < " , " < " )
. replace ( " > " , " > " )
. replace ( ' " ' , " " " )
)
def link_row ( label : str , url : str ) - > str :
if not url :
return " "
u = html_escape ( url )
l = html_escape ( label )
return f ' <li><a href= " { u } " target= " _blank " rel= " noopener " > { l } </a></li> '
def resolve_instructions_url ( domain : str , item_key : str , service : dict ) - > str :
explicit = str ( service . get ( " instructions_url " , " " ) ) . strip ( )
if explicit :
return explicit
return f " /life-noc/item/ { domain } / { item_key } "
2026-03-16 18:05:17 -04:00
def page_shell ( title : str , body : str ) - > HTMLResponse :
html = f """ <!DOCTYPE html>
< html lang = " fr " >
< head >
< meta charset = " utf-8 " >
< meta name = " viewport " content = " width=device-width, initial-scale=1 " >
< title > { html_escape ( title ) } < / title >
< style > { BASE_CSS } < / style >
< / head >
< body >
< main > { body } < / main >
< / body >
< / html > """
return HTMLResponse ( content = html )
def require_admin_enabled ( ) - > None :
if not ADMIN_UI_ENABLED :
raise HTTPException ( status_code = 404 , detail = " interface admin désactivée " )
def bool_attr ( value : bool ) - > str :
return " checked " if value else " "
def option_tags ( options : list [ str ] , selected : str ) - > str :
tags = [ ]
for value in options :
label = value if value else " — "
sel = " selected " if value == selected else " "
tags . append ( f ' <option value= " { html_escape ( value ) } " { sel } > { html_escape ( label ) } </option> ' )
return " " . join ( tags )
def render_alert ( message : str ) - > str :
if not message :
return " "
return f ' <div class= " alert " > { html_escape ( message ) } </div> '
def render_domain_card ( domain : str , items : list [ dict ] ) - > str :
probed = sum ( 1 for item in items if isinstance ( item , dict ) and isinstance ( item . get ( " probe " ) , dict ) )
return f """
< article class = " card " >
< div class = " row-between " >
< div >
< p class = " item-title " > { html_escape ( domain ) } < / p >
< p class = " muted " > { len ( items ) } item ( s ) · { probed } avec sonde réelle < / p >
< / div >
< a class = " button primary " href = " /life-noc/admin/domain/ { html_escape(domain)} " > Ouvrir < / a >
< / div >
< / article >
"""
def render_item_card ( domain : str , item : dict , index : int , total : int ) - > str :
name = str ( item . get ( " name " , " " ) ) . strip ( )
title = str ( item . get ( " title " ) or name or " Item " ) . strip ( )
summary = str ( item . get ( " summary " ) or item . get ( " notes " ) or " " ) . strip ( )
probe_type = str ( item . get ( " probe " , { } ) . get ( " type " , " mock " ) if isinstance ( item . get ( " probe " ) , dict ) else " mock " )
probe_badge = f ' <span class= " pill " > { html_escape ( probe_type ) } </span> '
move_up = f """
< form method = " post " action = " /life-noc/admin/domain/ { html_escape(domain)}/ { html_escape(name)}/move " >
< input type = " hidden " name = " direction " value = " up " >
< button title = " Monter " { ' disabled ' if index == 0 else ' ' } > ↑ < / button >
< / form >
""" if total > 1 else " "
move_down = f """
< form method = " post " action = " /life-noc/admin/domain/ { html_escape(domain)}/ { html_escape(name)}/move " >
< input type = " hidden " name = " direction " value = " down " >
< button title = " Descendre " { ' disabled ' if index == total - 1 else ' ' } > ↓ < / button >
< / form >
""" if total > 1 else " "
return f """
< article class = " card " >
< div class = " row-between " >
< div >
< p class = " item-title " > { html_escape ( title ) } < / p >
< p class = " muted " > < code > { html_escape ( name ) } < / code > < / p >
< p class = " muted " > { html_escape ( summary ) } < / p >
< / div >
< div > { probe_badge } < / div >
< / div >
< div class = " actions " >
< a class = " button " href = " /life-noc/admin/item/ { html_escape(domain)}/ { html_escape(name)} " > Voir < / a >
< a class = " button primary " href = " /life-noc/admin/item/ { html_escape(domain)}/ { html_escape(name)}/edit " > Modifier < / a >
{ move_up }
{ move_down }
< / div >
< / article >
"""
def render_item_editor ( domain : str , values : dict , action_url : str , heading : str , error_message : str = " " ) - > str :
thresholds_html = " " . join (
f ''' <label> { html_escape ( field ) } <input type= " text " name= " { html_escape ( field ) } " value= " { html_escape ( values . get ( field , ' ' ) ) } " ></label> '''
for field in THRESHOLD_FIELDS
)
yaml_preview = render_yaml_preview ( values )
return f """
< section class = " stack " >
< div class = " hero card " >
< div class = " toolbar " >
< a class = " button " href = " /life-noc/admin/domain/ { html_escape(domain)} " > ← Domaine < / a >
< a class = " button " href = " /life-noc/admin " > Accueil admin < / a >
< / div >
< h1 > { html_escape ( heading ) } < / h1 >
< p class = " muted " > Éditeur mobile - first optionnel de < code > domains . yaml < / code > . Sauvegarde explicite , sans changer le flux runtime existant . < / p >
< / div >
{ render_alert ( error_message ) }
< form method = " post " action = " { html_escape(action_url)} " class = " stack " >
< details open >
< summary > Identité < / summary >
< div class = " form-grid two " >
< label > Domaine
< input type = " text " name = " domain " value = " { html_escape(domain)} " readonly >
< / label >
< label > name
< input type = " text " name = " name " value = " { html_escape(values.get( ' name ' , ' ' ))} " required >
< / label >
< label > Date de référence
< input type = " date " name = " date " value = " { html_escape(values.get( ' date ' , ' ' ))} " >
< / label >
< label > title
< input type = " text " name = " title " value = " { html_escape(values.get( ' title ' , ' ' ))} " >
< / label >
< / div >
< div class = " form-grid " >
< label > summary
< textarea name = " summary " > { html_escape ( values . get ( ' summary ' , ' ' ) ) } < / textarea >
< / label >
< / div >
< / details >
< details >
< summary > Contexte et contenu < / summary >
< div class = " form-grid " >
< label > notes
< textarea name = " notes " > { html_escape ( values . get ( ' notes ' , ' ' ) ) } < / textarea >
< / label >
< label > instructions
< textarea name = " instructions " > { html_escape ( values . get ( ' instructions ' , ' ' ) ) } < / textarea >
< / label >
< / div >
< / details >
< details >
< summary > Liens < / summary >
< div class = " form-grid " >
< label > notes_url < input type = " text " name = " notes_url " value = " { html_escape(values.get( ' notes_url ' , ' ' ))} " > < / label >
< label > instructions_url < input type = " text " name = " instructions_url " value = " { html_escape(values.get( ' instructions_url ' , ' ' ))} " > < / label >
< label > action_url < input type = " text " name = " action_url " value = " { html_escape(values.get( ' action_url ' , ' ' ))} " > < / label >
< / div >
< / details >
< details >
< summary > Sonde < / summary >
< div class = " form-grid two " >
< label > probe . type
< select name = " probe_type " > { option_tags ( ALLOWED_PROBE_TYPES , str ( values . get ( ' probe_type ' , ' ' ) ) ) } < / select >
< / label >
< label > probe . source . type
< select name = " probe_source_type " > { option_tags ( ALLOWED_SOURCE_TYPES , str ( values . get ( ' probe_source_type ' , ' ' ) ) ) } < / select >
< / label >
< label > probe . source . inputs_file
< input type = " text " name = " probe_inputs_file " value = " { html_escape(values.get( ' probe_inputs_file ' , ' ' ))} " >
< / label >
< label > probe . source . item_key
< input type = " text " name = " probe_item_key " value = " { html_escape(values.get( ' probe_item_key ' , ' ' ))} " >
< / label >
< label > metric . unit
< select name = " metric_unit " > { option_tags ( ALLOWED_METRIC_UNITS , str ( values . get ( ' metric_unit ' , ' ' ) ) ) } < / select >
< / label >
< label > policy . on_error
< select name = " policy_on_error " > { option_tags ( ALLOWED_ON_ERROR , str ( values . get ( ' policy_on_error ' , ' ' ) ) ) } < / select >
< / label >
< / div >
< / details >
< details >
< summary > Seuils < / summary >
< div class = " form-grid two " >
{ thresholds_html }
< / div >
< / details >
< details >
< summary > Comportement UI < / summary >
< div class = " form-grid two " >
< label > ui . form_mode
< select name = " ui_form_mode " > { option_tags ( ALLOWED_FORM_MODES , str ( values . get ( ' ui_form_mode ' , ' ' ) ) ) } < / select >
< / label >
< / div >
< div class = " checks " >
< label class = " check " > < input type = " checkbox " name = " ui_allow_complete " value = " 1 " { bool_attr ( bool ( values . get ( ' ui_allow_complete ' , False ) ) ) } > ui . allow_complete < / label >
< label class = " check " > < input type = " checkbox " name = " ui_allow_manual_edit " value = " 1 " { bool_attr ( bool ( values . get ( ' ui_allow_manual_edit ' , False ) ) ) } > ui . allow_manual_edit < / label >
< / div >
< / details >
< details >
< summary > Aperçu YAML < / summary >
< pre class = " yaml-preview " > { html_escape ( yaml_preview ) } < / pre >
< / details >
< div class = " mobile-sticky " >
< div class = " card " >
< div class = " actions " >
< button class = " primary " type = " submit " > Écrire dans domains . yaml < / button >
< a class = " button " href = " /life-noc/admin/domain/ { html_escape(domain)} " > Annuler < / a >
< / div >
< / div >
< / div >
< / form >
< / section >
"""
def render_yaml_preview ( values : dict ) - > str :
try :
payload = normalize_item_payload ( values )
except Exception :
payload = { k : v for k , v in values . items ( ) if k not in { " domain " } }
import yaml
return yaml . safe_dump ( payload , sort_keys = False , allow_unicode = True , width = 1000 )
2026-03-14 19:21:54 -04:00
@app.get ( " /health " )
def health ( ) - > dict :
2026-03-16 18:05:17 -04:00
return { " status " : " ok " , " admin_ui_enabled " : ADMIN_UI_ENABLED }
2026-03-14 19:21:54 -04:00
@app.get ( " /inputs/ {domain} " )
def api_list_inputs ( domain : str ) - > dict :
return { " domain " : domain , " items " : list_items ( domain ) }
@app.get ( " /inputs/ {domain} / {item_key} " )
def api_get_input ( domain : str , item_key : str ) - > dict :
try :
item = get_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 ) )
return { " domain " : domain , " item_key " : item_key , * * item }
@app.post ( " /inputs/ {domain} / {item_key} " )
def api_set_input ( domain : str , item_key : str , req : SetInputRequest ) - > dict :
item = set_item ( domain , item_key , req . value , origin = req . origin )
return { " domain " : domain , " item_key " : item_key , * * item }
@app.post ( " /inputs/ {domain} / {item_key} /complete " )
def api_complete_input ( domain : str , item_key : str , req : CompleteInputRequest | None = None ) - > dict :
origin = " manual " if req is None else req . origin
item = complete_item ( domain , item_key , origin = origin )
return { " domain " : domain , " item_key " : item_key , * * item }
2026-03-15 14:43:50 -04:00
2026-03-16 22:14:54 -04:00
@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 }
2026-03-15 14:43:50 -04:00
@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 )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " item introuvable " )
2026-03-16 22:14:54 -04:00
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 "
2026-03-15 14:43:50 -04:00
title = service . get ( " title " ) or service . get ( " display_name " ) or item_key . replace ( " - " , " " ) . capitalize ( )
summary = service . get ( " summary " , " " )
notes = service . get ( " notes " , " " )
instructions = service . get ( " instructions " , " " )
notes_url = service . get ( " notes_url " , " " )
instructions_url = resolve_instructions_url ( domain , item_key , service )
action_url = service . get ( " action_url " , " " )
probe_type = service . get ( " probe " , { } ) . get ( " type " , " unknown " )
ui = service . get ( " ui " , { } ) if isinstance ( service . get ( " ui " , { } ) , dict ) else { }
form_mode = ui . get ( " form_mode " , " " )
allow_complete = bool ( ui . get ( " allow_complete " , probe_type == " elapsed_time " ) )
allow_manual_edit = bool ( ui . get ( " allow_manual_edit " , True ) )
2026-03-16 18:05:17 -04:00
links_html = " " . join (
fragment for fragment in [
link_row ( " Notes " , notes_url ) ,
link_row ( " Instructions " , instructions_url ) ,
link_row ( " Action " , action_url ) ,
] if fragment
) or " <li class= \" muted \" >Aucun lien défini.</li> "
2026-03-15 14:43:50 -04:00
2026-03-16 18:05:17 -04:00
summary_html = f ' <p><strong>Résumé :</strong> { html_escape ( summary ) } </p> ' if summary else " "
2026-03-15 14:43:50 -04:00
instructions_html = " "
if instructions :
instructions_html = f """
2026-03-16 18:05:17 -04:00
< section class = " card " >
< h2 > Instructions < / h2 >
< pre style = " white-space: pre-wrap; font-family: Arial, sans-serif; " > { html_escape ( instructions ) } < / pre >
< / section >
"""
2026-03-15 14:43:50 -04:00
manual_form_html = " "
2026-03-16 20:32:11 -04:00
if allow_manual_edit and form_mode in { " complete_date " , " due_date " } :
form_title = " Mettre à jour la date " if form_mode == " complete_date " else " Définir l’ échéance "
field_label = " Date " if form_mode == " complete_date " else " Date d’ échéance "
form_hint = " "
if form_mode == " due_date " :
form_hint = ' <p class= " muted " >Saisir la date cible de cette échéance.</p> '
2026-03-15 14:43:50 -04:00
manual_form_html = f """
2026-03-16 18:05:17 -04:00
< section class = " card " >
2026-03-16 20:32:11 -04:00
< h2 > { html_escape ( form_title ) } < / h2 >
{ form_hint }
< form method = " post " action = " /life-noc/item/ { html_escape(domain)}/ { html_escape(item_key)}/set " class = " form-grid " >
< label > { html_escape ( field_label ) }
< input type = " date " name = " value " value = " { html_escape(current_input.get( ' value ' , ' ' ))} " required >
< / label >
2026-03-16 18:05:17 -04:00
< input type = " hidden " name = " origin " value = " manual " >
2026-03-16 20:32:11 -04:00
< div class = " actions " >
< button type = " submit " > Enregistrer < / button >
< / div >
2026-03-16 18:05:17 -04:00
< / form >
< / section >
"""
2026-03-16 22:14:54 -04:00
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 >
"""
2026-03-15 14:43:50 -04:00
complete_form_html = " "
if allow_complete :
complete_form_html = f """
2026-03-16 18:05:17 -04:00
< section class = " card " >
< h2 > Action rapide < / h2 >
< form method = " post " action = " /life-noc/item/ { html_escape(domain)}/ { html_escape(item_key)}/complete " >
< button type = " submit " class = " primary " > Compléter < / button >
< / form >
< / section >
"""
admin_link = " "
if ADMIN_UI_ENABLED :
admin_link = f ' <a class= " button " href= " /life-noc/admin/item/ { html_escape ( domain ) } / { html_escape ( item_key ) } " >Admin</a> '
body = f """
< section class = " hero card " >
< div class = " toolbar " >
< a class = " button " href = " /life-noc/item/ { html_escape(domain)}/ { html_escape(item_key)} " > Actualiser < / a >
{ admin_link }
< / div >
< h1 > { html_escape ( title ) } < / h1 >
< p class = " state " > État actuel : { html_escape ( state ) } < / p >
< p > < strong > Type de sonde : < / strong > { html_escape ( probe_type ) } < / p >
{ summary_html }
< / section >
< section class = " card " >
< h2 > Dernier intrant < / h2 >
< p > < strong > Valeur : < / strong > { html_escape ( current_input . get ( ' value ' , ' ' ) ) } < / p >
2026-03-16 22:14:54 -04:00
{ f ' <p><strong>Valeur de référence :</strong> { html_escape ( current_input . get ( " reference_value " , " " ) ) } </p> ' if ' reference_value ' in current_input else ' ' }
2026-03-16 18:05:17 -04:00
< 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 >
< section class = " card " >
< h2 > Contexte < / h2 >
< p > < strong > Domaine : < / strong > { html_escape ( domain ) } < / p >
< p > < strong > Item key : < / strong > < code > { html_escape ( item_key ) } < / code > < / p >
< p > < strong > Notes : < / strong > { html_escape ( notes ) } < / p >
< / section >
{ instructions_html }
< section class = " card " >
< h2 > Liens utiles < / h2 >
< ul > { links_html } < / ul >
< / section >
{ manual_form_html }
{ complete_form_html }
"""
return page_shell ( f " Life-NOC — { title } " , body )
2026-03-15 14:43:50 -04:00
@app.post ( " /life-noc/item/ {domain} / {item_key} /set " )
def page_set_item ( domain : str , item_key : str , value : str = Form ( . . . ) , origin : str = Form ( default = " manual " ) ) - > RedirectResponse :
try :
set_item ( domain , item_key , value = 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 ) )
2026-03-16 18:05:17 -04:00
return RedirectResponse ( url = f " /life-noc/item/ { domain } / { item_key } " , status_code = 303 )
2026-03-15 14:43:50 -04:00
2026-03-16 22:14:54 -04:00
@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 )
2026-03-15 14:43:50 -04:00
@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 :
complete_item ( domain , item_key , origin = origin )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " item introuvable " )
except ValueError as exc :
raise HTTPException ( status_code = 400 , detail = str ( exc ) )
2026-03-16 18:05:17 -04:00
return RedirectResponse ( url = f " /life-noc/item/ { domain } / { item_key } " , status_code = 303 )
@app.get ( " /life-noc/admin " , response_class = HTMLResponse )
def admin_home ( ) - > HTMLResponse :
require_admin_enabled ( )
domain_cards = [ ]
for domain in list_domains ( ) :
items = list_domain_items ( domain )
domain_cards . append ( render_domain_card ( domain , items ) )
body = f """
< section class = " hero card " >
< h1 > Life - NOC Admin < / h1 >
< p class = " muted " > Interface web optionnelle pour administrer < code > domains . yaml < / code > sans casser le pipeline existant . < / p >
< / section >
< section class = " grid-cards " > { ' ' . join ( domain_cards ) } < / section >
"""
return page_shell ( " Life-NOC Admin " , body )
@app.get ( " /life-noc/admin/domain/ {domain} " , response_class = HTMLResponse )
def admin_domain ( domain : str , error : str = " " ) - > HTMLResponse :
require_admin_enabled ( )
try :
items = list_domain_items ( domain )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " domaine introuvable " )
cards = [ render_item_card ( domain , item , idx , len ( items ) ) for idx , item in enumerate ( items ) ]
body = f """
< section class = " hero card " >
< div class = " toolbar " >
< a class = " button " href = " /life-noc/admin " > ← Accueil admin < / a >
< a class = " button primary " href = " /life-noc/admin/domain/ { html_escape(domain)}/new " > Créer un item < / a >
< / div >
< h1 > Domaine : { html_escape ( domain ) } < / h1 >
< p class = " muted " > { len ( items ) } item ( s ) . Les boutons ↑ et ↓ réordonnent l ’ ordre des items dans < code > domains . yaml < / code > . < / p >
< / section >
{ render_alert ( error ) }
< section class = " stack " > { ' ' . join ( cards ) } < / section >
"""
return page_shell ( f " Life-NOC Admin — { domain } " , body )
@app.post ( " /life-noc/admin/domain/ {domain} / {item_key} /move " )
def admin_move_item ( domain : str , item_key : str , direction : str = Form ( . . . ) ) - > RedirectResponse :
require_admin_enabled ( )
try :
reorder_item ( domain , item_key , direction )
except ( DomainsError , KeyError ) as exc :
target = f " /life-noc/admin/domain/ { domain } ?error= { quote ( str ( exc ) ) } "
return RedirectResponse ( url = target , status_code = 303 )
return RedirectResponse ( url = f " /life-noc/admin/domain/ { domain } " , status_code = 303 )
@app.get ( " /life-noc/admin/item/ {domain} / {item_key} " , response_class = HTMLResponse )
def admin_item ( domain : str , item_key : str ) - > HTMLResponse :
require_admin_enabled ( )
try :
item = get_domain_item ( domain , item_key )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " item introuvable " )
values = item_to_form_values ( item , domain )
yaml_preview = render_yaml_preview ( values )
body = f """
< section class = " hero card " >
< div class = " toolbar " >
< a class = " button " href = " /life-noc/admin/domain/ { html_escape(domain)} " > ← Domaine < / a >
< a class = " button primary " href = " /life-noc/admin/item/ { html_escape(domain)}/ { html_escape(item_key)}/edit " > Modifier < / a >
< a class = " button " href = " /life-noc/item/ { html_escape(domain)}/ { html_escape(item_key)} " > Page item < / a >
< / div >
< h1 > { html_escape ( values . get ( ' title ' ) or item_key ) } < / h1 >
< p class = " muted " > < code > { html_escape ( item_key ) } < / code > < / p >
< / section >
< section class = " card " >
< h2 > Résumé < / h2 >
< p > { html_escape ( values . get ( ' summary ' ) or values . get ( ' notes ' ) or ' ' ) } < / p >
< p class = " muted " > probe . type = { html_escape ( values . get ( ' probe_type ' ) or ' mock ' ) } < / p >
< / section >
< section class = " card " >
< h2 > Aperçu YAML < / h2 >
< pre class = " yaml-preview " > { html_escape ( yaml_preview ) } < / pre >
< / section >
< section class = " card " >
< form method = " post " action = " /life-noc/admin/item/ { html_escape(domain)}/ { html_escape(item_key)}/delete " onsubmit = " return confirm( ' Supprimer cet item de domains.yaml ? ' ); " >
< button class = " danger " type = " submit " > Supprimer l ’ item < / button >
< / form >
< / section >
"""
return page_shell ( f " Life-NOC Admin — { item_key } " , body )
@app.get ( " /life-noc/admin/domain/ {domain} /new " , response_class = HTMLResponse )
def admin_new_item_form ( domain : str , error : str = " " ) - > HTMLResponse :
require_admin_enabled ( )
try :
list_domain_items ( domain )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " domaine introuvable " )
values = item_to_form_values ( None , domain )
body = render_item_editor ( domain , values , f " /life-noc/admin/domain/ { domain } /new " , f " Créer un item dans { domain } " , error )
return page_shell ( f " Life-NOC Admin — nouveau item { domain } " , body )
@app.post ( " /life-noc/admin/domain/ {domain} /new " )
def admin_new_item_submit (
domain : str ,
name : str = Form ( . . . ) ,
date : str = Form ( default = " " ) ,
title : str = Form ( default = " " ) ,
summary : str = Form ( default = " " ) ,
notes : str = Form ( default = " " ) ,
instructions : str = Form ( default = " " ) ,
notes_url : str = Form ( default = " " ) ,
instructions_url : str = Form ( default = " " ) ,
action_url : str = Form ( default = " " ) ,
probe_type : str = Form ( default = " " ) ,
probe_source_type : str = Form ( default = " " ) ,
probe_inputs_file : str = Form ( default = " " ) ,
probe_item_key : str = Form ( default = " " ) ,
metric_unit : str = Form ( default = " " ) ,
policy_on_error : str = Form ( default = " " ) ,
unknown_lt : str = Form ( default = " " ) ,
ok_gte : str = Form ( default = " " ) ,
warning_gte : str = Form ( default = " " ) ,
critical_gte : str = Form ( default = " " ) ,
unknown_gte : str = Form ( default = " " ) ,
ok_lt : str = Form ( default = " " ) ,
warning_lt : str = Form ( default = " " ) ,
critical_lt : str = Form ( default = " " ) ,
ui_form_mode : str = Form ( default = " " ) ,
ui_allow_complete : str = Form ( default = " " ) ,
ui_allow_manual_edit : str = Form ( default = " " ) ,
) - > RedirectResponse :
require_admin_enabled ( )
form_values = {
key : value
for key , value in locals ( ) . items ( )
if key not in { " domain " }
}
try :
item = normalize_item_payload ( form_values )
upsert_item ( domain , item , original_item_key = None )
except ( DomainsError , DomainsConflictError , KeyError ) as exc :
return RedirectResponse ( url = f " /life-noc/admin/domain/ { domain } /new?error= { quote ( str ( exc ) ) } " , status_code = 303 )
return RedirectResponse ( url = f " /life-noc/admin/item/ { domain } / { item [ ' name ' ] } " , status_code = 303 )
@app.get ( " /life-noc/admin/item/ {domain} / {item_key} /edit " , response_class = HTMLResponse )
def admin_edit_item_form ( domain : str , item_key : str , error : str = " " ) - > HTMLResponse :
require_admin_enabled ( )
try :
item = get_domain_item ( domain , item_key )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " item introuvable " )
values = item_to_form_values ( item , domain )
body = render_item_editor ( domain , values , f " /life-noc/admin/item/ { domain } / { item_key } /edit " , f " Modifier { item_key } " , error )
return page_shell ( f " Life-NOC Admin — modifier { item_key } " , body )
@app.post ( " /life-noc/admin/item/ {domain} / {item_key} /edit " )
def admin_edit_item_submit (
domain : str ,
item_key : str ,
name : str = Form ( . . . ) ,
date : str = Form ( default = " " ) ,
title : str = Form ( default = " " ) ,
summary : str = Form ( default = " " ) ,
notes : str = Form ( default = " " ) ,
instructions : str = Form ( default = " " ) ,
notes_url : str = Form ( default = " " ) ,
instructions_url : str = Form ( default = " " ) ,
action_url : str = Form ( default = " " ) ,
probe_type : str = Form ( default = " " ) ,
probe_source_type : str = Form ( default = " " ) ,
probe_inputs_file : str = Form ( default = " " ) ,
probe_item_key : str = Form ( default = " " ) ,
metric_unit : str = Form ( default = " " ) ,
policy_on_error : str = Form ( default = " " ) ,
unknown_lt : str = Form ( default = " " ) ,
ok_gte : str = Form ( default = " " ) ,
warning_gte : str = Form ( default = " " ) ,
critical_gte : str = Form ( default = " " ) ,
unknown_gte : str = Form ( default = " " ) ,
ok_lt : str = Form ( default = " " ) ,
warning_lt : str = Form ( default = " " ) ,
critical_lt : str = Form ( default = " " ) ,
ui_form_mode : str = Form ( default = " " ) ,
ui_allow_complete : str = Form ( default = " " ) ,
ui_allow_manual_edit : str = Form ( default = " " ) ,
) - > RedirectResponse :
require_admin_enabled ( )
try :
existing = get_domain_item ( domain , item_key )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " item introuvable " )
form_values = {
key : value
for key , value in locals ( ) . items ( )
if key not in { " domain " , " item_key " , " existing " }
}
try :
item = normalize_item_payload ( form_values , existing_item = existing )
upsert_item ( domain , item , original_item_key = item_key )
except ( DomainsError , DomainsConflictError , KeyError ) as exc :
return RedirectResponse ( url = f " /life-noc/admin/item/ { domain } / { item_key } /edit?error= { quote ( str ( exc ) ) } " , status_code = 303 )
return RedirectResponse ( url = f " /life-noc/admin/item/ { domain } / { item [ ' name ' ] } " , status_code = 303 )
@app.post ( " /life-noc/admin/item/ {domain} / {item_key} /delete " )
def admin_delete_item ( domain : str , item_key : str ) - > RedirectResponse :
require_admin_enabled ( )
try :
delete_item ( domain , item_key )
except ( DomainsError , KeyError ) as exc :
return RedirectResponse ( url = f " /life-noc/admin/domain/ { domain } ?error= { quote ( str ( exc ) ) } " , status_code = 303 )
return RedirectResponse ( url = f " /life-noc/admin/domain/ { domain } " , status_code = 303 )