life-noc/scripts/life_noc_input_api.py

954 lines
33 KiB
Python
Raw Normal View History

#!/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
import sys
2026-03-15 14:43:50 -04:00
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))
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,
)
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: 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)); }
}
"""
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("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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)
@app.get("/health")
def health() -> dict:
2026-03-16 18:05:17 -04:00
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}
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 = ""
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">
<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">
<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 lordre 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 litem</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)