Enhance host status tracking by parsing Ansible PLAY RECAP to update host reachability and last_seen timestamps after health-check playbook executions, add inventory group resolution to host API responses, and trigger automatic data refresh in dashboard after task completion to reflect updated host health indicators
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2025-12-22 10:43:17 -05:00
parent 6c51fb5c75
commit 46823eb42d
15 changed files with 1161 additions and 8 deletions
+6
View File
@@ -998,6 +998,12 @@ class DashboardManager {
// Rafraîchir les logs de tâches pour voir la tâche terminée
this.refreshTaskLogs();
// Rafraîchir aussi les hosts/métriques : un health-check met à jour status/last_seen côté backend
// (sinon l'indicateur de santé peut rester sur l'ancien état)
this.loadAllData().catch((e) => {
console.error('Erreur rafraîchissement données après fin de tâche:', e);
});
// Notification
const status = taskData.status || 'completed';
+96
View File
@@ -7,6 +7,7 @@ import subprocess
from datetime import datetime, timezone
from time import perf_counter
from typing import Optional, Dict, Any
import re
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.dependencies import get_db, verify_api_key
from app.crud.task import TaskRepository
from app.crud.host import HostRepository
from app.schemas.ansible import AnsibleExecutionRequest, AdHocCommandRequest, AdHocCommandResult, BootstrapRequest
from app.schemas.task_api import Task
from app.schemas.common import CommandResult
@@ -35,6 +37,50 @@ router = APIRouter()
task_log_service = TaskLogService(settings.tasks_logs_dir)
def _parse_ansible_play_recap(stdout: str) -> dict[str, dict[str, int]]:
if not stdout:
return {}
recap: dict[str, dict[str, int]] = {}
pattern = re.compile(
r"^\s*(?P<host>[^\s:]+)\s*:\s*.*?unreachable=(?P<unreachable>\d+)\s+failed=(?P<failed>\d+)\b",
re.IGNORECASE,
)
for line in stdout.splitlines():
m = pattern.match(line)
if not m:
continue
host = m.group("host")
recap[host] = {
"unreachable": int(m.group("unreachable")),
"failed": int(m.group("failed")),
}
return recap
async def _resolve_host_from_recap_name(
host_repo: HostRepository,
recap_host_name: str,
inventory_alias_to_host: dict[str, str],
):
host = await host_repo.get_by_name(recap_host_name)
if not host:
host = await host_repo.get_by_ip(recap_host_name)
if host:
return host
mapped = inventory_alias_to_host.get(recap_host_name)
if mapped:
host = await host_repo.get_by_ip(mapped)
if not host:
host = await host_repo.get_by_name(mapped)
if host:
return host
return None
@router.get("/playbooks")
async def get_ansible_playbooks(
target: Optional[str] = None,
@@ -327,6 +373,56 @@ async def execute_ansible_playbook(
error_message=task.error,
result_data={"output": result.get("stdout", "")[:5000]}
)
# Si c'est un health-check, mettre à jour le statut/last_seen des hosts impliqués
if request.playbook and "health-check" in request.playbook and request.target:
try:
host_repo = HostRepository(db_session)
now = datetime.now(timezone.utc)
inventory_alias_to_host: dict[str, str] = {}
try:
for inv_host in ansible_service.get_hosts_from_inventory():
if inv_host.name and inv_host.ansible_host:
inventory_alias_to_host[inv_host.name] = inv_host.ansible_host
except Exception:
inventory_alias_to_host = {}
recap = _parse_ansible_play_recap(result.get("stdout", "") or "")
if recap:
for host_name, counters in recap.items():
unreachable = int(counters.get("unreachable", 0) or 0)
reachable = unreachable == 0
status = "online" if reachable else "offline"
host = await _resolve_host_from_recap_name(
host_repo,
host_name,
inventory_alias_to_host,
)
if host:
await host_repo.update(
host,
status=status,
reachable=reachable,
last_seen=now,
)
elif request.target != "all":
host = await _resolve_host_from_recap_name(
host_repo,
request.target,
inventory_alias_to_host,
)
if host:
await host_repo.update(
host,
status="online" if bool(result.get("success")) else "offline",
reachable=bool(result.get("success")),
last_seen=now,
)
except Exception:
pass
await db_session.commit()
# Notification
+37 -5
View File
@@ -18,8 +18,13 @@ from app.services import ansible_service, ws_manager
router = APIRouter()
def _host_to_response(host, bootstrap=None) -> dict:
def _host_to_response(host, bootstrap=None, inventory_groups: Optional[List[str]] = None) -> dict:
"""Convertit un modèle Host DB en réponse API."""
groups: List[str] = [host.ansible_group] if host.ansible_group else []
if inventory_groups:
for g in inventory_groups:
if g and g not in groups:
groups.append(g)
return {
"id": host.id,
"name": host.name,
@@ -28,7 +33,7 @@ def _host_to_response(host, bootstrap=None) -> dict:
"os": "Linux",
"last_seen": host.last_seen,
"created_at": host.created_at,
"groups": [host.ansible_group] if host.ansible_group else [],
"groups": groups,
"bootstrap_ok": bootstrap.status == "success" if bootstrap else False,
"bootstrap_date": bootstrap.last_attempt if bootstrap else None,
}
@@ -62,7 +67,15 @@ async def get_host_by_name(
raise HTTPException(status_code=404, detail=f"Hôte '{host_name}' non trouvé")
bootstrap = await bs_repo.latest_for_host(host.id)
return _host_to_response(host, bootstrap)
inventory_groups: Optional[List[str]] = None
try:
inv_hosts = ansible_service.get_hosts_from_inventory()
inv_match = next((h for h in inv_hosts if h.name == host.name), None)
inventory_groups = inv_match.groups if inv_match else None
except Exception:
inventory_groups = None
return _host_to_response(host, bootstrap, inventory_groups=inventory_groups)
@router.post("/refresh")
@@ -154,7 +167,15 @@ async def get_host(
raise HTTPException(status_code=404, detail="Hôte non trouvé")
bootstrap = await bs_repo.latest_for_host(host.id)
return _host_to_response(host, bootstrap)
inventory_groups: Optional[List[str]] = None
try:
inv_hosts = ansible_service.get_hosts_from_inventory()
inv_match = next((h for h in inv_hosts if h.name == host.name), None)
inventory_groups = inv_match.groups if inv_match else None
except Exception:
inventory_groups = None
return _host_to_response(host, bootstrap, inventory_groups=inventory_groups)
@router.get("")
@@ -198,6 +219,13 @@ async def get_hosts(
})
return fallback_results
inventory_groups_map = {}
try:
for inv_host in ansible_service.get_hosts_from_inventory():
inventory_groups_map[inv_host.name] = inv_host.groups
except Exception:
inventory_groups_map = {}
result = []
for host in hosts:
bootstrap = await bs_repo.latest_for_host(host.id)
@@ -206,7 +234,11 @@ async def get_hosts(
continue
if bootstrap_status == "not_configured" and bootstrap and bootstrap.status == "success":
continue
result.append(_host_to_response(host, bootstrap))
result.append(_host_to_response(
host,
bootstrap,
inventory_groups=inventory_groups_map.get(host.name),
))
return result
+114 -1
View File
@@ -7,6 +7,7 @@ import asyncio
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import re
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +17,7 @@ from app.core.constants import ACTION_PLAYBOOK_MAP, ACTION_DISPLAY_NAMES
from app.core.dependencies import get_db, verify_api_key
from app.crud.task import TaskRepository
from app.crud.log import LogRepository
from app.crud.host import HostRepository
from app.schemas.task_api import TaskRequest
from app.services import ws_manager, db, ansible_service, notification_service
from app.services.task_log_service import TaskLogService
@@ -33,6 +35,56 @@ task_log_service = TaskLogService(settings.tasks_logs_dir)
running_task_handles = {}
def _parse_ansible_play_recap(stdout: str) -> dict[str, dict[str, int]]:
"""Parse Ansible PLAY RECAP to extract per-host unreachable/failed counters.
Returns a mapping host_name -> {"unreachable": int, "failed": int}.
"""
if not stdout:
return {}
# Typical line:
# host1 : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
recap: dict[str, dict[str, int]] = {}
pattern = re.compile(
r"^\s*(?P<host>[^\s:]+)\s*:\s*.*?unreachable=(?P<unreachable>\d+)\s+failed=(?P<failed>\d+)\b",
re.IGNORECASE,
)
for line in stdout.splitlines():
m = pattern.match(line)
if not m:
continue
host = m.group("host")
recap[host] = {
"unreachable": int(m.group("unreachable")),
"failed": int(m.group("failed")),
}
return recap
async def _resolve_host_from_recap_name(
host_repo: HostRepository,
recap_host_name: str,
inventory_alias_to_host: dict[str, str],
):
host = await host_repo.get_by_name(recap_host_name)
if not host:
host = await host_repo.get_by_ip(recap_host_name)
if host:
return host
mapped = inventory_alias_to_host.get(recap_host_name)
if mapped:
host = await host_repo.get_by_ip(mapped)
if not host:
host = await host_repo.get_by_name(mapped)
if host:
return host
return None
@router.get("")
async def get_tasks(
limit: int = 100,
@@ -428,7 +480,68 @@ async def _execute_task_playbook(
error_message=mem_task.error,
result_data={"output": result.get("stdout", "")[:5000]}
)
await session.commit()
# Si c'est un health-check ciblé, mettre à jour le statut/last_seen de l'hôte
if playbook and "health-check" in playbook and target:
try:
host_repo = HostRepository(session)
now = datetime.now(timezone.utc)
inventory_alias_to_host: dict[str, str] = {}
try:
for inv_host in ansible_service.get_hosts_from_inventory():
if inv_host.name and inv_host.ansible_host:
inventory_alias_to_host[inv_host.name] = inv_host.ansible_host
except Exception:
inventory_alias_to_host = {}
recap = _parse_ansible_play_recap(result.get("stdout", "") or "")
# If we have a recap, update each host that appears in the execution.
if recap:
for host_name, counters in recap.items():
unreachable = int(counters.get("unreachable", 0) or 0)
failed = int(counters.get("failed", 0) or 0)
# reachable means Ansible could contact the host (unreachable=0)
reachable = unreachable == 0
# status represents connectivity (not task success)
status = "online" if reachable else "offline"
host = await _resolve_host_from_recap_name(
host_repo,
host_name,
inventory_alias_to_host,
)
if host:
await host_repo.update(
host,
status=status,
reachable=reachable,
last_seen=now,
)
# No recap: keep old behavior for targeted host, but avoid touching all hosts blindly.
elif target != "all":
host = await _resolve_host_from_recap_name(
host_repo,
target,
inventory_alias_to_host,
)
if host:
await host_repo.update(
host,
status="online" if bool(success) else "offline",
reachable=bool(success),
last_seen=now,
)
except Exception:
# Ne pas faire échouer la tâche si la MAJ de statut host échoue
pass
await session.commit()
# Sauvegarder le log markdown
try: