Remove HTML-to-Markdown parser and PDF generation functionality, clean up unused imports and whitespace

This commit is contained in:
Bruno Charest 2025-12-14 20:42:02 -05:00
parent 8db38ca75e
commit 6c83ada7d1
11 changed files with 969 additions and 1578 deletions

View File

@ -31,13 +31,10 @@ import pytz
from fastapi import FastAPI, HTTPException, Depends, Request, Form, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Depends, Request, Form, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from html.parser import HTMLParser
from io import BytesIO from io import BytesIO
from xml.sax.saxutils import escape as _xml_escape from xml.sax.saxutils import escape as _xml_escape
import hashlib
from pydantic import BaseModel, Field, field_validator, ConfigDict from pydantic import BaseModel, Field, field_validator, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy import select from sqlalchemy import select
@ -48,8 +45,6 @@ from app.crud.log import LogRepository # type: ignore
from app.crud.task import TaskRepository # type: ignore from app.crud.task import TaskRepository # type: ignore
from app.crud.schedule import ScheduleRepository # type: ignore from app.crud.schedule import ScheduleRepository # type: ignore
from app.crud.schedule_run import ScheduleRunRepository # type: ignore from app.crud.schedule_run import ScheduleRunRepository # type: ignore
from app.models.database import init_db # type: ignore
from app.services.notification_service import notification_service, send_notification # type: ignore
from app.schemas.notification import NotificationRequest, NotificationResponse # type: ignore from app.schemas.notification import NotificationRequest, NotificationResponse # type: ignore
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@ -102,452 +97,6 @@ ACTION_PLAYBOOK_MAP = {
# Gestionnaire de clés API # Gestionnaire de clés API
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
class _HelpHtmlToMarkdownParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._in_help = False
self._help_depth = 0
self._lines: list[str] = []
self._buf: list[str] = []
self._list_stack: list[str] = []
self._in_pre = False
self._span_class: str = ""
self._in_toc_link = False
def _flush(self) -> None:
txt = "".join(self._buf).strip()
self._buf = []
if txt:
self._lines.append(txt)
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
attrs_dict = {k: (v or "") for k, v in attrs}
if tag == "section" and attrs_dict.get("id") == "page-help":
self._in_help = True
self._help_depth = 1
return
if not self._in_help:
return
if tag == "section":
self._help_depth += 1
if tag in {"h1", "h2", "h3", "h4"}:
self._flush()
if tag in {"p", "div"}:
self._flush()
if tag in {"ul", "ol"}:
self._flush()
self._list_stack.append(tag)
if tag == "li":
self._flush()
if tag == "pre":
self._flush()
self._in_pre = True
self._lines.append("```")
if tag == "code":
self._buf.append("`")
if tag == "span":
self._span_class = attrs_dict.get("class") or ""
if "help-code" in self._span_class:
self._buf.append("`")
if tag == "a":
cls = attrs_dict.get("class") or ""
if "help-toc-item" in cls:
self._flush()
self._in_toc_link = True
def handle_endtag(self, tag: str) -> None:
if not self._in_help:
return
if tag == "a" and self._in_toc_link:
txt = "".join(self._buf).strip()
self._buf = []
if txt:
self._lines.append(f"- {txt}")
self._lines.append("")
self._in_toc_link = False
return
if tag == "section":
self._help_depth -= 1
if self._help_depth <= 0:
self._flush()
self._in_help = False
return
if tag in {"h1", "h2", "h3", "h4"}:
txt = "".join(self._buf).strip()
self._buf = []
if txt:
level = {"h1": "#", "h2": "##", "h3": "###", "h4": "####"}[tag]
self._lines.append(f"{level} {txt}")
self._lines.append("")
return
if tag == "li":
txt = "".join(self._buf).strip()
self._buf = []
if txt:
if self._list_stack and self._list_stack[-1] == "ol":
self._lines.append(f"1. {txt}")
else:
self._lines.append(f"- {txt}")
return
if tag in {"p", "div"}:
self._flush()
self._lines.append("")
return
if tag in {"ul", "ol"}:
self._flush()
if self._list_stack:
self._list_stack.pop()
self._lines.append("")
return
if tag == "pre":
self._flush()
self._in_pre = False
self._lines.append("```")
self._lines.append("")
return
if tag == "code":
self._buf.append("`")
return
if tag == "span" and "help-code" in (self._span_class or ""):
self._buf.append("`")
self._span_class = ""
return
def handle_data(self, data: str) -> None:
if not self._in_help:
return
if not data:
return
txt = data
if not self._in_pre:
txt = re.sub(r"\s+", " ", txt)
self._buf.append(txt)
def markdown(self) -> str:
out = "\n".join(line.rstrip() for line in self._lines)
out = re.sub(r"\n{3,}", "\n\n", out).strip() + "\n"
return out
def _build_help_markdown() -> str:
html_path = BASE_DIR / "index.html"
html = html_path.read_text(encoding="utf-8")
parser = _HelpHtmlToMarkdownParser()
parser.feed(html)
md = parser.markdown()
if len(md.strip()) < 200:
raise HTTPException(status_code=500, detail="Extraction du contenu d'aide insuffisante")
return md
def _extract_leading_emojis(text: str) -> tuple[str, str]:
if not text:
return "", ""
m = re.match(
r"^(?P<emoji>[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF\U0001F1E0-\U0001F1FF\u2600-\u26FF\u2700-\u27BF\u200D\uFE0E\uFE0F]+)\s*(?P<rest>.*)$",
text,
)
if not m:
return "", text
emoji = (m.group("emoji") or "").strip()
rest = (m.group("rest") or "").lstrip()
if not emoji:
return "", text
return emoji, rest
_LAST_EMOJI_FONT_PATH = ""
def _emoji_to_png_bytes(emoji: str, px: int = 64) -> bytes:
try:
from PIL import Image, ImageDraw, ImageFont
except ModuleNotFoundError:
raise HTTPException(
status_code=500,
detail="Génération PDF avec emojis indisponible: dépendance 'pillow' manquante (installer pillow dans l'environnement runtime).",
)
font_paths = [
r"C:\\Windows\\Fonts\\seguiemj.ttf",
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
"/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
emoji_soft_fallback = {
"❤️\u200d\U0001FA79": "❤️",
"⚙️": "",
"🛠️": "🛠",
"🏗️": "🏗",
"⚡️": "",
}
global _LAST_EMOJI_FONT_PATH
_LAST_EMOJI_FONT_PATH = ""
def _load_font():
for fp in font_paths:
try:
try:
_LAST_EMOJI_FONT_PATH = fp
return ImageFont.truetype(fp, size=int(px * 0.75), embedded_color=True)
except TypeError:
_LAST_EMOJI_FONT_PATH = fp
return ImageFont.truetype(fp, size=int(px * 0.75))
except Exception:
continue
_LAST_EMOJI_FONT_PATH = "(default)"
return ImageFont.load_default()
font = _load_font()
pad = max(2, int(px * 0.18))
canvas = px + (2 * pad)
img = Image.new("RGBA", (canvas, canvas), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
bbox = draw.textbbox((0, 0), emoji, font=font)
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]
x = ((canvas - w) // 2) - bbox[0]
y = ((canvas - h) // 2) - bbox[1]
draw_kwargs = {"font": font, "fill": (0, 0, 0, 255)}
try:
draw.text((x, y), emoji, embedded_color=True, **draw_kwargs)
except TypeError:
draw.text((x, y), emoji, **draw_kwargs)
try:
alpha = img.getchannel("A")
nonzero = alpha.getbbox() is not None
except Exception:
nonzero = True
if (not nonzero) and emoji in emoji_soft_fallback:
fallback_emoji = emoji_soft_fallback[emoji]
img = Image.new("RGBA", (canvas, canvas), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
bbox = draw.textbbox((0, 0), fallback_emoji, font=font)
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]
x = ((canvas - w) // 2) - bbox[0]
y = ((canvas - h) // 2) - bbox[1]
try:
draw.text((x, y), fallback_emoji, embedded_color=True, **draw_kwargs)
except TypeError:
draw.text((x, y), fallback_emoji, **draw_kwargs)
out = BytesIO()
img.save(out, format="PNG")
return out.getvalue()
def _markdown_to_pdf_bytes(markdown: str) -> bytes:
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import Paragraph, Preformatted, SimpleDocTemplate, Spacer, Table, TableStyle, Image
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
except ModuleNotFoundError as e:
if "reportlab" in str(e).lower():
raise HTTPException(
status_code=500,
detail="Génération PDF indisponible: dépendance 'reportlab' manquante (installer reportlab dans l'environnement runtime).",
)
raise
styles = getSampleStyleSheet()
mono_font_name = "Courier"
mono_font_paths = [
r"C:\\Windows\\Fonts\\consola.ttf",
r"C:\\Windows\\Fonts\\lucon.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
]
for fp in mono_font_paths:
try:
pdfmetrics.registerFont(TTFont("MonoUnicode", fp))
mono_font_name = "MonoUnicode"
break
except Exception:
continue
normal = styles["BodyText"]
h1 = styles["Heading1"]
h2 = styles["Heading2"]
h3 = styles["Heading3"]
code_style = ParagraphStyle(
"CodeBlock",
parent=styles.get("Code", normal),
fontName=mono_font_name,
fontSize=9,
leading=11,
backColor=colors.whitesmoke,
)
story: list[Any] = []
in_code = False
code_lines: list[str] = []
lines = (markdown or "").splitlines()
i = 0
while i < len(lines):
line = lines[i]
if line.strip().startswith("```"):
if not in_code:
in_code = True
code_lines = []
else:
in_code = False
story.append(Preformatted("\n".join(code_lines), code_style))
story.append(Spacer(1, 0.35 * cm))
i += 1
continue
if in_code:
code_lines.append(line)
i += 1
continue
if not line.strip():
story.append(Spacer(1, 0.2 * cm))
i += 1
continue
if line.startswith("### "):
raw = line[4:].strip()
emoji, rest = _extract_leading_emojis(raw)
if emoji:
png = _emoji_to_png_bytes(emoji, px=56)
img = Image(BytesIO(png), width=0.55 * cm, height=0.55 * cm)
tbl = Table([[img, Paragraph(_xml_escape(rest), h3)]], colWidths=[0.7 * cm, None])
tbl.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 1),
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
]
)
)
story.append(tbl)
else:
story.append(Paragraph(_xml_escape(raw), h3))
story.append(Spacer(1, 0.2 * cm))
i += 1
continue
if line.startswith("## "):
raw = line[3:].strip()
emoji, rest = _extract_leading_emojis(raw)
if emoji:
png = _emoji_to_png_bytes(emoji, px=64)
img = Image(BytesIO(png), width=0.65 * cm, height=0.65 * cm)
tbl = Table([[img, Paragraph(_xml_escape(rest), h2)]], colWidths=[0.8 * cm, None])
tbl.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 1),
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
]
)
)
story.append(tbl)
else:
story.append(Paragraph(_xml_escape(raw), h2))
story.append(Spacer(1, 0.25 * cm))
i += 1
continue
if line.startswith("# "):
raw = line[2:].strip()
emoji, rest = _extract_leading_emojis(raw)
if emoji:
png = _emoji_to_png_bytes(emoji, px=72)
img = Image(BytesIO(png), width=0.8 * cm, height=0.8 * cm)
tbl = Table([[img, Paragraph(_xml_escape(rest), h1)]], colWidths=[1.0 * cm, None])
tbl.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 1),
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
]
)
)
story.append(tbl)
else:
story.append(Paragraph(_xml_escape(raw), h1))
story.append(Spacer(1, 0.3 * cm))
i += 1
continue
if line.lstrip().startswith(("- ", "* ")):
while i < len(lines) and lines[i].lstrip().startswith(("- ", "* ")):
item_text = lines[i].lstrip()[2:].strip()
emoji, rest = _extract_leading_emojis(item_text)
if emoji:
png = _emoji_to_png_bytes(emoji, px=52)
img = Image(BytesIO(png), width=0.5 * cm, height=0.5 * cm)
tbl = Table([[img, Paragraph(_xml_escape(rest), normal)]], colWidths=[0.75 * cm, None])
tbl.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 12),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 1),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
]
)
)
story.append(tbl)
else:
story.append(Paragraph(_xml_escape(f"{item_text}"), normal))
story.append(Spacer(1, 0.12 * cm))
i += 1
story.append(Spacer(1, 0.15 * cm))
continue
story.append(Paragraph(_xml_escape(line.strip()), normal))
story.append(Spacer(1, 0.2 * cm))
i += 1
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
title="Homelab Automation Dashboard — Centre d'Aide",
)
doc.build(story)
return buffer.getvalue()
# Modèles Pydantic améliorés # Modèles Pydantic améliorés
class CommandResult(BaseModel): class CommandResult(BaseModel):
status: str status: str
@ -668,14 +217,12 @@ class HostRequest(BaseModel):
env_group: str = Field(..., description="Groupe d'environnement (ex: env_homelab, env_prod)") env_group: str = Field(..., description="Groupe d'environnement (ex: env_homelab, env_prod)")
role_groups: List[str] = Field(default=[], description="Groupes de rôles (ex: role_proxmox, role_sbc)") role_groups: List[str] = Field(default=[], description="Groupes de rôles (ex: role_proxmox, role_sbc)")
class HostUpdateRequest(BaseModel): class HostUpdateRequest(BaseModel):
"""Requête de mise à jour d'un hôte""" """Requête de mise à jour d'un hôte"""
env_group: Optional[str] = Field(default=None, description="Nouveau groupe d'environnement") env_group: Optional[str] = Field(default=None, description="Nouveau groupe d'environnement")
role_groups: Optional[List[str]] = Field(default=None, description="Nouveaux groupes de rôles") role_groups: Optional[List[str]] = Field(default=None, description="Nouveaux groupes de rôles")
ansible_host: Optional[str] = Field(default=None, description="Nouvelle adresse ansible_host") ansible_host: Optional[str] = Field(default=None, description="Nouvelle adresse ansible_host")
class GroupRequest(BaseModel): class GroupRequest(BaseModel):
"""Requête pour créer un groupe""" """Requête pour créer un groupe"""
name: str = Field(..., min_length=3, max_length=50, description="Nom du groupe (ex: env_prod, role_web)") name: str = Field(..., min_length=3, max_length=50, description="Nom du groupe (ex: env_prod, role_web)")
@ -696,7 +243,6 @@ class GroupRequest(BaseModel):
raise ValueError("Le type doit être 'env' ou 'role'") raise ValueError("Le type doit être 'env' ou 'role'")
return v return v
class GroupUpdateRequest(BaseModel): class GroupUpdateRequest(BaseModel):
"""Requête pour modifier un groupe""" """Requête pour modifier un groupe"""
new_name: str = Field(..., min_length=3, max_length=50, description="Nouveau nom du groupe") new_name: str = Field(..., min_length=3, max_length=50, description="Nouveau nom du groupe")
@ -709,12 +255,10 @@ class GroupUpdateRequest(BaseModel):
raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores') raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores')
return v return v
class GroupDeleteRequest(BaseModel): class GroupDeleteRequest(BaseModel):
"""Requête pour supprimer un groupe""" """Requête pour supprimer un groupe"""
move_hosts_to: Optional[str] = Field(default=None, description="Groupe vers lequel déplacer les hôtes") move_hosts_to: Optional[str] = Field(default=None, description="Groupe vers lequel déplacer les hôtes")
class AdHocCommandRequest(BaseModel): class AdHocCommandRequest(BaseModel):
"""Requête pour exécuter une commande ad-hoc Ansible""" """Requête pour exécuter une commande ad-hoc Ansible"""
target: str = Field(..., description="Hôte ou groupe cible") target: str = Field(..., description="Hôte ou groupe cible")
@ -724,7 +268,6 @@ class AdHocCommandRequest(BaseModel):
timeout: int = Field(default=60, ge=5, le=600, description="Timeout en secondes") timeout: int = Field(default=60, ge=5, le=600, description="Timeout en secondes")
category: Optional[str] = Field(default="default", description="Catégorie d'historique pour cette commande") category: Optional[str] = Field(default="default", description="Catégorie d'historique pour cette commande")
class AdHocCommandResult(BaseModel): class AdHocCommandResult(BaseModel):
"""Résultat d'une commande ad-hoc""" """Résultat d'une commande ad-hoc"""
target: str target: str
@ -736,7 +279,6 @@ class AdHocCommandResult(BaseModel):
duration: float duration: float
hosts_results: Optional[Dict[str, Any]] = None hosts_results: Optional[Dict[str, Any]] = None
class AdHocHistoryEntry(BaseModel): class AdHocHistoryEntry(BaseModel):
"""Entrée dans l'historique des commandes ad-hoc""" """Entrée dans l'historique des commandes ad-hoc"""
id: str id: str
@ -750,7 +292,6 @@ class AdHocHistoryEntry(BaseModel):
last_used: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) last_used: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
use_count: int = 1 use_count: int = 1
class AdHocHistoryCategory(BaseModel): class AdHocHistoryCategory(BaseModel):
"""Catégorie pour organiser les commandes ad-hoc""" """Catégorie pour organiser les commandes ad-hoc"""
name: str name: str
@ -758,7 +299,6 @@ class AdHocHistoryCategory(BaseModel):
color: str = "#7c3aed" color: str = "#7c3aed"
icon: str = "fa-folder" icon: str = "fa-folder"
class TaskLogFile(BaseModel): class TaskLogFile(BaseModel):
"""Représentation d'un fichier de log de tâche""" """Représentation d'un fichier de log de tâche"""
id: str id: str
@ -784,7 +324,6 @@ class TaskLogFile(BaseModel):
target_type: Optional[str] = None # Type de cible: 'host', 'group', 'role' target_type: Optional[str] = None # Type de cible: 'host', 'group', 'role'
source_type: Optional[str] = None # Source: 'scheduled', 'manual', 'adhoc' source_type: Optional[str] = None # Source: 'scheduled', 'manual', 'adhoc'
class TasksFilterParams(BaseModel): class TasksFilterParams(BaseModel):
"""Paramètres de filtrage des tâches""" """Paramètres de filtrage des tâches"""
status: Optional[str] = None # pending, running, completed, failed, all status: Optional[str] = None # pending, running, completed, failed, all
@ -799,7 +338,6 @@ class TasksFilterParams(BaseModel):
limit: int = 50 # Pagination côté serveur limit: int = 50 # Pagination côté serveur
offset: int = 0 offset: int = 0
# ===== MODÈLES PLANIFICATEUR (SCHEDULER) ===== # ===== MODÈLES PLANIFICATEUR (SCHEDULER) =====
class ScheduleRecurrence(BaseModel): class ScheduleRecurrence(BaseModel):
@ -810,7 +348,6 @@ class ScheduleRecurrence(BaseModel):
day_of_month: Optional[int] = Field(default=None, ge=1, le=31, description="Jour du mois (1-31) pour monthly") day_of_month: Optional[int] = Field(default=None, ge=1, le=31, description="Jour du mois (1-31) pour monthly")
cron_expression: Optional[str] = Field(default=None, description="Expression cron pour custom") cron_expression: Optional[str] = Field(default=None, description="Expression cron pour custom")
class Schedule(BaseModel): class Schedule(BaseModel):
"""Modèle d'un schedule de playbook""" """Modèle d'un schedule de playbook"""
id: str = Field(default_factory=lambda: f"sched_{uuid.uuid4().hex[:12]}") id: str = Field(default_factory=lambda: f"sched_{uuid.uuid4().hex[:12]}")
@ -850,7 +387,6 @@ class Schedule(BaseModel):
# Si schedule_type est 'once', recurrence n'est pas obligatoire # Si schedule_type est 'once', recurrence n'est pas obligatoire
return v return v
class ScheduleRun(BaseModel): class ScheduleRun(BaseModel):
"""Historique d'une exécution de schedule""" """Historique d'une exécution de schedule"""
id: str = Field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}") id: str = Field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}")
@ -869,7 +405,6 @@ class ScheduleRun(BaseModel):
datetime: lambda v: v.isoformat() if v else None datetime: lambda v: v.isoformat() if v else None
} }
class ScheduleCreateRequest(BaseModel): class ScheduleCreateRequest(BaseModel):
"""Requête de création d'un schedule""" """Requête de création d'un schedule"""
name: str = Field(..., min_length=3, max_length=100) name: str = Field(..., min_length=3, max_length=100)
@ -898,7 +433,6 @@ class ScheduleCreateRequest(BaseModel):
except pytz.exceptions.UnknownTimeZoneError: except pytz.exceptions.UnknownTimeZoneError:
raise ValueError(f"Fuseau horaire invalide: {v}") raise ValueError(f"Fuseau horaire invalide: {v}")
class ScheduleUpdateRequest(BaseModel): class ScheduleUpdateRequest(BaseModel):
"""Requête de mise à jour d'un schedule""" """Requête de mise à jour d'un schedule"""
name: Optional[str] = Field(default=None, min_length=3, max_length=100) name: Optional[str] = Field(default=None, min_length=3, max_length=100)
@ -918,7 +452,6 @@ class ScheduleUpdateRequest(BaseModel):
notification_type: Optional[Literal["none", "all", "errors"]] = Field(default=None) notification_type: Optional[Literal["none", "all", "errors"]] = Field(default=None)
tags: Optional[List[str]] = Field(default=None) tags: Optional[List[str]] = Field(default=None)
class ScheduleStats(BaseModel): class ScheduleStats(BaseModel):
"""Statistiques globales des schedules""" """Statistiques globales des schedules"""
total: int = 0 total: int = 0
@ -931,7 +464,6 @@ class ScheduleStats(BaseModel):
executions_24h: int = 0 executions_24h: int = 0
success_rate_7d: float = 0.0 success_rate_7d: float = 0.0
# ===== SERVICE DE LOGGING MARKDOWN ===== # ===== SERVICE DE LOGGING MARKDOWN =====
class TaskLogService: class TaskLogService:
@ -4095,51 +3627,6 @@ async def verify_api_key(api_key: str = Depends(api_key_header)) -> bool:
raise HTTPException(status_code=401, detail="Clé API invalide ou manquante") raise HTTPException(status_code=401, detail="Clé API invalide ou manquante")
return True return True
@app.get("/api/help/documentation.md")
async def download_help_markdown(api_key_valid: bool = Depends(verify_api_key)):
"""Télécharge la documentation du centre d'aide en format Markdown."""
markdown = _build_help_markdown()
digest = hashlib.sha256(markdown.encode("utf-8")).hexdigest()[:16]
etag = f'W/"md-{digest}"'
filename = f"homelab-documentation-{digest}.md"
return Response(
content=markdown,
media_type="text/markdown; charset=utf-8",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
"ETag": etag,
"X-Help-Doc-Generator": "app_optimized._build_help_markdown",
},
)
@app.get("/api/help/documentation.pdf")
async def download_help_pdf(api_key_valid: bool = Depends(verify_api_key)):
"""Télécharge la documentation du centre d'aide en format PDF."""
markdown = _build_help_markdown()
pdf_bytes = _markdown_to_pdf_bytes(markdown)
digest = hashlib.sha256(bytes(pdf_bytes)).hexdigest()[:16]
etag = f'W/"pdf-{digest}"'
filename = f"homelab-documentation-{digest}.pdf"
emoji_font = globals().get("_LAST_EMOJI_FONT_PATH", "")
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
"ETag": etag,
"X-Help-Doc-Generator": "app_optimized._markdown_to_pdf_bytes",
"X-Help-Emoji-Font": emoji_font,
},
)
# Routes API
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def root(request: Request): async def root(request: Request):
"""Page principale du dashboard""" """Page principale du dashboard"""

