diff --git a/app/app_optimized.py b/app/app_optimized.py index a0c2fa7..9211d47 100644 --- a/app/app_optimized.py +++ b/app/app_optimized.py @@ -31,13 +31,10 @@ import pytz from fastapi import FastAPI, HTTPException, Depends, Request, Form, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response from fastapi.security import APIKeyHeader -from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from html.parser import HTMLParser from io import BytesIO from xml.sax.saxutils import escape as _xml_escape -import hashlib from pydantic import BaseModel, Field, field_validator, ConfigDict from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker 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.schedule import ScheduleRepository # 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 BASE_DIR = Path(__file__).resolve().parent @@ -102,452 +97,6 @@ ACTION_PLAYBOOK_MAP = { # Gestionnaire de clés API 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[\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.*)$", - 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 class CommandResult(BaseModel): status: str @@ -668,14 +217,12 @@ class HostRequest(BaseModel): 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)") - class HostUpdateRequest(BaseModel): """Requête de mise à jour d'un hôte""" 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") ansible_host: Optional[str] = Field(default=None, description="Nouvelle adresse ansible_host") - class GroupRequest(BaseModel): """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)") @@ -696,7 +243,6 @@ class GroupRequest(BaseModel): raise ValueError("Le type doit être 'env' ou 'role'") return v - class GroupUpdateRequest(BaseModel): """Requête pour modifier un 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') return v - class GroupDeleteRequest(BaseModel): """Requête pour supprimer un groupe""" move_hosts_to: Optional[str] = Field(default=None, description="Groupe vers lequel déplacer les hôtes") - class AdHocCommandRequest(BaseModel): """Requête pour exécuter une commande ad-hoc Ansible""" 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") category: Optional[str] = Field(default="default", description="Catégorie d'historique pour cette commande") - class AdHocCommandResult(BaseModel): """Résultat d'une commande ad-hoc""" target: str @@ -736,7 +279,6 @@ class AdHocCommandResult(BaseModel): duration: float hosts_results: Optional[Dict[str, Any]] = None - class AdHocHistoryEntry(BaseModel): """Entrée dans l'historique des commandes ad-hoc""" id: str @@ -750,7 +292,6 @@ class AdHocHistoryEntry(BaseModel): last_used: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) use_count: int = 1 - class AdHocHistoryCategory(BaseModel): """Catégorie pour organiser les commandes ad-hoc""" name: str @@ -758,7 +299,6 @@ class AdHocHistoryCategory(BaseModel): color: str = "#7c3aed" icon: str = "fa-folder" - class TaskLogFile(BaseModel): """Représentation d'un fichier de log de tâche""" id: str @@ -784,7 +324,6 @@ class TaskLogFile(BaseModel): target_type: Optional[str] = None # Type de cible: 'host', 'group', 'role' source_type: Optional[str] = None # Source: 'scheduled', 'manual', 'adhoc' - class TasksFilterParams(BaseModel): """Paramètres de filtrage des tâches""" status: Optional[str] = None # pending, running, completed, failed, all @@ -799,7 +338,6 @@ class TasksFilterParams(BaseModel): limit: int = 50 # Pagination côté serveur offset: int = 0 - # ===== MODÈLES PLANIFICATEUR (SCHEDULER) ===== 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") cron_expression: Optional[str] = Field(default=None, description="Expression cron pour custom") - class Schedule(BaseModel): """Modèle d'un schedule de playbook""" 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 return v - class ScheduleRun(BaseModel): """Historique d'une exécution de schedule""" 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 } - class ScheduleCreateRequest(BaseModel): """Requête de création d'un schedule""" name: str = Field(..., min_length=3, max_length=100) @@ -898,7 +433,6 @@ class ScheduleCreateRequest(BaseModel): except pytz.exceptions.UnknownTimeZoneError: raise ValueError(f"Fuseau horaire invalide: {v}") - class ScheduleUpdateRequest(BaseModel): """Requête de mise à jour d'un schedule""" 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) tags: Optional[List[str]] = Field(default=None) - class ScheduleStats(BaseModel): """Statistiques globales des schedules""" total: int = 0 @@ -931,7 +464,6 @@ class ScheduleStats(BaseModel): executions_24h: int = 0 success_rate_7d: float = 0.0 - # ===== SERVICE DE LOGGING MARKDOWN ===== 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") 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) async def root(request: Request): """Page principale du dashboard""" diff --git a/app/index.html b/app/index.html index 560540b..f23a8fb 100644 --- a/app/index.html +++ b/app/index.html @@ -3727,724 +3727,26 @@
- + - -
- - -
-

- ⚡️ - Démarrage Rapide -

-
-
-
-

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. -

-
-
-
-

Bootstrap Ansible

-

- Exécutez le Bootstrap sur chaque host pour configurer - l'accès SSH et les prérequis Ansible. -

-
-
-
-

Automatiser

-

