From 6c83ada7d19c50b973f56af44847b9eff0d4be48 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 14 Dec 2025 20:42:02 -0500 Subject: [PATCH] Remove HTML-to-Markdown parser and PDF generation functionality, clean up unused imports and whitespace --- app/app_optimized.py | 513 ------------------- app/index.html | 776 +++-------------------------- app/routes/help.py | 32 +- app/static/help.md | 317 ++++++++++++ app/utils/__init__.py | 2 - app/utils/help_renderer.py | 520 +++++++++++++++++++ app/utils/markdown_parser.py | 332 ------------ data/homelab.db-shm | Bin 32768 -> 32768 bytes data/homelab.db-wal | Bin 4272472 -> 4272472 bytes documentation/REFACTORING_AUDIT.md | 8 +- tests/test_help_downloads.py | 47 +- 11 files changed, 969 insertions(+), 1578 deletions(-) create mode 100644 app/static/help.md create mode 100644 app/utils/help_renderer.py delete mode 100644 app/utils/markdown_parser.py 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 3f2455c68eda167f6dc9939d57c331eab9ceb7b2..0e5ffe9b549d209750d94c770e43d2345ad58cba 100644 GIT binary patch literal 32768 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∋v9 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 Uls2GvfzlQfZ~V;Lc5B-I4;MsBSO5S3 delta 176880 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 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"]