View File

@ -3727,724 +3727,26 @@
<!-- Layout avec Table des Matières --> <!-- Layout avec Table des Matières -->
<div class="flex gap-8"> <div class="flex gap-8">
<!-- Table des Matières (sidebar gauche) --> <!-- Table des Matières (sidebar gauche) - Chargée dynamiquement -->
<aside class="hidden lg:block w-64 flex-shrink-0"> <aside class="hidden lg:block w-64 flex-shrink-0">
<div class="help-toc glass-card p-4"> <div class="help-toc glass-card p-4 sticky top-24">
<div class="help-toc-title">Table des Matières</div> <div class="help-toc-title">Table des Matières</div>
<nav> <nav id="help-toc-nav">
<a href="#help-quickstart" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-quickstart')"> <!-- TOC chargée dynamiquement depuis help.md -->
<span class="mr-2">⚡️</span>Démarrage Rapide <div class="text-gray-500 text-sm py-2">Chargement...</div>
</a>
<a href="#help-indicators" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-indicators')">
<span class="mr-2">❤️‍🩹</span>Indicateurs de Santé
</a>
<a href="#help-architecture" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-architecture')">
<span class="mr-2">🏗️</span>Architecture
</a>
<a href="#help-features" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-features')">
<span class="mr-2">⚙️</span>Fonctionnalités
</a>
<a href="#help-notifications" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-notifications')">
<span class="mr-2">🔔</span>Notifications
</a>
<a href="#help-playbooks" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-playbooks')">
<span class="mr-2">📖</span>Playbooks Ansible
</a>
<a href="#help-api" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-api')">
<span class="mr-2">🔗</span>Référence API
</a>
<a href="#help-troubleshooting" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-troubleshooting')">
<span class="mr-2">🛠️</span>Dépannage
</a>
<a href="#help-shortcuts" class="help-toc-item" onclick="scrollToHelpSection(event, 'help-shortcuts')">
<span class="mr-2"></span>Raccourcis & Astuces
</a>
</nav> </nav>
</div> </div>
</aside> </aside>
<!-- Contenu Principal --> <!-- Contenu Principal - Chargé dynamiquement depuis help.md -->
<div class="flex-1 help-main-content"> <div id="help-dynamic-content" class="flex-1 help-main-content">
<!-- Placeholder pendant le chargement -->
<!-- Quick Start --> <div class="glass-card p-8 mb-8 text-center">
<div id="help-quickstart" class="glass-card p-8 mb-8 fade-in help-section-anchor"> <div class="loading-spinner mx-auto mb-4"></div>
<h2 class="help-section-title"> <p class="text-gray-400">Chargement de la documentation...</p>
<span class="text-2xl mr-2">⚡️</span>
Démarrage Rapide
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="help-card">
<div class="text-3xl mb-4 text-green-400"><i class="fas fa-1"></i></div>
<h3 class="font-semibold mb-2">Ajouter vos Hosts</h3>
<p class="text-gray-400 text-sm">
Commencez par ajouter vos serveurs dans la section <span class="help-code">Hosts</span>.
Chaque host nécessite un nom, une adresse IP et un système d'exploitation.
</p>
</div>
<div class="help-card">
<div class="text-3xl mb-4 text-blue-400"><i class="fas fa-2"></i></div>
<h3 class="font-semibold mb-2">Bootstrap Ansible</h3>
<p class="text-gray-400 text-sm">
Exécutez le <span class="help-code">Bootstrap</span> sur chaque host pour configurer
l'accès SSH et les prérequis Ansible.
</p>
</div>
<div class="help-card">
<div class="text-3xl mb-4 text-purple-400"><i class="fas fa-3"></i></div>
<h3 class="font-semibold mb-2">Automatiser</h3>
<p class="text-gray-400 text-sm">
Utilisez les <span class="help-code">Actions Rapides</span> ou exécutez des playbooks
personnalisés pour automatiser vos tâches.
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Indicateurs de Santé -->
<div id="help-indicators" class="glass-card p-8 mb-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2">❤️‍🩹</span>
Indicateurs de Santé des Hosts
</h2>
<p class="text-gray-400 mb-6">
Chaque host affiche un indicateur visuel de santé représenté par des barres colorées.
Cet indicateur combine plusieurs facteurs pour évaluer l'état global de votre serveur.
</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Explication des barres -->
<div>
<h3 class="font-semibold mb-4 text-purple-400">Comprendre l'Indicateur</h3>
<div class="space-y-4">
<!-- Niveau Excellent -->
<div class="flex items-center gap-4 p-3 bg-gray-800/50 rounded-lg">
<div class="health-indicator-demo">
<div class="health-bar bg-green-500"></div>
<div class="health-bar bg-green-500"></div>
<div class="health-bar bg-green-500"></div>
<div class="health-bar bg-green-500"></div>
<div class="health-bar bg-green-500"></div>
</div>
<div>
<span class="font-semibold text-green-400">Excellent</span>
<span class="text-gray-400 text-sm ml-2">(5 barres vertes)</span>
<p class="text-gray-500 text-xs mt-1">Host en ligne, bootstrap OK, vérifié récemment</p>
</div>
</div>
<!-- Niveau Bon -->
<div class="flex items-center gap-4 p-3 bg-gray-800/50 rounded-lg">
<div class="health-indicator-demo">
<div class="health-bar bg-yellow-500"></div>
<div class="health-bar bg-yellow-500"></div>
<div class="health-bar bg-yellow-500"></div>
<div class="health-bar bg-gray-600"></div>
<div class="health-bar bg-gray-600"></div>
</div>
<div>
<span class="font-semibold text-yellow-400">Bon</span>
<span class="text-gray-400 text-sm ml-2">(3-4 barres jaunes)</span>
<p class="text-gray-500 text-xs mt-1">Host fonctionnel mais certains aspects à améliorer</p>
</div>
</div>
<!-- Niveau Moyen -->
<div class="flex items-center gap-4 p-3 bg-gray-800/50 rounded-lg">
<div class="health-indicator-demo">
<div class="health-bar bg-orange-500"></div>
<div class="health-bar bg-orange-500"></div>
<div class="health-bar bg-gray-600"></div>
<div class="health-bar bg-gray-600"></div>
<div class="health-bar bg-gray-600"></div>
</div>
<div>
<span class="font-semibold text-orange-400">Moyen</span>
<span class="text-gray-400 text-sm ml-2">(2 barres oranges)</span>
<p class="text-gray-500 text-xs mt-1">Attention requise - vérification recommandée</p>
</div>
</div>
<!-- Niveau Faible -->
<div class="flex items-center gap-4 p-3 bg-gray-800/50 rounded-lg">
<div class="health-indicator-demo">
<div class="health-bar bg-red-500"></div>
<div class="health-bar bg-gray-600"></div>
<div class="health-bar bg-gray-600"></div>
<div class="health-bar bg-gray-600"></div>
<div class="health-bar bg-gray-600"></div>
</div>
<div>
<span class="font-semibold text-red-400">Faible</span>
<span class="text-gray-400 text-sm ml-2">(1 barre rouge)</span>
<p class="text-gray-500 text-xs mt-1">Host hors ligne ou non configuré</p>
</div>
</div>
</div>
</div>
<!-- Facteurs de calcul -->
<div>
<h3 class="font-semibold mb-4 text-purple-400">Facteurs de Calcul du Score</h3>
<div class="space-y-3">
<div class="p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-circle text-green-400 text-xs"></i>
<span class="font-medium">Statut en ligne</span>
<span class="text-green-400 text-sm ml-auto">+2 points</span>
</div>
<p class="text-gray-500 text-xs">Le host répond aux requêtes réseau</p>
</div>
<div class="p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-check-circle text-blue-400 text-xs"></i>
<span class="font-medium">Bootstrap Ansible OK</span>
<span class="text-blue-400 text-sm ml-auto">+1 point</span>
</div>
<p class="text-gray-500 text-xs">SSH et prérequis Ansible configurés</p>
</div>
<div class="p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-clock text-purple-400 text-xs"></i>
<span class="font-medium">Vérifié récemment (&lt;1h)</span>
<span class="text-purple-400 text-sm ml-auto">+2 points</span>
</div>
<p class="text-gray-500 text-xs">Dernière vérification il y a moins d'une heure</p>
</div>
<div class="p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-clock text-yellow-400 text-xs"></i>
<span class="font-medium">Vérifié aujourd'hui</span>
<span class="text-yellow-400 text-sm ml-auto">+1 point</span>
</div>
<p class="text-gray-500 text-xs">Dernière vérification dans les 24 dernières heures</p>
</div>
</div>
<div class="mt-4 p-3 bg-purple-900/20 border border-purple-600/30 rounded-lg">
<p class="text-sm text-purple-300">
<i class="fas fa-info-circle mr-2"></i>
<strong>Astuce:</strong> Exécutez régulièrement un <span class="help-code">Health Check</span>
pour maintenir un score de santé élevé.
</p>
</div>
</div>
</div>
<!-- Statuts Bootstrap -->
<div class="mt-8">
<h3 class="font-semibold mb-4 text-purple-400">Statuts Bootstrap Ansible</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="p-4 bg-green-900/20 border border-green-600/30 rounded-lg">
<div class="flex items-center gap-3 mb-2">
<span class="px-2 py-0.5 bg-green-600/30 text-green-400 text-xs rounded-full flex items-center">
<i class="fas fa-check-circle mr-1"></i>Ansible Ready
</span>
</div>
<p class="text-gray-400 text-sm">
Le host est entièrement configuré pour Ansible. L'utilisateur automation existe,
la clé SSH est déployée et sudo est configuré sans mot de passe.
</p>
</div>
<div class="p-4 bg-yellow-900/20 border border-yellow-600/30 rounded-lg">
<div class="flex items-center gap-3 mb-2">
<span class="px-2 py-0.5 bg-yellow-600/30 text-yellow-400 text-xs rounded-full flex items-center">
<i class="fas fa-exclamation-triangle mr-1"></i>Non configuré
</span>
</div>
<p class="text-gray-400 text-sm">
Le bootstrap n'a pas encore été exécuté sur ce host. Cliquez sur le bouton
<span class="help-code">Bootstrap</span> pour configurer l'accès Ansible.
</p>
</div>
</div>
</div>
<!-- Texte "Jamais vérifié" -->
<div class="mt-6 p-4 bg-gray-800/50 rounded-lg">
<h4 class="font-semibold mb-2 flex items-center gap-2">
<i class="fas fa-question-circle text-gray-400"></i>
Que signifie "Jamais vérifié" ?
</h4>
<p class="text-gray-400 text-sm">
Ce message apparaît lorsqu'aucun Health Check n'a été exécuté sur le host depuis son ajout.
Le système ne peut pas déterminer l'état réel du serveur. Lancez un Health Check pour
mettre à jour le statut et obtenir un score de santé précis.
</p>
</div>
</div>
<!-- Architecture -->
<div id="help-architecture" class="glass-card p-8 mb-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2">🏗️</span>
Architecture de la Solution
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h3 class="font-semibold mb-4 text-purple-400">Stack Technologique</h3>
<ul class="help-list">
<li class="flex items-center gap-3">
<i class="fas fa-server text-green-400"></i>
<span><strong>Backend:</strong> FastAPI (Python) - API REST haute performance</span>
</li>
<li class="flex items-center gap-3">
<i class="fas fa-cogs text-orange-400"></i>
<span><strong>Automation:</strong> Ansible - Gestion de configuration</span>
</li>
<li class="flex items-center gap-3">
<i class="fas fa-desktop text-blue-400"></i>
<span><strong>Frontend:</strong> HTML/CSS/JS avec TailwindCSS</span>
</li>
<li class="flex items-center gap-3">
<i class="fab fa-docker text-cyan-400"></i>
<span><strong>Déploiement:</strong> Docker & Docker Compose</span>
</li>
<li class="flex items-center gap-3">
<i class="fas fa-plug text-yellow-400"></i>
<span><strong>Temps réel:</strong> WebSocket pour les mises à jour live</span>
</li>
</ul>
</div>
<div>
<h3 class="font-semibold mb-4 text-purple-400">Structure des Fichiers</h3>
<div class="bg-black/40 p-4 rounded-lg font-mono text-sm">
<pre class="text-gray-300">
homelab-automation/
├── app/
│ ├── app_optimized.py # API FastAPI
│ ├── index.html # Interface web
│ └── main.js # Logique frontend
├── ansible/
│ ├── inventory/
│ │ ├── hosts.yml # Inventaire des hosts
│ │ └── group_vars/ # Variables par groupe
│ └── playbooks/ # Playbooks Ansible
├── tasks_logs/ # Logs des tâches
├── docker-compose.yml
└── Dockerfile</pre>
</div>
</div>
</div>
</div>
<!-- Fonctionnalités -->
<div id="help-features" class="glass-card p-8 mb-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2">⚙️</span>
Fonctionnalités Détaillées par Section
</h2>
<!-- Accordéon -->
<div class="space-y-2">
<!-- Dashboard -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-tachometer-alt text-purple-400"></i>
<strong>Dashboard</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Vue d'ensemble centralisée de votre infrastructure homelab.</p>
<ul class="help-list text-sm">
<li><strong>Métriques en temps réel:</strong> État des hosts (Online/Offline), statistiques des tâches (Succès/Échec).</li>
<li><strong>Actions Rapides:</strong> Accès immédiat aux opérations courantes (Mise à jour globale, Health Check général).</li>
<li><strong>Aperçu des Hosts:</strong> Liste condensée avec indicateurs de statut et OS pour une surveillance rapide.</li>
<li><strong>Notifications:</strong> Alertes visuelles sur les dernières activités importantes.</li>
</ul>
</div>
</div>
</div>
<!-- Hosts -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-server text-blue-400"></i>
<strong>Hosts</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Gestion complète du cycle de vie de vos serveurs.</p>
<ul class="help-list text-sm">
<li><strong>Inventaire:</strong> Ajout, modification et suppression de hosts avec détection d'OS.</li>
<li><strong>Bootstrap Ansible:</strong> Préparation automatique des serveurs (User, Clés SSH, Sudo).</li>
<li><strong>Indicateurs de Santé:</strong> Score de santé détaillé basé sur la connectivité et la configuration.</li>
<li><strong>Actions Individuelles:</strong> Exécution de playbooks spécifiques (Upgrade, Backup, Reboot) sur un host.</li>
<li><strong>Détails Avancés:</strong> Vue détaillée avec historique des tâches et logs spécifiques au host.</li>
</ul>
</div>
</div>
</div>
<!-- Playbooks -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-book text-pink-400"></i>
<strong>Playbooks</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Bibliothèque et exécution de playbooks Ansible personnalisés.</p>
<ul class="help-list text-sm">
<li><strong>Catalogue:</strong> Liste de tous les playbooks disponibles dans votre répertoire.</li>
<li><strong>Exécution Ciblée:</strong> Lancement de playbooks sur des hosts spécifiques ou des groupes.</li>
<li><strong>Logs en Direct:</strong> Suivi temps réel de l'exécution Ansible (console output).</li>
<li><strong>Historique:</strong> Accès rapide aux résultats des exécutions précédentes.</li>
</ul>
</div>
</div>
</div>
<!-- Tasks -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-tasks text-green-400"></i>
<strong>Tasks</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Traçabilité complète de toutes les opérations d'automatisation.</p>
<ul class="help-list text-sm">
<li><strong>Suivi d'État:</strong> Visualisation instantanée (En cours, Succès, Échec).</li>
<li><strong>Filtrage Avancé:</strong> Recherche par statut, par date ou par type d'action.</li>
<li><strong>Logs Détaillés:</strong> Accès aux sorties standard et d'erreur pour le débogage.</li>
<li><strong>Auto-refresh:</strong> Mise à jour automatique des tâches en cours d'exécution.</li>
</ul>
</div>
</div>
</div>
<!-- Schedules -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-calendar-alt text-indigo-400"></i>
<strong>Schedules</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Planification automatisée des tâches récurrentes.</p>
<ul class="help-list text-sm">
<li><strong>Planification Cron:</strong> Configuration flexible de la fréquence d'exécution.</li>
<li><strong>Tâches Récurrentes:</strong> Backups quotidiens, Mises à jour hebdomadaires, Health Checks horaires.</li>
<li><strong>Ciblage:</strong> Définition des hosts ou groupes cibles pour chaque planification.</li>
<li><strong>Gestion:</strong> Activation, désactivation ou modification des planifications existantes.</li>
</ul>
</div>
</div>
</div>
<!-- Logs -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-file-alt text-orange-400"></i>
<strong>Logs</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Journal technique des événements système.</p>
<ul class="help-list text-sm">
<li><strong>Streaming WebSocket:</strong> Arrivée des logs en temps réel sans rechargement de page.</li>
<li><strong>Niveaux de Log:</strong> Distinction claire entre Info, Warning et Error.</li>
<li><strong>Export:</strong> Possibilité de télécharger les logs pour analyse externe.</li>
<li><strong>Rétention:</strong> Gestion de l'historique et nettoyage des logs anciens.</li>
</ul>
</div>
</div>
</div>
<!-- Alertes -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-bell text-red-400"></i>
<strong>Alertes</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Centre de messages pour les événements importants.</p>
<ul class="help-list text-sm">
<li><strong>Suivi:</strong> Consultez les alertes récentes (succès/échec, changements d'état).</li>
<li><strong>Lecture:</strong> Les alertes peuvent être marquées comme lues pour garder une boîte de réception propre.</li>
<li><strong>Notifications:</strong> Certaines alertes peuvent déclencher des notifications ntfy (si activé).</li>
</ul>
</div>
</div>
</div>
<!-- Configuration -->
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas fa-cog text-cyan-400"></i>
<strong>Configuration</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">Paramètres de l'application et intégrations.</p>
<ul class="help-list text-sm">
<li><strong>Paramètres applicatifs:</strong> Options persistées (ex: collecte des métriques).</li>
<li><strong>Notifications:</strong> Configuration et test du service ntfy.</li>
<li><strong>Sécurité:</strong> Gestion du compte utilisateur (mot de passe) via l'écran utilisateur.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Notifications -->
<div id="help-notifications" class="glass-card p-8 mb-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2">🔔</span>
Système de Notifications (ntfy)
</h2>
<p class="text-gray-400 mb-6">
Restez informé de l'état de votre infrastructure grâce au système de notifications intégré basé sur <strong>ntfy</strong>.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="help-card">
<h3 class="font-semibold mb-3 text-purple-400">Canaux & Configuration</h3>
<p class="text-gray-400 text-sm mb-4">
Les notifications sont envoyées via le service ntfy, permettant de recevoir des alertes push sur mobile et desktop.
</p>
<ul class="help-list text-sm">
<li><strong>Push Mobile:</strong> Via l'application ntfy (Android/iOS).</li>
<li><strong>Web Push:</strong> Notifications navigateur sur desktop.</li>
<li><strong>Priorité:</strong> Gestion des niveaux d'urgence (Low à High).</li>
</ul>
</div>
<div class="help-card">
<h3 class="font-semibold mb-3 text-blue-400">Types d'Alertes</h3>
<p class="text-gray-400 text-sm mb-4">
Vous recevez des notifications pour les événements critiques :
</p>
<ul class="help-list text-sm">
<li><span class="mr-2"></span>Succès des Backups</li>
<li><span class="mr-2"></span>Échecs de Tâches</li>
<li><span class="mr-2">⚠️</span>Changements de Santé Host</li>
<li><span class="mr-2">🛠️</span>Fin de Bootstrap</li>
</ul>
</div>
</div>
</div>
<!-- Playbooks Ansible -->
<div id="help-playbooks" class="glass-card p-8 mb-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2">📖</span>
Playbooks Ansible Disponibles
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="help-card">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<i class="fas fa-tools text-yellow-400"></i>
bootstrap-host.yml
</h3>
<p class="text-gray-400 text-sm mb-2">
Configure un nouveau host pour Ansible: création utilisateur, clé SSH, sudo sans mot de passe.
</p>
<span class="text-xs text-purple-400">Requis avant toute autre opération</span>
</div>
<div class="help-card">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<i class="fas fa-heartbeat text-green-400"></i>
health-check.yml
</h3>
<p class="text-gray-400 text-sm mb-2">
Vérifie l'état de santé: CPU, RAM, disque, services critiques.
</p>
<span class="text-xs text-purple-400">Exécution rapide, non destructif</span>
</div>
<div class="help-card">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<i class="fas fa-arrow-up text-blue-400"></i>
system-upgrade.yml
</h3>
<p class="text-gray-400 text-sm mb-2">
Met à jour tous les paquets système (apt/yum/dnf selon l'OS).
</p>
<span class="text-xs text-orange-400">Peut nécessiter un redémarrage</span>
</div>
<div class="help-card">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<i class="fas fa-save text-cyan-400"></i>
backup-config.yml
</h3>
<p class="text-gray-400 text-sm mb-2">
Sauvegarde les fichiers de configuration importants (/etc, configs apps).
</p>
<span class="text-xs text-purple-400">Stockage local ou distant</span>
</div>
</div>
</div>
<!-- API Reference -->
<div id="help-api" class="glass-card p-8 mb-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2">🔗</span>
Référence API
</h2>
<p class="text-gray-400 mb-6">
L'API REST est accessible sur le port configuré. Authentification via header <span class="help-code">Authorization: Bearer &lt;token&gt;</span>.
</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-700">
<th class="text-left py-3 px-4 text-purple-400">Endpoint</th>
<th class="text-left py-3 px-4 text-purple-400">Méthode</th>
<th class="text-left py-3 px-4 text-purple-400">Description</th>
</tr>
</thead>
<tbody class="text-gray-300">
<tr class="border-b border-gray-800">
<td class="py-3 px-4"><span class="help-code">/api/hosts</span></td>
<td class="py-3 px-4"><span class="text-green-400">GET</span></td>
<td class="py-3 px-4">Liste tous les hosts</td>
</tr>
<tr class="border-b border-gray-800">
<td class="py-3 px-4"><span class="help-code">/api/hosts</span></td>
<td class="py-3 px-4"><span class="text-blue-400">POST</span></td>
<td class="py-3 px-4">Ajoute un nouveau host</td>
</tr>
<tr class="border-b border-gray-800">
<td class="py-3 px-4"><span class="help-code">/api/tasks/logs</span></td>
<td class="py-3 px-4"><span class="text-green-400">GET</span></td>
<td class="py-3 px-4">Récupère les logs de tâches</td>
</tr>
<tr class="border-b border-gray-800">
<td class="py-3 px-4"><span class="help-code">/api/ansible/playbooks</span></td>
<td class="py-3 px-4"><span class="text-green-400">GET</span></td>
<td class="py-3 px-4">Liste les playbooks disponibles</td>
</tr>
<tr class="border-b border-gray-800">
<td class="py-3 px-4"><span class="help-code">/api/ansible/execute</span></td>
<td class="py-3 px-4"><span class="text-blue-400">POST</span></td>
<td class="py-3 px-4">Exécute un playbook</td>
</tr>
<tr>
<td class="py-3 px-4"><span class="help-code">/api/metrics</span></td>
<td class="py-3 px-4"><span class="text-green-400">GET</span></td>
<td class="py-3 px-4">Métriques du dashboard</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Troubleshooting -->
<div id="help-troubleshooting" class="glass-card p-8 mb-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2">🛠️</span>
Dépannage
</h2>
<div class="space-y-4">
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span>Le bootstrap échoue avec "Permission denied"</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4 text-gray-400 text-sm">
<p class="mb-2"><strong>Cause:</strong> Les identifiants SSH fournis sont incorrects ou l'utilisateur n'a pas les droits sudo.</p>
<p><strong>Solution:</strong> Vérifiez le nom d'utilisateur et mot de passe. Assurez-vous que l'utilisateur peut exécuter <span class="help-code">sudo</span> sur le host cible.</p>
</div>
</div>
</div>
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span>Les hosts apparaissent "offline" alors qu'ils sont accessibles</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4 text-gray-400 text-sm">
<p class="mb-2"><strong>Cause:</strong> Le health check n'a pas été exécuté ou la clé SSH n'est pas configurée.</p>
<p><strong>Solution:</strong> Exécutez le bootstrap si ce n'est pas fait, puis lancez un Health Check.</p>
</div>
</div>
</div>
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span>Les tâches restent bloquées "En cours"</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4 text-gray-400 text-sm">
<p class="mb-2"><strong>Cause:</strong> Le processus Ansible peut être bloqué ou le host ne répond plus.</p>
<p><strong>Solution:</strong> Vérifiez la connectivité réseau. Consultez les logs système pour plus de détails. Redémarrez le conteneur Docker si nécessaire.</p>
</div>
</div>
</div>
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span>L'interface ne se met pas à jour en temps réel</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4 text-gray-400 text-sm">
<p class="mb-2"><strong>Cause:</strong> La connexion WebSocket est interrompue.</p>
<p><strong>Solution:</strong> Rafraîchissez la page. Vérifiez que le port WebSocket n'est pas bloqué par un firewall ou proxy.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Keyboard Shortcuts -->
<div id="help-shortcuts" class="glass-card p-8 fade-in help-section-anchor">
<h2 class="help-section-title">
<span class="text-2xl mr-2"></span>
Raccourcis & Astuces
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="font-semibold mb-4 text-purple-400">Navigation</h3>
<ul class="help-list text-sm">
<li>Cliquez sur le logo pour revenir au Dashboard</li>
<li>Utilisez les onglets du menu pour naviguer</li>
<li>Le thème clair/sombre est persistant</li>
</ul>
</div>
<div>
<h3 class="font-semibold mb-4 text-purple-400">Productivité</h3>
<ul class="help-list text-sm">
<li>Filtrez les hosts par groupe pour des actions groupées</li>
<li>Utilisez les filtres de date pour retrouver des tâches</li>
<li>Exportez les logs avant de les effacer</li>
</ul>
</div>
</div>
</div>
</div><!-- Fin help-main-content -->
</div><!-- Fin flex container -->
</div> </div>
</div> </div>
</section> </section>
@ -4532,6 +3834,62 @@ homelab-automation/
targetPage.querySelectorAll('.fade-in').forEach(el => { targetPage.querySelectorAll('.fade-in').forEach(el => {
el.classList.add('visible'); el.classList.add('visible');
}); });
// Charger dynamiquement le contenu d'aide si nécessaire
if (pageName === 'help') {
loadHelpContent();
}
}
}
// Chargement dynamique du contenu d'aide depuis help.md
let helpContentLoaded = false;
async function loadHelpContent() {
if (helpContentLoaded) return;
const contentContainer = document.getElementById('help-dynamic-content');
const tocNav = document.getElementById('help-toc-nav');
if (!contentContainer) return;
try {
const response = await fetch('/api/help/content', {
headers: window.dashboard ? window.dashboard.getAuthHeaders() : {}
});
if (!response.ok) {
throw new Error('Erreur de chargement');
}
const data = await response.json();
// Injecter le contenu
contentContainer.innerHTML = data.content;
// Injecter la TOC
if (tocNav && data.toc) {
tocNav.innerHTML = data.toc;
}
// Marquer comme chargé
helpContentLoaded = true;
// Réinitialiser les animations fade-in
contentContainer.querySelectorAll('.fade-in, .glass-card').forEach(el => {
el.classList.add('visible');
});
} catch (error) {
console.error('Erreur chargement aide:', error);
contentContainer.innerHTML = `
<div class="glass-card p-8 mb-8 text-center">
<i class="fas fa-exclamation-triangle text-4xl text-yellow-400 mb-4"></i>
<p class="text-gray-400 mb-4">Impossible de charger la documentation.</p>
<button onclick="helpContentLoaded = false; loadHelpContent();" class="px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors">
<i class="fas fa-redo mr-2"></i>Réessayer
</button>
</div>
`;
} }
} }