- Utilisez les Actions Rapides ou exécutez des playbooks - personnalisés pour automatiser vos tâches. -

+ +
+ +
+
+

Chargement de la documentation...

- - -
-

- ❤️‍🩹 - 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

-
- -
-
-
-
-
-
-
-
-
- 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

-
-
-
- - Statut en ligne - +2 points -
-

Le host répond aux requêtes réseau

-
- -
-
- - Bootstrap Ansible OK - +1 point -
-

SSH et prérequis Ansible configurés

-
- -
-
- - Vérifié récemment (<1h) - +2 points -
-

Dernière vérification il y a moins d'une heure

-
- -
-
- - Vérifié aujourd'hui - +1 point -
-

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

-
    -
  • - - Backend: FastAPI (Python) - API REST haute performance -
  • -
  • - - Automation: Ansible - Gestion de configuration -
  • -
  • - - Frontend: HTML/CSS/JS avec TailwindCSS -
  • -
  • - - Déploiement: Docker & Docker Compose -
  • -
  • - - Temps réel: WebSocket pour les mises à jour live -
  • -
-
-
-

Structure des Fichiers

-
-
-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
-
-
-
-
- - -
-

- ⚙️ - Fonctionnalités Détaillées 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 & 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. -

- Requis avant toute autre opération -
-
-

- - health-check.yml -

-

- Vérifie l'état de santé: CPU, RAM, disque, services critiques. -

- Exécution rapide, non destructif -
-
-

- - system-upgrade.yml -

-

