#!/usr/bin/env python3 from __future__ import annotations from pathlib import Path from urllib.parse import quote import os import sys from fastapi import FastAPI, HTTPException, Form from fastapi.responses import HTMLResponse, RedirectResponse from pydantic import BaseModel ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) 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, ) 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, ) class SetInputRequest(BaseModel): value: str origin: str = "manual" 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") 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: 920px; margin: 0 auto; padding: 1rem; } .stack { display: grid; gap: 1rem; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 1rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03); } .hero { padding: 1rem; } .muted { color: var(--muted); } .state { font-size: 1.15rem; font-weight: bold; } h1, h2, h3 { margin-top: 0; } ul { padding-left: 1.2rem; } code { background: #eef2f7; padding: 0.15rem 0.35rem; border-radius: 6px; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } .toolbar, .actions { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; } .actions form, .inline-form { display: inline-flex; margin: 0; } .pill { display: inline-block; padding: 0.25rem 0.55rem; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 0.9rem; } .button, button { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; min-height: 44px; padding: 0.75rem 1rem; border-radius: 12px; border: 1px solid #335b94; background: #ffffff; color: #163454; font-size: 1rem; 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.9rem; } .row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } .item-title { font-size: 1.05rem; font-weight: bold; margin: 0 0 0.35rem 0; } .form-grid { display: grid; gap: 0.85rem; } .form-grid.two { grid-template-columns: 1fr; } label { display: grid; gap: 0.35rem; font-size: 0.95rem; font-weight: bold; } input[type="text"], input[type="date"], textarea, select { width: 100%; min-height: 44px; padding: 0.75rem 0.8rem; border-radius: 12px; border: 1px solid #aebacc; font: inherit; background: #fff; color: var(--text); } textarea { min-height: 100px; resize: vertical; } .checks { display: grid; gap: 0.55rem; } .check { display: flex; align-items: center; gap: 0.6rem; font-weight: normal; } .check input { width: 20px; height: 20px; } .section-title { margin-bottom: 0.5rem; font-size: 1.02rem; } details { border: 1px solid var(--border); border-radius: 14px; background: #fcfdff; padding: 0.15rem 0.85rem 0.85rem 0.85rem; } details + details { margin-top: 0.8rem; } summary { cursor: pointer; font-weight: bold; list-style: none; padding: 0.9rem 0 0.6rem 0; } summary::-webkit-details-marker { display: none; } .alert { border: 1px solid #f2c5c5; background: #fff4f4; color: #7a1a1a; border-radius: 12px; padding: 0.85rem 1rem; } pre.yaml-preview { white-space: pre-wrap; word-break: break-word; background: #0f1720; color: #e8eef6; padding: 1rem; border-radius: 14px; overflow-x: auto; } .mobile-sticky { position: sticky; bottom: 0; background: rgba(245,247,251,0.96); padding-top: 0.7rem; } @media (min-width: 760px) { .form-grid.two { grid-template-columns: 1fr 1fr; } .grid-cards { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } } """ def html_escape(value: object) -> str: 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'
  • {l}
  • ' 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}" def page_shell(title: str, body: str) -> HTMLResponse: html = f""" {html_escape(title)}
    {body}
    """ 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'') return "".join(tags) def render_alert(message: str) -> str: if not message: return "" return f'
    {html_escape(message)}
    ' 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"""

    {html_escape(domain)}

    {len(items)} item(s) · {probed} avec sonde réelle

    Ouvrir
    """ 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'{html_escape(probe_type)}' move_up = f"""
    """ if total > 1 else "" move_down = f"""
    """ if total > 1 else "" return f"""

    {html_escape(title)}

    {html_escape(name)}

    {html_escape(summary)}

    {probe_badge}
    Voir Modifier {move_up} {move_down}
    """ def render_item_editor(domain: str, values: dict, action_url: str, heading: str, error_message: str = "") -> str: thresholds_html = "".join( f'''''' for field in THRESHOLD_FIELDS ) yaml_preview = render_yaml_preview(values) return f"""

    {html_escape(heading)}

    Éditeur mobile-first optionnel de domains.yaml. Sauvegarde explicite, sans changer le flux runtime existant.

    {render_alert(error_message)}
    Identité
    Contexte et contenu
    Liens
    Sonde
    Seuils
    {thresholds_html}
    Comportement UI
    Aperçu YAML
    {html_escape(yaml_preview)}
    Annuler
    """ 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) @app.get("/health") def health() -> dict: return {"status": "ok", "admin_ui_enabled": ADMIN_UI_ENABLED} @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} @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) except KeyError: raise HTTPException(status_code=404, detail="item introuvable") 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", "") 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)) 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 "
  • Aucun lien défini.
  • " summary_html = f'

    Résumé : {html_escape(summary)}

    ' if summary else "" instructions_html = "" if instructions: instructions_html = f"""

    Instructions

    {html_escape(instructions)}
    """ manual_form_html = "" 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 = '

    Saisir la date cible de cette échéance.

    ' manual_form_html = f"""

    {html_escape(form_title)}

    {form_hint}
    """ elif allow_manual_edit and form_mode == "counter_pair": manual_form_html = f"""

    Mettre à jour les compteurs

    Saisir le compteur courant et le compteur au dernier entretien.

    """ complete_form_html = "" if allow_complete: complete_form_html = f"""

    Action rapide

    """ admin_link = "" if ADMIN_UI_ENABLED: admin_link = f'Admin' body = f"""
    Actualiser {admin_link}

    {html_escape(title)}

    État actuel : {html_escape(state)}

    Type de sonde : {html_escape(probe_type)}

    {summary_html}

    Dernier intrant

    Valeur : {html_escape(current_input.get('value', ''))}

    {f'

    Valeur de référence : {html_escape(current_input.get("reference_value", ""))}

    ' if 'reference_value' in current_input else ''}

    Capturé le : {html_escape(current_input.get('captured_at', ''))}

    Origine : {html_escape(current_input.get('origin', ''))}

    Contexte

    Domaine : {html_escape(domain)}

    Item key : {html_escape(item_key)}

    Notes : {html_escape(notes)}

    {instructions_html}

    Liens utiles

    {manual_form_html} {complete_form_html} """ return page_shell(f"Life-NOC — {title}", body) @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)) 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: 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)) 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"""

    Life-NOC Admin

    Interface web optionnelle pour administrer domains.yaml sans casser le pipeline existant.

    {''.join(domain_cards)}
    """ 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"""
    ← Accueil admin Créer un item

    Domaine : {html_escape(domain)}

    {len(items)} item(s). Les boutons ↑ et ↓ réordonnent l’ordre des items dans domains.yaml.

    {render_alert(error)}
    {''.join(cards)}
    """ 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"""
    ← Domaine Modifier Page item

    {html_escape(values.get('title') or item_key)}

    {html_escape(item_key)}

    Résumé

    {html_escape(values.get('summary') or values.get('notes') or '')}

    probe.type = {html_escape(values.get('probe_type') or 'mock')}

    Aperçu YAML

    {html_escape(yaml_preview)}
    """ 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)