View File

@ -1,30 +1,46 @@
""" """
Routes API pour l'aide et la documentation. Routes API pour l'aide et la documentation.
Source unique: app/static/help.md
""" """
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import Response from fastapi.responses import JSONResponse, Response
from app.core.config import settings from app.core.config import settings
from app.core.dependencies import verify_api_key from app.core.dependencies import verify_api_key
from app.utils.markdown_parser import build_help_markdown from app.utils.help_renderer import render_help_page, get_raw_markdown
from app.utils.pdf_generator import markdown_to_pdf_bytes from app.utils.pdf_generator import markdown_to_pdf_bytes
router = APIRouter() router = APIRouter()
# Chemin vers le fichier source Markdown
HELP_MD_PATH = settings.base_dir / "static" / "help.md"
@router.get("/content")
async def get_help_content(api_key_valid: bool = Depends(verify_api_key)):
"""
Retourne le contenu HTML de la page d'aide généré depuis help.md.
Utilisé pour le chargement dynamique de la page d'aide.
"""
html_content, toc_html = render_help_page(HELP_MD_PATH)
return JSONResponse({
"content": html_content,
"toc": toc_html
})
@router.get("/documentation.md") @router.get("/documentation.md")
async def download_help_markdown(api_key_valid: bool = Depends(verify_api_key)): async def download_help_markdown(api_key_valid: bool = Depends(verify_api_key)):
"""Télécharge la documentation d'aide en format Markdown.""" """Télécharge la documentation d'aide en format Markdown."""
# Essayer de charger depuis index.html markdown_content = get_raw_markdown(HELP_MD_PATH)
html_path = settings.base_dir / "index.html"
markdown_content = build_help_markdown(html_path=html_path)
return Response( return Response(
content=markdown_content, content=markdown_content,
media_type="text/markdown", media_type="text/markdown; charset=utf-8",
headers={ headers={
"Content-Disposition": "attachment; filename=homelab-automation-help.md" "Content-Disposition": "attachment; filename=homelab-automation-help.md"
} }
@ -34,9 +50,7 @@ async def download_help_markdown(api_key_valid: bool = Depends(verify_api_key)):
@router.get("/documentation.pdf") @router.get("/documentation.pdf")
async def download_help_pdf(api_key_valid: bool = Depends(verify_api_key)): async def download_help_pdf(api_key_valid: bool = Depends(verify_api_key)):
"""Télécharge la documentation d'aide en format PDF.""" """Télécharge la documentation d'aide en format PDF."""
# Essayer de charger depuis index.html markdown_content = get_raw_markdown(HELP_MD_PATH)
html_path = settings.base_dir / "index.html"
markdown_content = build_help_markdown(html_path=html_path)
pdf_bytes = markdown_to_pdf_bytes( pdf_bytes = markdown_to_pdf_bytes(
markdown_content, markdown_content,

317
app/static/help.md Normal file
View File

@ -0,0 +1,317 @@
# 🚀 Guide d'Utilisation
Bienvenue dans le guide officiel de votre **Homelab Automation Dashboard** !
Découvrez comment gérer et automatiser efficacement votre infrastructure grâce à cette solution puissante et centralisée.
---
## ⚡️ Démarrage Rapide {#help-quickstart}
<!-- quickstart-cards -->
:::card green fa-1
### Ajouter vos Hosts
Commencez par ajouter vos serveurs dans la section `Hosts`. Chaque host nécessite un nom, une adresse IP et un système d'exploitation.
:::
:::card blue fa-2
### Bootstrap Ansible
Exécutez le `Bootstrap` sur chaque host pour configurer l'accès SSH et les prérequis Ansible.
:::
:::card purple fa-3
### Automatiser
Utilisez les `Actions Rapides` ou exécutez des playbooks personnalisés pour automatiser vos tâches.
:::
<!-- /quickstart-cards -->
---
## ❤️‍🩹 Indicateurs de Santé des Hosts {#help-indicators}
Chaque host affiche un indicateur visuel de santé représenté par des barres colorées. Cet indicateur combine plusieurs facteurs pour évaluer l'état global de votre serveur.
### Comprendre l'Indicateur
:::health-level excellent 5 green
**Excellent** (5 barres vertes)
Host en ligne, bootstrap OK, vérifié récemment
:::
:::health-level good 3 yellow
**Bon** (3-4 barres jaunes)
Host fonctionnel mais certains aspects à améliorer
:::
:::health-level medium 2 orange
**Moyen** (2 barres oranges)
Attention requise - vérification recommandée
:::
:::health-level low 1 red
**Faible** (1 barre rouge)
Host hors ligne ou non configuré
:::
### Facteurs de Calcul du Score
:::score-factor fa-circle green +2 points
**Statut en ligne**
Le host répond aux requêtes réseau
:::
:::score-factor fa-check-circle blue +1 point
**Bootstrap Ansible OK**
SSH et prérequis Ansible configurés
:::
:::score-factor fa-clock purple +2 points
**Vérifié récemment (<1h)**
Dernière vérification il y a moins d'une heure
:::
:::score-factor fa-clock yellow +1 point
**Vérifié aujourd'hui**
Dernière vérification dans les 24 dernières heures
:::
> **Astuce:** Exécutez régulièrement un `Health Check` pour maintenir un score de santé élevé.
### Statuts Bootstrap Ansible
:::status-badge green fa-check-circle Ansible Ready
Le host est entièrement configuré pour Ansible. L'utilisateur automation existe, la clé SSH est déployée et sudo est configuré sans mot de passe.
:::
:::status-badge yellow fa-exclamation-triangle Non configuré
Le bootstrap n'a pas encore été exécuté sur ce host. Cliquez sur le bouton `Bootstrap` pour configurer l'accès Ansible.
:::
### Que signifie "Jamais vérifié" ?
Ce message apparaît lorsqu'aucun Health Check n'a été exécuté sur le host depuis son ajout. Le système ne peut pas déterminer l'état réel du serveur. Lancez un Health Check pour mettre à jour le statut et obtenir un score de santé précis.
---
## 🏗️ Architecture de la Solution {#help-architecture}
### Stack Technologique
- **Backend:** FastAPI (Python) - API REST haute performance {icon:fa-server color:green}
- **Automation:** Ansible - Gestion de configuration {icon:fa-cogs color:orange}
- **Frontend:** HTML/CSS/JS avec TailwindCSS {icon:fa-desktop color:blue}
- **Déploiement:** Docker & Docker Compose {icon:fab fa-docker color:cyan}
- **Temps réel:** WebSocket pour les mises à jour live {icon:fa-plug color:yellow}
### Structure des Fichiers
```
homelab-automation/
├── app/
│ ├── routes/ # Routes API FastAPI
│ ├── models/ # Modèles SQLAlchemy
│ ├── schemas/ # Schémas Pydantic
│ ├── services/ # Logique métier
│ ├── index.html # Interface web
│ └── main.js # Logique frontend
├── ansible/
│ ├── inventory/
│ │ ├── hosts.yml # Inventaire des hosts
│ │ └── group_vars/ # Variables par groupe
│ └── playbooks/ # Playbooks Ansible
├── alembic/ # Migrations de base de données
├── data/ # Base de données SQLite
├── tasks_logs/ # Logs des tâches
├── docker-compose.yml
└── Dockerfile
```
---
## ⚙️ Fonctionnalités Détaillées par Section {#help-features}
:::accordion fa-tachometer-alt purple Dashboard
Vue d'ensemble centralisée de votre infrastructure homelab.
- **Métriques en temps réel:** État des hosts (Online/Offline), statistiques des tâches (Succès/Échec).
- **Actions Rapides:** Accès immédiat aux opérations courantes (Mise à jour globale, Health Check général).
- **Aperçu des Hosts:** Liste condensée avec indicateurs de statut et OS pour une surveillance rapide.
- **Notifications:** Alertes visuelles sur les dernières activités importantes.
:::
:::accordion fa-server blue Hosts
Gestion complète du cycle de vie de vos serveurs.
- **Inventaire:** Ajout, modification et suppression de hosts avec détection d'OS.
- **Bootstrap Ansible:** Préparation automatique des serveurs (User, Clés SSH, Sudo).
- **Indicateurs de Santé:** Score de santé détaillé basé sur la connectivité et la configuration.
- **Actions Individuelles:** Exécution de playbooks spécifiques (Upgrade, Backup, Reboot) sur un host.
- **Détails Avancés:** Vue détaillée avec historique des tâches et logs spécifiques au host.
:::
:::accordion fa-book pink Playbooks
Bibliothèque et exécution de playbooks Ansible personnalisés.
- **Catalogue:** Liste de tous les playbooks disponibles dans votre répertoire.
- **Exécution Ciblée:** Lancement de playbooks sur des hosts spécifiques ou des groupes.
- **Logs en Direct:** Suivi temps réel de l'exécution Ansible (console output).
- **Historique:** Accès rapide aux résultats des exécutions précédentes.
:::
:::accordion fa-tasks green Tasks
Traçabilité complète de toutes les opérations d'automatisation.
- **Suivi d'État:** Visualisation instantanée (En cours, Succès, Échec).
- **Filtrage Avancé:** Recherche par statut, par date ou par type d'action.
- **Logs Détaillés:** Accès aux sorties standard et d'erreur pour le débogage.
- **Auto-refresh:** Mise à jour automatique des tâches en cours d'exécution.
:::
:::accordion fa-calendar-alt indigo Schedules
Planification automatisée des tâches récurrentes.
- **Planification Cron:** Configuration flexible de la fréquence d'exécution.
- **Tâches Récurrentes:** Backups quotidiens, Mises à jour hebdomadaires, Health Checks horaires.
- **Ciblage:** Définition des hosts ou groupes cibles pour chaque planification.
- **Gestion:** Activation, désactivation ou modification des planifications existantes.
:::
:::accordion fa-file-alt orange Logs
Journal technique des événements système.
- **Streaming WebSocket:** Arrivée des logs en temps réel sans rechargement de page.
- **Niveaux de Log:** Distinction claire entre Info, Warning et Error.
- **Export:** Possibilité de télécharger les logs pour analyse externe.
- **Rétention:** Gestion de l'historique et nettoyage des logs anciens.
:::
:::accordion fa-bell red Alertes
Centre de messages pour les événements importants.
- **Suivi:** Consultez les alertes récentes (succès/échec, changements d'état).
- **Lecture:** Les alertes peuvent être marquées comme lues pour garder une boîte de réception propre.
- **Notifications:** Certaines alertes peuvent déclencher des notifications ntfy (si activé).
:::
:::accordion fa-cog cyan Configuration
Paramètres de l'application et intégrations.
- **Paramètres applicatifs:** Options persistées (ex: collecte des métriques).
- **Notifications:** Configuration et test du service ntfy.
- **Sécurité:** Gestion du compte utilisateur (mot de passe) via l'écran utilisateur.
:::
---
## 🔔 Système de Notifications (ntfy) {#help-notifications}
Restez informé de l'état de votre infrastructure grâce au système de notifications intégré basé sur **ntfy**.
### Canaux & Configuration
Les notifications sont envoyées via le service ntfy, permettant de recevoir des alertes push sur mobile et desktop.
- **Push Mobile:** Via l'application ntfy (Android/iOS).
- **Web Push:** Notifications navigateur sur desktop.
- **Priorité:** Gestion des niveaux d'urgence (Low à High).
### Types d'Alertes
Vous recevez des notifications pour les événements critiques :
- ✅ Succès des Backups
- ❌ Échecs de Tâches
- ⚠️ Changements de Santé Host
- 🛠️ Fin de Bootstrap
---
## 📖 Playbooks Ansible Disponibles {#help-playbooks}
:::playbook fa-tools yellow bootstrap-host.yml
Configure un nouveau host pour Ansible: création utilisateur, clé SSH, sudo sans mot de passe.
**Requis avant toute autre opération**
:::
:::playbook fa-heartbeat green health-check.yml
Vérifie l'état de santé: CPU, RAM, disque, services critiques.
**Exécution rapide, non destructif**
:::
:::playbook fa-arrow-up blue system-upgrade.yml
Met à jour tous les paquets système (apt/yum/dnf selon l'OS).
**Peut nécessiter un redémarrage**
:::
:::playbook fa-save cyan backup-config.yml
Sauvegarde les fichiers de configuration importants (/etc, configs apps).
**Stockage local ou distant**
:::
---
## 🔗 Référence API {#help-api}
L'API REST est accessible sur le port configuré. Authentification via header `Authorization: Bearer <token>`.
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/api/hosts` | GET | Liste tous les hosts |
| `/api/hosts` | POST | Ajoute un nouveau host |
| `/api/hosts/{id}` | PUT | Modifie un host existant |
| `/api/hosts/{id}` | DELETE | Supprime un host |
| `/api/tasks/logs` | GET | Récupère les logs de tâches |
| `/api/ansible/playbooks` | GET | Liste les playbooks disponibles |
| `/api/ansible/execute` | POST | Exécute un playbook |
| `/api/schedules` | GET | Liste les planifications |
| `/api/schedules` | POST | Crée une planification |
| `/api/metrics` | GET | Métriques du dashboard |
| `/api/auth/login` | POST | Authentification utilisateur |
| `/api/auth/me` | GET | Informations utilisateur courant |
---
## 🛠️ Dépannage {#help-troubleshooting}
:::troubleshoot Le bootstrap échoue avec "Permission denied"
**Cause:** Les identifiants SSH fournis sont incorrects ou l'utilisateur n'a pas les droits sudo.
**Solution:** Vérifiez le nom d'utilisateur et mot de passe. Assurez-vous que l'utilisateur peut exécuter `sudo` sur le host cible.
:::
:::troubleshoot Les hosts apparaissent "offline" alors qu'ils sont accessibles
**Cause:** Le health check n'a pas été exécuté ou la clé SSH n'est pas configurée.
**Solution:** Exécutez le bootstrap si ce n'est pas fait, puis lancez un Health Check.
:::
:::troubleshoot Les tâches restent bloquées "En cours"
**Cause:** Le processus Ansible peut être bloqué ou le host ne répond plus.
**Solution:** Vérifiez la connectivité réseau. Consultez les logs système pour plus de détails. Redémarrez le conteneur Docker si nécessaire.
:::
:::troubleshoot L'interface ne se met pas à jour en temps réel
**Cause:** La connexion WebSocket est interrompue.
**Solution:** Rafraîchissez la page. Vérifiez que le port WebSocket n'est pas bloqué par un firewall ou proxy.
:::
---
## ✨ Raccourcis & Astuces {#help-shortcuts}
### Navigation
- Cliquez sur le logo pour revenir au Dashboard
- Utilisez les onglets du menu pour naviguer
- Le thème clair/sombre est persistant
### Productivité
- Filtrez les hosts par groupe pour des actions groupées
- Utilisez les filtres de date pour retrouver des tâches
- Exportez les logs avant de les effacer
---
*Documentation générée par Homelab Automation Dashboard v1.0*

View File

@ -4,12 +4,10 @@ Utilitaires pour l'API Homelab Automation.
from app.utils.ssh_utils import find_ssh_private_key, run_ssh_command, bootstrap_host from app.utils.ssh_utils import find_ssh_private_key, run_ssh_command, bootstrap_host
from app.utils.pdf_generator import markdown_to_pdf_bytes from app.utils.pdf_generator import markdown_to_pdf_bytes
from app.utils.markdown_parser import build_help_markdown
__all__ = [ __all__ = [
"find_ssh_private_key", "find_ssh_private_key",
"run_ssh_command", "run_ssh_command",
"bootstrap_host", "bootstrap_host",
"markdown_to_pdf_bytes", "markdown_to_pdf_bytes",
"build_help_markdown",
] ]

520
app/utils/help_renderer.py Normal file
View File

@ -0,0 +1,520 @@
"""
Renderer de documentation Markdown vers HTML avec styles TailwindCSS.
Convertit le fichier help.md en HTML pour la page d'aide du dashboard.
"""
import re
from pathlib import Path
from typing import List, Tuple, Optional
class HelpMarkdownRenderer:
"""Convertit le Markdown personnalisé en HTML avec styles TailwindCSS."""
def __init__(self, markdown_content: str):
self.content = markdown_content
self.toc_items: List[Tuple[str, str, str]] = [] # (id, emoji, title)
def render(self) -> Tuple[str, str]:
"""
Rend le Markdown en HTML.
Returns:
Tuple (html_content, toc_html)
"""
html = self.content
# Extraire et traiter les sections avec IDs pour la TOC
html = self._process_sections(html)
# Traiter les blocs personnalisés
html = self._process_quickstart_cards(html)
html = self._process_health_levels(html)
html = self._process_score_factors(html)
html = self._process_status_badges(html)
html = self._process_accordions(html)
html = self._process_playbooks(html)
html = self._process_troubleshoot(html)
# Traiter le Markdown standard
html = self._process_blockquotes(html)
html = self._process_tables(html)
html = self._process_code_blocks(html)
html = self._process_inline_code(html)
html = self._process_lists(html)
html = self._process_headings(html)
html = self._process_paragraphs(html)
html = self._process_bold_italic(html)
html = self._process_horizontal_rules(html)
# Générer la TOC
toc_html = self._generate_toc()
return html, toc_html
def _process_sections(self, html: str) -> str:
"""Extrait les sections H2 avec IDs pour la TOC."""
pattern = r'^## ([^\n{]+)\s*\{#([^}]+)\}'
def replace_section(match):
title = match.group(1).strip()
section_id = match.group(2).strip()
# Extraire l'emoji du titre
emoji_match = re.match(r'^(\S+)\s+(.+)$', title)
if emoji_match:
emoji = emoji_match.group(1)
text = emoji_match.group(2)
else:
emoji = ""
text = title
self.toc_items.append((section_id, emoji, text))
return f'</div><div id="{section_id}" class="glass-card p-8 mb-8 fade-in help-section-anchor">\n<h2 class="help-section-title"><span class="text-2xl mr-2">{emoji}</span>{text}</h2>'
html = re.sub(pattern, replace_section, html, flags=re.MULTILINE)
# Nettoyer le premier </div> en trop
html = html.replace('</div><div id="help-', '<div id="help-', 1)
# Ajouter la fermeture finale
html += '\n</div>'
return html
def _process_quickstart_cards(self, html: str) -> str:
"""Traite les cartes de démarrage rapide."""
# Trouver le bloc quickstart-cards
pattern = r'<!-- quickstart-cards -->(.*?)<!-- /quickstart-cards -->'
def process_cards(match):
cards_content = match.group(1)
# Parser chaque carte
card_pattern = r':::card\s+(\w+)\s+(fa-\d+)\s*\n###\s+([^\n]+)\n([^:]+):::'
cards = re.findall(card_pattern, cards_content, re.DOTALL)
cards_html = '<div class="grid grid-cols-1 md:grid-cols-3 gap-6">'
for color, icon, title, description in cards:
color_class = f"text-{color}-400"
desc = self._process_inline_code(description.strip())
cards_html += f'''
<div class="help-card">
<div class="text-3xl mb-4 {color_class}"><i class="fas {icon}"></i></div>
<h3 class="font-semibold mb-2">{title}</h3>
<p class="text-gray-400 text-sm">{desc}</p>
</div>'''
cards_html += '</div>'
return cards_html
return re.sub(pattern, process_cards, html, flags=re.DOTALL)
def _process_health_levels(self, html: str) -> str:
"""Traite les indicateurs de niveau de santé."""
pattern = r':::health-level\s+(\w+)\s+(\d+)\s+(\w+)\s*\n\*\*([^*]+)\*\*\s*\(([^)]+)\)\s*\n([^:]+):::'
def replace_health(match):
level = match.group(1)
bars = int(match.group(2))
color = match.group(3)
title = match.group(4)
subtitle = match.group(5)
description = match.group(6).strip()
bars_html = ''
for i in range(5):
if i < bars:
bars_html += f'<div class="health-bar bg-{color}-500"></div>'
else:
bars_html += '<div class="health-bar bg-gray-600"></div>'
return f'''
<div class="flex items-center gap-4 p-3 bg-gray-800/50 rounded-lg">
<div class="health-indicator-demo">{bars_html}</div>
<div>
<span class="font-semibold text-{color}-400">{title}</span>
<span class="text-gray-400 text-sm ml-2">({subtitle})</span>
<p class="text-gray-500 text-xs mt-1">{description}</p>
</div>
</div>'''
return re.sub(pattern, replace_health, html, flags=re.DOTALL)
def _process_score_factors(self, html: str) -> str:
"""Traite les facteurs de score."""
pattern = r':::score-factor\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n\*\*([^*]+)\*\*\s*\n([^:]+):::'
def replace_factor(match):
icon = match.group(1)
color = match.group(2)
points = match.group(3).strip()
title = match.group(4)
description = match.group(5).strip()
return f'''
<div class="p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<i class="fas {icon} text-{color}-400 text-xs"></i>
<span class="font-medium">{title}</span>
<span class="text-{color}-400 text-sm ml-auto">{points}</span>
</div>
<p class="text-gray-500 text-xs">{description}</p>
</div>'''
return re.sub(pattern, replace_factor, html, flags=re.DOTALL)
def _process_status_badges(self, html: str) -> str:
"""Traite les badges de statut."""
pattern = r':::status-badge\s+(\w+)\s+(fa-[\w-]+)\s+([^\n]+)\n([^:]+):::'
def replace_badge(match):
color = match.group(1)
icon = match.group(2)
badge_text = match.group(3).strip()
description = match.group(4).strip()
description = self._process_inline_code(description)
return f'''
<div class="p-4 bg-{color}-900/20 border border-{color}-600/30 rounded-lg">
<div class="flex items-center gap-3 mb-2">
<span class="px-2 py-0.5 bg-{color}-600/30 text-{color}-400 text-xs rounded-full flex items-center">
<i class="fas {icon} mr-1"></i>{badge_text}
</span>
</div>
<p class="text-gray-400 text-sm">{description}</p>
</div>'''
return re.sub(pattern, replace_badge, html, flags=re.DOTALL)
def _process_accordions(self, html: str) -> str:
"""Traite les accordéons."""
pattern = r':::accordion\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n(.*?):::'
def replace_accordion(match):
icon = match.group(1)
color = match.group(2)
title = match.group(3).strip()
content = match.group(4).strip()
# Traiter les listes dans le contenu
content = self._process_lists(content)
content = self._process_bold_italic(content)
# Séparer description et liste
parts = content.split('\n\n', 1)
description = parts[0] if parts else ''
list_content = parts[1] if len(parts) > 1 else ''
return f'''
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas {icon} text-{color}-400"></i>
<strong>{title}</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">{description}</p>
{list_content}
</div>
</div>
</div>'''
return re.sub(pattern, replace_accordion, html, flags=re.DOTALL)
def _process_playbooks(self, html: str) -> str:
"""Traite les cartes de playbooks."""
pattern = r':::playbook\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n([^*]+)\*\*([^*]+)\*\*\s*:::'
def replace_playbook(match):
icon = match.group(1)
color = match.group(2)
filename = match.group(3).strip()
description = match.group(4).strip()
note = match.group(5).strip()
# Déterminer la couleur de la note
note_color = "purple"
if "redémarrage" in note.lower() or "attention" in note.lower():
note_color = "orange"
return f'''
<div class="help-card">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<i class="fas {icon} text-{color}-400"></i>
{filename}
</h3>
<p class="text-gray-400 text-sm mb-2">{description}</p>
<span class="text-xs text-{note_color}-400">{note}</span>
</div>'''
return re.sub(pattern, replace_playbook, html, flags=re.DOTALL)
def _process_troubleshoot(self, html: str) -> str:
"""Traite les sections de dépannage."""
pattern = r':::troubleshoot\s+([^\n]+)\n(.*?):::'
def replace_troubleshoot(match):
title = match.group(1).strip()
content = match.group(2).strip()
# Traiter le contenu
content = self._process_bold_italic(content)
content = self._process_inline_code(content)
content = content.replace('\n\n', '</p><p class="text-gray-400 text-sm">')
return f'''
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span>{title}</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4 text-gray-400 text-sm">
<p class="text-gray-400 text-sm">{content}</p>
</div>
</div>
</div>'''
return re.sub(pattern, replace_troubleshoot, html, flags=re.DOTALL)
def _process_blockquotes(self, html: str) -> str:
"""Traite les citations (blockquotes)."""
pattern = r'^>\s*\*\*([^:]+):\*\*\s*(.+)$'
def replace_quote(match):
label = match.group(1)
content = match.group(2).strip()
content = self._process_inline_code(content)
return f'''
<div class="mt-4 p-3 bg-purple-900/20 border border-purple-600/30 rounded-lg">
<p class="text-sm text-purple-300">
<i class="fas fa-info-circle mr-2"></i>
<strong>{label}:</strong> {content}
</p>
</div>'''
return re.sub(pattern, replace_quote, html, flags=re.MULTILINE)
def _process_tables(self, html: str) -> str:
"""Traite les tableaux Markdown."""
# Trouver les tableaux
table_pattern = r'(\|[^\n]+\|\n)+(\|[-:| ]+\|\n)(\|[^\n]+\|\n)+'
def replace_table(match):
table_text = match.group(0)
lines = [l.strip() for l in table_text.strip().split('\n') if l.strip()]
if len(lines) < 3:
return table_text
# En-têtes
headers = [h.strip() for h in lines[0].split('|') if h.strip()]
# Ignorer la ligne de séparation (lines[1])
# Données
rows = []
for line in lines[2:]:
cells = [c.strip() for c in line.split('|') if c.strip()]
rows.append(cells)
# Construire le HTML
html_table = '<div class="overflow-x-auto"><table class="w-full text-sm">'
html_table += '<thead><tr class="border-b border-gray-700">'
for h in headers:
html_table += f'<th class="text-left py-3 px-4 text-purple-400">{h}</th>'
html_table += '</tr></thead>'
html_table += '<tbody class="text-gray-300">'
for row in rows:
html_table += '<tr class="border-b border-gray-800">'
for cell in row:
cell = self._process_inline_code(cell)
html_table += f'<td class="py-3 px-4">{cell}</td>'
html_table += '</tr>'
html_table += '</tbody></table></div>'
return html_table
return re.sub(table_pattern, replace_table, html)
def _process_code_blocks(self, html: str) -> str:
"""Traite les blocs de code."""
pattern = r'```(\w*)\n(.*?)```'
def replace_code(match):
lang = match.group(1) or ''
code = match.group(2)
return f'<div class="bg-black/40 p-4 rounded-lg font-mono text-sm"><pre class="text-gray-300">{code}</pre></div>'
return re.sub(pattern, replace_code, html, flags=re.DOTALL)
def _process_inline_code(self, html: str) -> str:
"""Traite le code inline."""
return re.sub(r'`([^`]+)`', r'<span class="help-code">\1</span>', html)
def _process_lists(self, html: str) -> str:
"""Traite les listes à puces."""
lines = html.split('\n')
result = []
in_list = False
for line in lines:
if line.strip().startswith('- '):
if not in_list:
result.append('<ul class="help-list text-sm">')
in_list = True
item = line.strip()[2:]
item = self._process_bold_italic(item)
item = self._process_inline_code(item)
result.append(f'<li>{item}</li>')
else:
if in_list:
result.append('</ul>')
in_list = False
result.append(line)
if in_list:
result.append('</ul>')
return '\n'.join(result)
def _process_headings(self, html: str) -> str:
"""Traite les titres H3 et H4."""
# H3
html = re.sub(
r'^### ([^\n{]+)$',
r'<h3 class="font-semibold mb-4 text-purple-400">\1</h3>',
html,
flags=re.MULTILINE
)
# H4
html = re.sub(
r'^#### ([^\n]+)$',
r'<h4 class="font-semibold mb-2 flex items-center gap-2"><i class="fas fa-question-circle text-gray-400"></i>\1</h4>',
html,
flags=re.MULTILINE
)
return html
def _process_paragraphs(self, html: str) -> str:
"""Traite les paragraphes."""
# Remplacer les lignes vides multiples par des paragraphes
html = re.sub(r'\n\n+', '\n\n', html)
# Envelopper les paragraphes isolés
lines = html.split('\n\n')
result = []
for block in lines:
block = block.strip()
if not block:
continue
# Ne pas envelopper si c'est déjà du HTML
if block.startswith('<') or block.startswith('|'):
result.append(block)
elif not any(block.startswith(x) for x in ['#', '-', '>', '```', ':::', '<!--']):
result.append(f'<p class="text-gray-400 mb-6">{block}</p>')
else:
result.append(block)
return '\n\n'.join(result)
def _process_bold_italic(self, html: str) -> str:
"""Traite le gras et l'italique."""
# Gras
html = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', html)
# Italique
html = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', html)
return html
def _process_horizontal_rules(self, html: str) -> str:
"""Traite les lignes horizontales."""
return re.sub(r'^---+$', '', html, flags=re.MULTILINE)
def _generate_toc(self) -> str:
"""Génère le HTML de la table des matières."""
toc_html = ''
for section_id, emoji, title in self.toc_items:
toc_html += f'''
<a href="#{section_id}" class="help-toc-item" onclick="scrollToHelpSection(event, '{section_id}')">
<span class="mr-2">{emoji}</span>{title}
</a>'''
return toc_html
def render_help_page(markdown_path: Path) -> Tuple[str, str]:
"""
Rend la page d'aide depuis le fichier Markdown.
Args:
markdown_path: Chemin vers le fichier help.md
Returns:
Tuple (html_content, toc_html)
"""
if not markdown_path.exists():
return "<p>Documentation non disponible.</p>", ""
content = markdown_path.read_text(encoding='utf-8')
renderer = HelpMarkdownRenderer(content)
return renderer.render()
def get_raw_markdown(markdown_path: Path) -> str:
"""
Retourne le contenu Markdown brut nettoyé pour le téléchargement.
Args:
markdown_path: Chemin vers le fichier help.md
Returns:
Contenu Markdown nettoyé (sans syntaxe personnalisée)
"""
if not markdown_path.exists():
return "# Documentation non disponible"
content = markdown_path.read_text(encoding='utf-8')
# Nettoyer la syntaxe personnalisée pour un Markdown standard
# Supprimer les IDs de section
content = re.sub(r'\s*\{#[^}]+\}', '', content)
# Convertir les cartes en texte simple
content = re.sub(r'<!-- quickstart-cards -->', '', content)
content = re.sub(r'<!-- /quickstart-cards -->', '', content)
content = re.sub(r':::card\s+\w+\s+fa-\d+\s*\n', '', content)
# Convertir les health-levels
content = re.sub(r':::health-level\s+\w+\s+\d+\s+\w+\s*\n', '', content)
# Convertir les score-factors
content = re.sub(r':::score-factor\s+[^\n]+\n', '', content)
# Convertir les status-badges
content = re.sub(r':::status-badge\s+[^\n]+\n', '', content)
# Convertir les accordions
content = re.sub(r':::accordion\s+[^\n]+\n', '### ', content)
# Convertir les playbooks
content = re.sub(r':::playbook\s+[^\n]+\n', '### ', content)
# Convertir les troubleshoot
content = re.sub(r':::troubleshoot\s+', '### ', content)
# Supprimer les fermetures :::
content = re.sub(r'^:::$', '', content, flags=re.MULTILINE)
# Supprimer les attributs {icon:...}
content = re.sub(r'\s*\{icon:[^}]+\}', '', content)
# Nettoyer les lignes vides multiples
content = re.sub(r'\n{3,}', '\n\n', content)
return content.strip()

View File

@ -1,332 +0,0 @@
"""
Générateur de documentation Markdown pour l'aide Homelab Automation.
"""
from pathlib import Path
def build_help_markdown(html_path: Path = None, html_content: str = None) -> str:
"""Génère le contenu Markdown d'aide professionnel.
Args:
html_path: Non utilisé (conservé pour compatibilité)
html_content: Non utilisé (conservé pour compatibilité)
Returns:
Contenu Markdown formaté professionnellement
"""
return _get_professional_help_markdown()
def _get_professional_help_markdown() -> str:
"""Retourne la documentation d'aide complète et professionnelle."""
return """# Homelab Automation Dashboard
## Guide d'Utilisation
Bienvenue dans le guide officiel de votre **Homelab Automation Dashboard** !
Découvrez comment gérer et automatiser efficacement votre infrastructure grâce à cette solution puissante et centralisée.
---
## Table des Matières
1. [Démarrage Rapide](#démarrage-rapide)
2. [Indicateurs de Santé des Hosts](#indicateurs-de-santé-des-hosts)
3. [Architecture de la Solution](#architecture-de-la-solution)
4. [Fonctionnalités par Section](#fonctionnalités-par-section)
5. [Système de Notifications](#système-de-notifications-ntfy)
6. [Playbooks Ansible Disponibles](#playbooks-ansible-disponibles)
7. [Référence API](#référence-api)
8. [Dépannage](#dépannage)
9. [Raccourcis et Astuces](#raccourcis-et-astuces)
---
## Démarrage Rapide
### Étape 1 : Ajouter vos Hosts
Commencez par ajouter vos serveurs dans la section **Hosts**. Chaque host nécessite :
- Un nom unique
- Une adresse IP
- Un système d'exploitation
### Étape 2 : Bootstrap Ansible
Exécutez le **Bootstrap** sur chaque host pour configurer :
- L'accès SSH avec clé publique
- L'utilisateur d'automatisation
- Les prérequis Ansible (Python, sudo)
### Étape 3 : Automatiser
Utilisez les **Actions Rapides** ou exécutez des playbooks personnalisés pour automatiser vos tâches récurrentes.
---
## Indicateurs de Santé des Hosts
Chaque host affiche un indicateur visuel de santé représenté par des barres colorées. Cet indicateur combine plusieurs facteurs pour évaluer l'état global de votre serveur.
### Comprendre l'Indicateur
| Niveau | Barres | Description |
|--------|--------|-------------|
| **Excellent** | 5 barres vertes | Host en ligne, bootstrap OK, vérifié récemment |
| **Bon** | 3-4 barres jaunes | Host fonctionnel mais certains aspects à améliorer |
| **Moyen** | 2 barres oranges | Attention requise - vérification recommandée |
| **Faible** | 1 barre rouge | Host hors ligne ou non configuré |
### Facteurs de Calcul du Score
| Facteur | Points | Description |
|---------|--------|-------------|
| Statut en ligne | +2 | Le host répond aux requêtes réseau |
| Bootstrap Ansible OK | +1 | SSH et prérequis Ansible configurés |
| Vérifié récemment (<1h) | +2 | Dernière vérification il y a moins d'une heure |
| Vérifié aujourd'hui | +1 | Dernière vérification dans les 24 dernières heures |
> **Astuce :** Exécutez régulièrement un `Health Check` pour maintenir un score de santé élevé.
### Statuts Bootstrap Ansible
- **Ansible Ready** : Le host est entièrement configuré pour Ansible. L'utilisateur automation existe, la clé SSH est déployée et sudo est configuré sans mot de passe.
- **Non configuré** : Le bootstrap n'a pas encore été exécuté sur ce host. Cliquez sur le bouton **Bootstrap** pour configurer l'accès Ansible.
#### Que signifie "Jamais vérifié" ?
Ce message apparaît lorsqu'aucun Health Check n'a été exécuté sur le host depuis son ajout. Le système ne peut pas déterminer l'état réel du serveur. Lancez un Health Check pour mettre à jour le statut et obtenir un score de santé précis.
---
## Architecture de la Solution
### Stack Technologique
| Composant | Technologie | Description |
|-----------|-------------|-------------|
| **Backend** | FastAPI (Python) | API REST haute performance |
| **Automation** | Ansible | Gestion de configuration |
| **Frontend** | HTML/CSS/JS + TailwindCSS | Interface web moderne |
| **Déploiement** | Docker & Docker Compose | Conteneurisation |
| **Temps réel** | WebSocket | Mises à jour live |
### Structure des Fichiers
```
homelab-automation/
app/
routes/ # Routes API FastAPI
models/ # Modèles SQLAlchemy
schemas/ # Schémas Pydantic
services/ # Logique métier
index.html # Interface web
main.js # Logique frontend
ansible/
inventory/
hosts.yml # Inventaire des hosts
group_vars/ # Variables par groupe
playbooks/ # Playbooks Ansible
alembic/ # Migrations de base de données
data/ # Base de données SQLite
tasks_logs/ # Logs des tâches
docker-compose.yml
Dockerfile
```
---
## Fonctionnalités par Section
### Dashboard
Vue d'ensemble centralisée de votre infrastructure homelab.
- **Métriques en temps réel** : État des hosts (Online/Offline), statistiques des tâches (Succès/Échec)
- **Actions Rapides** : Accès immédiat aux opérations courantes (Mise à jour globale, Health Check général)
- **Aperçu des Hosts** : Liste condensée avec indicateurs de statut et OS pour une surveillance rapide
- **Notifications** : Alertes visuelles sur les dernières activités importantes
### Hosts
Gestion complète du cycle de vie de vos serveurs.
- **Inventaire** : Ajout, modification et suppression de hosts avec détection d'OS
- **Bootstrap Ansible** : Préparation automatique des serveurs (User, Clés SSH, Sudo)
- **Indicateurs de Santé** : Score de santé détaillé basé sur la connectivité et la configuration
- **Actions Individuelles** : Exécution de playbooks spécifiques (Upgrade, Backup, Reboot) sur un host
- **Détails Avancés** : Vue détaillée avec historique des tâches et logs spécifiques au host
### Playbooks
Bibliothèque et exécution de playbooks Ansible personnalisés.
- **Catalogue** : Liste de tous les playbooks disponibles dans votre répertoire
- **Exécution Ciblée** : Lancement de playbooks sur des hosts spécifiques ou des groupes
- **Logs en Direct** : Suivi temps réel de l'exécution Ansible (console output)
- **Historique** : Accès rapide aux résultats des exécutions précédentes
### Tasks
Traçabilité complète de toutes les opérations d'automatisation.
- **Suivi d'État** : Visualisation instantanée (En cours, Succès, Échec)
- **Filtrage Avancé** : Recherche par statut, par date ou par type d'action
- **Logs Détaillés** : Accès aux sorties standard et d'erreur pour le débogage
- **Auto-refresh** : Mise à jour automatique des tâches en cours d'exécution
### Schedules
Planification automatisée des tâches récurrentes.
- **Planification Cron** : Configuration flexible de la fréquence d'exécution
- **Tâches Récurrentes** : Backups quotidiens, Mises à jour hebdomadaires, Health Checks horaires
- **Ciblage** : Définition des hosts ou groupes cibles pour chaque planification
- **Gestion** : Activation, désactivation ou modification des planifications existantes
### Logs
Journal technique des événements système.
- **Streaming WebSocket** : Arrivée des logs en temps réel sans rechargement de page
- **Niveaux de Log** : Distinction claire entre Info, Warning et Error
- **Export** : Possibilité de télécharger les logs pour analyse externe
- **Rétention** : Gestion de l'historique et nettoyage des logs anciens
### Alertes
Centre de messages pour les événements importants.
- **Suivi** : Consultez les alertes récentes (succès/échec, changements d'état)
- **Lecture** : Les alertes peuvent être marquées comme lues pour garder une boîte de réception propre
- **Notifications** : Certaines alertes peuvent déclencher des notifications ntfy (si activé)
### Configuration
Paramètres de l'application et intégrations.
- **Paramètres applicatifs** : Options persistées (ex: collecte des métriques)
- **Notifications** : Configuration et test du service ntfy
- **Sécurité** : Gestion du compte utilisateur (mot de passe) via l'écran utilisateur
---
## Système de Notifications (ntfy)
Restez informé de l'état de votre infrastructure grâce au système de notifications intégré basé sur **ntfy**.
### Canaux et Configuration
Les notifications sont envoyées via le service ntfy, permettant de recevoir des alertes push sur mobile et desktop.
- **Push Mobile** : Via l'application ntfy (Android/iOS)
- **Web Push** : Notifications navigateur sur desktop
- **Priorité** : Gestion des niveaux d'urgence (Low à High)
### Types d'Alertes
Vous recevez des notifications pour les événements critiques :
- Succès des Backups
- Échecs de Tâches
- Changements de Santé Host
- 🛠 Fin de Bootstrap
---
## Playbooks Ansible Disponibles
### bootstrap-host.yml
Configure un nouveau host pour Ansible : création utilisateur, clé SSH, sudo sans mot de passe.
> **Note :** Requis avant toute autre opération
### health-check.yml
Vérifie l'état de santé : CPU, RAM, disque, services critiques.
> **Note :** Exécution rapide, non destructif
### system-upgrade.yml
Met à jour tous les paquets système (apt/yum/dnf selon l'OS).
> **Attention :** Peut nécessiter un redémarrage
### backup-config.yml
Sauvegarde les fichiers de configuration importants (/etc, configs apps).
> **Note :** Stockage local ou distant
---
## Référence API
L'API REST est accessible sur le port configuré. Authentification via header `Authorization: Bearer <token>`.
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/api/hosts` | GET | Liste tous les hosts |
| `/api/hosts` | POST | Ajoute un nouveau host |
| `/api/hosts/{id}` | PUT | Modifie un host existant |
| `/api/hosts/{id}` | DELETE | Supprime un host |
| `/api/tasks/logs` | GET | Récupère les logs de tâches |
| `/api/ansible/playbooks` | GET | Liste les playbooks disponibles |
| `/api/ansible/execute` | POST | Exécute un playbook |
| `/api/schedules` | GET | Liste les planifications |
| `/api/schedules` | POST | Crée une planification |
| `/api/metrics` | GET | Métriques du dashboard |
| `/api/auth/login` | POST | Authentification utilisateur |
| `/api/auth/me` | GET | Informations utilisateur courant |
---
## Dépannage
### Le bootstrap échoue avec "Permission denied"
**Cause :** Les identifiants SSH fournis sont incorrects ou l'utilisateur n'a pas les droits sudo.
**Solution :** Vérifiez le nom d'utilisateur et mot de passe. Assurez-vous que l'utilisateur peut exécuter `sudo` sur le host cible.
### Les hosts apparaissent "offline" alors qu'ils sont accessibles
**Cause :** Le health check n'a pas été exécuté ou la clé SSH n'est pas configurée.
**Solution :** Exécutez le bootstrap si ce n'est pas fait, puis lancez un Health Check.
### Les tâches restent bloquées "En cours"
**Cause :** Le processus Ansible peut être bloqué ou le host ne répond plus.
**Solution :** Vérifiez la connectivité réseau. Consultez les logs système pour plus de détails. Redémarrez le conteneur Docker si nécessaire.
### L'interface ne se met pas à jour en temps réel
**Cause :** La connexion WebSocket est interrompue.
**Solution :** Rafraîchissez la page. Vérifiez que le port WebSocket n'est pas bloqué par un firewall ou proxy.
---
## Raccourcis et Astuces
### Navigation
- Cliquez sur le logo pour revenir au Dashboard
- Utilisez les onglets du menu pour naviguer
- Le thème clair/sombre est persistant
### Productivité
- Filtrez les hosts par groupe pour des actions groupées
- Utilisez les filtres de date pour retrouver des tâches
- Exportez les logs avant de les effacer
---
*Documentation générée par Homelab Automation Dashboard v1.0*
"""

Binary file not shown.

Binary file not shown.

View File

@ -193,7 +193,7 @@ projet/
│ ├── utils/ │ ├── utils/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── pdf_generator.py # _markdown_to_pdf_bytes │ │ ├── pdf_generator.py # _markdown_to_pdf_bytes
│ │ ├── markdown_parser.py # _HelpHtmlToMarkdownParser │ │ ├── help_renderer.py # help.md -> HTML + markdown clean
│ │ ├── ssh_utils.py # find_ssh_private_key, run_ssh_command │ │ ├── ssh_utils.py # find_ssh_private_key, run_ssh_command
│ │ └── helpers.py # Fonctions utilitaires diverses │ │ └── helpers.py # Fonctions utilitaires diverses
│ │ │ │
@ -239,7 +239,7 @@ projet/
| Endpoints Health | 5902-6112 | `app/routes/health.py` | | Endpoints Health | 5902-6112 | `app/routes/health.py` |
| Endpoints Schedules | 6388-6949 | `app/routes/schedules.py` | | Endpoints Schedules | 6388-6949 | `app/routes/schedules.py` |
| Endpoints Notifications | 6952-7022 | `app/routes/notifications.py` | | Endpoints Notifications | 6952-7022 | `app/routes/notifications.py` |
| PDF/Markdown utils | 105-548 | `app/utils/pdf_generator.py`, `app/utils/markdown_parser.py` | | PDF/Markdown utils | 105-548 | `app/utils/pdf_generator.py`, `app/utils/help_renderer.py` |
| SSH utils | 3581-3673 | `app/utils/ssh_utils.py` | | SSH utils | 3581-3673 | `app/utils/ssh_utils.py` |
| Startup/Shutdown | 7025-7098 | `app/__init__.py` (create_app) | | Startup/Shutdown | 7025-7098 | `app/__init__.py` (create_app) |
@ -276,7 +276,7 @@ projet/
### Étape 4: Utils ### Étape 4: Utils
1. Créer `app/utils/__init__.py` 1. Créer `app/utils/__init__.py`
2. Créer `app/utils/pdf_generator.py` 2. Créer `app/utils/pdf_generator.py`
3. Créer `app/utils/markdown_parser.py` 3. Créer `app/utils/help_renderer.py`
4. Créer `app/utils/ssh_utils.py` 4. Créer `app/utils/ssh_utils.py`
5. Créer `app/utils/helpers.py` 5. Créer `app/utils/helpers.py`
@ -392,7 +392,7 @@ projet/
│ ├── __init__.py # ✅ │ ├── __init__.py # ✅
│ ├── ssh_utils.py # ✅ SSH & Bootstrap │ ├── ssh_utils.py # ✅ SSH & Bootstrap
│ ├── pdf_generator.py # ✅ Markdown to PDF │ ├── pdf_generator.py # ✅ Markdown to PDF
│ └── markdown_parser.py # ✅ HTML to Markdown │ └── help_renderer.py # ✅ help.md -> HTML + markdown clean
``` ```
--- ---

View File

@ -6,11 +6,11 @@ and that the PDF generator returns a non-empty PDF payload.
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient
# Ensure project root on path # Ensure project root on path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@ -18,10 +18,16 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_help_markdown_builder_exists_and_non_empty(): async def test_help_markdown_builder_exists_and_non_empty():
from app import app_optimized # type: ignore from app import create_app
assert hasattr(app_optimized, "_build_help_markdown") app = create_app()
md = app_optimized._build_help_markdown() client = TestClient(app)
resp = client.get(
"/api/help/documentation.md",
headers={"X-API-Key": "dev-key-1234567890"},
)
assert resp.status_code == 200
md = resp.text
assert isinstance(md, str) assert isinstance(md, str)
assert len(md) > 100 assert len(md) > 100
assert "Guide d'Utilisation" in md or "Démarrage Rapide" in md assert "Guide d'Utilisation" in md or "Démarrage Rapide" in md
@ -31,19 +37,42 @@ async def test_help_markdown_builder_exists_and_non_empty():
async def test_help_pdf_generator_returns_pdf_bytes(): async def test_help_pdf_generator_returns_pdf_bytes():
pytest.importorskip("reportlab") pytest.importorskip("reportlab")
pytest.importorskip("PIL") pytest.importorskip("PIL")
from app import app_optimized # type: ignore from app import create_app
md = app_optimized._build_help_markdown()
pdf_bytes = app_optimized._markdown_to_pdf_bytes(md)
app = create_app()
client = TestClient(app)
resp = client.get(
"/api/help/documentation.pdf",
headers={"X-API-Key": "dev-key-1234567890"},
)
assert resp.status_code == 200
pdf_bytes = resp.content
assert isinstance(pdf_bytes, (bytes, bytearray)) assert isinstance(pdf_bytes, (bytes, bytearray))
assert len(pdf_bytes) > 1000 assert len(pdf_bytes) > 1000
assert bytes(pdf_bytes[:4]) == b"%PDF" assert bytes(pdf_bytes[:4]) == b"%PDF"
def test_help_routes_registered(): def test_help_routes_registered():
from app.app_optimized import app # type: ignore from app import create_app
app = create_app()
paths = {r.path for r in app.routes} paths = {r.path for r in app.routes}
assert "/api/help/content" in paths
assert "/api/help/documentation.md" in paths assert "/api/help/documentation.md" in paths
assert "/api/help/documentation.pdf" in paths assert "/api/help/documentation.pdf" in paths
def test_help_content_endpoint_returns_html_and_toc():
from app import create_app
app = create_app()
client = TestClient(app)
resp = client.get(
"/api/help/content",
headers={"X-API-Key": "dev-key-1234567890"},
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data.get("content"), str)
assert isinstance(data.get("toc"), str)
assert "help-quickstart" in data["toc"]