896 lines
31 KiB
Python
896 lines
31 KiB
Python
#!/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,
|
||
complete_item,
|
||
get_defined_service,
|
||
compute_state_for_item,
|
||
)
|
||
|
||
|
||
class SetInputRequest(BaseModel):
|
||
value: str
|
||
origin: str = "manual"
|
||
|
||
|
||
class CompleteInputRequest(BaseModel):
|
||
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'<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.get("/life-noc/item/{domain}/{item_key}", response_class=HTMLResponse)
|
||
def page_item(domain: str, item_key: str) -> HTMLResponse:
|
||
try:
|
||
service = get_defined_service(domain, item_key)
|
||
current_input = get_item(domain, item_key)
|
||
state = compute_state_for_item(domain, item_key)
|
||
except KeyError:
|
||
raise HTTPException(status_code=404, detail="item introuvable")
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
|
||
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>
|
||
"""
|
||
|
||
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>
|
||
<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}/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 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)
|