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
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:
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user