- Met à jour tous les paquets système (apt/yum/dnf selon l'OS). -

- Peut nécessiter un redémarrage -
-
-

- - backup-config.yml -

-

- Sauvegarde les fichiers de configuration importants (/etc, configs apps). -

- Stockage local ou distant -
-
-
- - -
-

- 🔗 - Référence API -

-

- L'API REST est accessible sur le port configuré. Authentification via header Authorization: Bearer <token>. -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EndpointMéthodeDescription
/api/hostsGETListe tous les hosts
/api/hostsPOSTAjoute un nouveau host
/api/tasks/logsGETRécupère les logs de tâches
/api/ansible/playbooksGETListe les playbooks disponibles
/api/ansible/executePOSTExécute un playbook
/api/metricsGETMétriques du dashboard
-
-
- - -
-

- 🛠️ - 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 & 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
  • -
-
-
-
- -
-
@@ -4532,6 +3834,62 @@ homelab-automation/ targetPage.querySelectorAll('.fade-in').forEach(el => { 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 = ` +
+ +

Impossible de charger la documentation.

+ +
+ `; } } diff --git a/app/routes/help.py b/app/routes/help.py index 79bf008..f079f9c 100644 --- a/app/routes/help.py +++ b/app/routes/help.py @@ -1,30 +1,46 @@ """ Routes API pour l'aide et la documentation. +Source unique: app/static/help.md """ from pathlib import Path 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.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 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") async def download_help_markdown(api_key_valid: bool = Depends(verify_api_key)): """Télécharge la documentation d'aide en format Markdown.""" - # Essayer de charger depuis index.html - html_path = settings.base_dir / "index.html" - markdown_content = build_help_markdown(html_path=html_path) + markdown_content = get_raw_markdown(HELP_MD_PATH) return Response( content=markdown_content, - media_type="text/markdown", + media_type="text/markdown; charset=utf-8", headers={ "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") async def download_help_pdf(api_key_valid: bool = Depends(verify_api_key)): """Télécharge la documentation d'aide en format PDF.""" - # Essayer de charger depuis index.html - html_path = settings.base_dir / "index.html" - markdown_content = build_help_markdown(html_path=html_path) + markdown_content = get_raw_markdown(HELP_MD_PATH) pdf_bytes = markdown_to_pdf_bytes( markdown_content, diff --git a/app/static/help.md b/app/static/help.md new file mode 100644 index 0000000..e746547 --- /dev/null +++ b/app/static/help.md @@ -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} + + +:::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. +::: + + +--- + +## ❤️‍🩹 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 `. + +| 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* diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 271864a..25e05d2 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -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.pdf_generator import markdown_to_pdf_bytes -from app.utils.markdown_parser import build_help_markdown __all__ = [ "find_ssh_private_key", "run_ssh_command", "bootstrap_host", "markdown_to_pdf_bytes", - "build_help_markdown", ] diff --git a/app/utils/help_renderer.py b/app/utils/help_renderer.py new file mode 100644 index 0000000..ffdd50e --- /dev/null +++ b/app/utils/help_renderer.py @@ -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'
\n

{emoji}{text}

' + + html = re.sub(pattern, replace_section, html, flags=re.MULTILINE) + + # Nettoyer le premier
en trop + html = html.replace('
str: + """Traite les cartes de démarrage rapide.""" + # Trouver le bloc quickstart-cards + pattern = r'(.*?)' + + 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 = '
' + + for color, icon, title, description in cards: + color_class = f"text-{color}-400" + desc = self._process_inline_code(description.strip()) + cards_html += f''' +
+
+

{title}

+

{desc}

+
''' + + cards_html += '
' + 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'
' + else: + bars_html += '
' + + return f''' +
+
{bars_html}
+
+ {title} + ({subtitle}) +

{description}

+
+
''' + + 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''' +
+
+ + {title} + {points} +
+

{description}

+
''' + + 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''' +
+
+ + {badge_text} + +
+

{description}

+
''' + + 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''' +
+
+ + + {title} + + +
+
+
+

{description}

+ {list_content} +
+
+
''' + + 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''' +
+

+ + {filename} +

+

{description}

+ {note} +
''' + + 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', '

') + + return f''' +

+
+ {title} + +
+
+
+

{content}

+
+
+
''' + + 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''' +
+

+ + {label}: {content} +

+
''' + + 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 = '
' + html_table += '' + for h in headers: + html_table += f'' + html_table += '' + + html_table += '' + for row in rows: + html_table += '' + for cell in row: + cell = self._process_inline_code(cell) + html_table += f'' + html_table += '' + html_table += '
{h}
{cell}
' + + 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'
{code}
' + + 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'\1', 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('
    ') + in_list = True + item = line.strip()[2:] + item = self._process_bold_italic(item) + item = self._process_inline_code(item) + result.append(f'
  • {item}
  • ') + else: + if in_list: + result.append('
') + in_list = False + result.append(line) + + if in_list: + result.append('') + + return '\n'.join(result) + + def _process_headings(self, html: str) -> str: + """Traite les titres H3 et H4.""" + # H3 + html = re.sub( + r'^### ([^\n{]+)$', + r'

\1

', + html, + flags=re.MULTILINE + ) + # H4 + html = re.sub( + r'^#### ([^\n]+)$', + r'

\1

', + 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 ['#', '-', '>', '```', ':::', '', '', content) + content = re.sub(r'', '', 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() diff --git a/app/utils/markdown_parser.py b/app/utils/markdown_parser.py deleted file mode 100644 index 07127a0..0000000 --- a/app/utils/markdown_parser.py +++ /dev/null @@ -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 `. - -| 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* -""" diff --git a/data/homelab.db-shm b/data/homelab.db-shm index 3f2455c..0e5ffe9 100644 Binary files a/data/homelab.db-shm and b/data/homelab.db-shm differ diff --git a/data/homelab.db-wal b/data/homelab.db-wal index c2d8242..a772e77 100644 Binary files a/data/homelab.db-wal and b/data/homelab.db-wal differ diff --git a/documentation/REFACTORING_AUDIT.md b/documentation/REFACTORING_AUDIT.md index 80b6fde..2bf05c4 100644 --- a/documentation/REFACTORING_AUDIT.md +++ b/documentation/REFACTORING_AUDIT.md @@ -193,7 +193,7 @@ projet/ │ ├── utils/ │ │ ├── __init__.py │ │ ├── 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 │ │ └── helpers.py # Fonctions utilitaires diverses │ │ @@ -239,7 +239,7 @@ projet/ | Endpoints Health | 5902-6112 | `app/routes/health.py` | | Endpoints Schedules | 6388-6949 | `app/routes/schedules.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` | | Startup/Shutdown | 7025-7098 | `app/__init__.py` (create_app) | @@ -276,7 +276,7 @@ projet/ ### Étape 4: Utils 1. Créer `app/utils/__init__.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` 5. Créer `app/utils/helpers.py` @@ -392,7 +392,7 @@ projet/ │ ├── __init__.py # ✅ │ ├── ssh_utils.py # ✅ SSH & Bootstrap │ ├── pdf_generator.py # ✅ Markdown to PDF -│ └── markdown_parser.py # ✅ HTML to Markdown +│ └── help_renderer.py # ✅ help.md -> HTML + markdown clean ``` --- diff --git a/tests/test_help_downloads.py b/tests/test_help_downloads.py index 057c102..3353cac 100644 --- a/tests/test_help_downloads.py +++ b/tests/test_help_downloads.py @@ -6,11 +6,11 @@ and that the PDF generator returns a non-empty PDF payload. from __future__ import annotations -import os import sys from pathlib import Path import pytest +from fastapi.testclient import TestClient # Ensure project root on path 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 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") - md = app_optimized._build_help_markdown() + app = create_app() + 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 len(md) > 100 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(): pytest.importorskip("reportlab") pytest.importorskip("PIL") - from app import app_optimized # type: ignore - - md = app_optimized._build_help_markdown() - pdf_bytes = app_optimized._markdown_to_pdf_bytes(md) + from app import create_app + 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 len(pdf_bytes) > 1000 assert bytes(pdf_bytes[:4]) == b"%PDF" 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} + assert "/api/help/content" in paths assert "/api/help/documentation.md" 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"]