life-noc/scripts/life_noc_input_api.py

953 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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("<", "&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}"
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:
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 "<li class=\"muted\">Aucun lien défini.</li>"
summary_html = f'<p><strong>Résumé :</strong> {html_escape(summary)}</p>' if summary else ""
instructions_html = ""
if instructions:
instructions_html = f"""
<section class="card">
<h2>Instructions</h2>
<pre style="white-space: pre-wrap; font-family: Arial, sans-serif;">{html_escape(instructions)}</pre>
</section>
"""
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>'
manual_form_html = f"""
<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>
<input type="hidden" name="origin" value="manual">
<div class="actions">
<button type="submit">Enregistrer</button>
</div>
</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:
complete_form_html = f"""
<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>
{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>
<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)
@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"""
<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)