Remove Node.js cache files containing npm vulnerability data for vitest and vite packages
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:
@@ -24,6 +24,7 @@ from app.routes.metrics import router as metrics_router
|
||||
from app.routes.builtin_playbooks import router as builtin_playbooks_router
|
||||
from app.routes.server import router as server_router
|
||||
from app.routes.alerts import router as alerts_router
|
||||
from app.routes.docker import router as docker_router
|
||||
|
||||
# Router principal qui agrège tous les sous-routers
|
||||
api_router = APIRouter()
|
||||
@@ -46,6 +47,7 @@ api_router.include_router(metrics_router, prefix="/metrics", tags=["Metrics"])
|
||||
api_router.include_router(builtin_playbooks_router, prefix="/builtin-playbooks", tags=["Builtin Playbooks"])
|
||||
api_router.include_router(server_router, prefix="/server", tags=["Server"])
|
||||
api_router.include_router(alerts_router, prefix="/alerts", tags=["Alerts"])
|
||||
api_router.include_router(docker_router, prefix="/docker", tags=["Docker"])
|
||||
|
||||
__all__ = [
|
||||
"api_router",
|
||||
|
||||
+111
-2
@@ -14,8 +14,9 @@ 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.schemas.ansible import AnsibleExecutionRequest, AdHocCommandRequest, AdHocCommandResult
|
||||
from app.schemas.ansible import AnsibleExecutionRequest, AdHocCommandRequest, AdHocCommandResult, BootstrapRequest
|
||||
from app.schemas.task_api import Task
|
||||
from app.schemas.common import CommandResult
|
||||
from app.schemas.common import LogEntry
|
||||
from app.services import (
|
||||
ansible_service,
|
||||
@@ -23,9 +24,10 @@ from app.services import (
|
||||
notification_service,
|
||||
adhoc_history_service,
|
||||
db,
|
||||
bootstrap_status_service,
|
||||
)
|
||||
from app.services.task_log_service import TaskLogService
|
||||
from app.utils.ssh_utils import find_ssh_private_key
|
||||
from app.utils.ssh_utils import find_ssh_private_key, bootstrap_host
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -110,6 +112,113 @@ async def get_ssh_config(api_key_valid: bool = Depends(verify_api_key)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bootstrap", response_model=CommandResult)
|
||||
async def bootstrap_ansible_host_compat(
|
||||
request: BootstrapRequest,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Compat: Bootstrap un hôte via /api/ansible/bootstrap (historique UI).
|
||||
|
||||
La route canonical est /api/bootstrap, mais l'UI historique appelle /api/ansible/bootstrap.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
logger = logging.getLogger("bootstrap_endpoint")
|
||||
|
||||
try:
|
||||
logger.info(f"[BOOTSTRAP] Bootstrap request for host={request.host}, user={request.automation_user}")
|
||||
result = bootstrap_host(
|
||||
host=request.host,
|
||||
root_password=request.root_password,
|
||||
automation_user=request.automation_user,
|
||||
)
|
||||
logger.info(f"[BOOTSTRAP] Bootstrap result: status={result.status}, return_code={result.return_code}")
|
||||
|
||||
if result.return_code != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"status": result.status,
|
||||
"return_code": result.return_code,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
},
|
||||
)
|
||||
|
||||
host_name = request.host
|
||||
for h in db.hosts:
|
||||
if h.ip == request.host or h.name == request.host:
|
||||
host_name = h.name
|
||||
break
|
||||
|
||||
bootstrap_status_service.set_bootstrap_status(
|
||||
host_name=host_name,
|
||||
success=True,
|
||||
details=f"Bootstrap réussi via API (user: {request.automation_user})",
|
||||
)
|
||||
|
||||
db.invalidate_hosts_cache()
|
||||
|
||||
log_entry = LogEntry(
|
||||
id=db.get_next_id("logs"),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
level="INFO",
|
||||
message=f"Bootstrap réussi pour {host_name} (user: {request.automation_user})",
|
||||
source="bootstrap",
|
||||
host=host_name,
|
||||
)
|
||||
db.logs.insert(0, log_entry)
|
||||
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "bootstrap_success",
|
||||
"data": {
|
||||
"host": host_name,
|
||||
"user": request.automation_user,
|
||||
"status": "ok",
|
||||
"bootstrap_ok": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.create_task(notification_service.notify_bootstrap_success(host_name))
|
||||
return result
|
||||
|
||||
except HTTPException as http_exc:
|
||||
error_detail = str(http_exc.detail) if http_exc.detail else "Erreur inconnue"
|
||||
asyncio.create_task(
|
||||
notification_service.notify_bootstrap_failed(
|
||||
hostname=request.host,
|
||||
error=error_detail[:200],
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[BOOTSTRAP] Bootstrap exception: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
log_entry = LogEntry(
|
||||
id=db.get_next_id("logs"),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
level="ERROR",
|
||||
message=f"Échec bootstrap pour {request.host}: {str(e)}",
|
||||
source="bootstrap",
|
||||
host=request.host,
|
||||
)
|
||||
db.logs.insert(0, log_entry)
|
||||
|
||||
asyncio.create_task(
|
||||
notification_service.notify_bootstrap_failed(
|
||||
hostname=request.host,
|
||||
error=str(e)[:200],
|
||||
)
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def execute_ansible_playbook(
|
||||
request: AnsibleExecutionRequest,
|
||||
|
||||
+101
-21
@@ -3,15 +3,18 @@ Routes API pour les builtin playbooks (collecte métriques).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.dependencies import get_db, verify_api_key
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.host_metrics import HostMetricsRepository
|
||||
from app.models.database import async_session_maker
|
||||
from app.services import ansible_service, ws_manager
|
||||
from app.services.builtin_playbooks import (
|
||||
BuiltinPlaybookService,
|
||||
@@ -23,6 +26,46 @@ from app.services.builtin_playbooks import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _sync_hosts_from_inventory(db_session: AsyncSession) -> int:
|
||||
repo = HostRepository(db_session)
|
||||
inventory_hosts = ansible_service.get_hosts_from_inventory()
|
||||
created_or_updated = 0
|
||||
|
||||
for inv_host in inventory_hosts:
|
||||
existing = await repo.get_by_name(inv_host.name)
|
||||
ip_address = inv_host.ansible_host or inv_host.name
|
||||
ansible_group = getattr(inv_host, "group", None)
|
||||
inv_groups = getattr(inv_host, "groups", None) or []
|
||||
docker_enabled = "role_docker" in inv_groups
|
||||
|
||||
if existing:
|
||||
await repo.update(
|
||||
existing,
|
||||
ip_address=ip_address,
|
||||
ansible_group=ansible_group,
|
||||
docker_enabled=docker_enabled or existing.docker_enabled,
|
||||
)
|
||||
created_or_updated += 1
|
||||
else:
|
||||
created = await repo.create(
|
||||
id=uuid.uuid4().hex,
|
||||
name=inv_host.name,
|
||||
ip_address=ip_address,
|
||||
ansible_group=ansible_group,
|
||||
status="unknown",
|
||||
reachable=False,
|
||||
last_seen=None,
|
||||
)
|
||||
if docker_enabled:
|
||||
await repo.update(created, docker_enabled=True)
|
||||
created_or_updated += 1
|
||||
|
||||
if created_or_updated:
|
||||
await db_session.commit()
|
||||
|
||||
return created_or_updated
|
||||
|
||||
|
||||
def _get_service() -> BuiltinPlaybookService:
|
||||
"""Récupère ou initialise le service builtin playbooks."""
|
||||
try:
|
||||
@@ -116,8 +159,8 @@ async def execute_builtin_playbook_background(
|
||||
}
|
||||
})
|
||||
|
||||
# Planifier l'exécution en arrière-plan
|
||||
background_tasks.add_task(asyncio.create_task, run_in_background())
|
||||
# Lancer en arrière-plan via la boucle asyncio active (retour immédiat)
|
||||
asyncio.create_task(run_in_background())
|
||||
|
||||
return {
|
||||
"message": f"Builtin playbook '{builtin_id}' planifié pour exécution sur {target}",
|
||||
@@ -134,41 +177,78 @@ async def collect_all_metrics(
|
||||
):
|
||||
"""Collecte les métriques de tous les hôtes."""
|
||||
host_repo = HostRepository(db_session)
|
||||
hosts = await host_repo.list(limit=1000)
|
||||
try:
|
||||
hosts = await host_repo.list(limit=1000)
|
||||
except OperationalError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Base de données non initialisée/migrations manquantes (hosts): {str(e)}",
|
||||
)
|
||||
|
||||
if not hosts:
|
||||
await _sync_hosts_from_inventory(db_session)
|
||||
hosts = await host_repo.list(limit=1000)
|
||||
|
||||
if not hosts:
|
||||
return {"message": "Aucun hôte trouvé", "hosts_count": 0}
|
||||
return {"success": True, "message": "Aucun hôte trouvé", "hosts_count": 0}
|
||||
|
||||
async def collect_for_all():
|
||||
service = _get_service()
|
||||
results = []
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
result = await service.execute_builtin("collect_system_info", host.name)
|
||||
results.append({
|
||||
"host": host.name,
|
||||
"success": result.get("success", False)
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"host": host.name,
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
async with async_session_maker() as session:
|
||||
host_repo_bg = HostRepository(session)
|
||||
metrics_repo = HostMetricsRepository(session)
|
||||
|
||||
hosts_bg = await host_repo_bg.list(limit=1000)
|
||||
|
||||
for host in hosts_bg:
|
||||
try:
|
||||
result = await service.execute_builtin("collect_system_info", host.name)
|
||||
ok = bool(result.get("success", False))
|
||||
|
||||
if ok:
|
||||
for hostname, metrics_data in result.get("parsed_metrics", {}).items():
|
||||
target_host = await host_repo_bg.get_by_name(hostname)
|
||||
if not target_host:
|
||||
continue
|
||||
|
||||
metrics_create = service.create_metrics_from_parsed(
|
||||
host_id=target_host.id,
|
||||
parsed_data=metrics_data,
|
||||
builtin_id="collect_system_info",
|
||||
execution_time_ms=result.get("execution_time_ms", 0),
|
||||
)
|
||||
await metrics_repo.create(**metrics_create.model_dump())
|
||||
|
||||
await session.commit()
|
||||
|
||||
results.append({
|
||||
"host": host.name,
|
||||
"success": ok,
|
||||
})
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
results.append({
|
||||
"host": host.name,
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "metrics_collection_complete",
|
||||
"data": {
|
||||
"total": len(hosts),
|
||||
"total": len(results),
|
||||
"success": sum(1 for r in results if r.get("success")),
|
||||
"failed": sum(1 for r in results if not r.get("success"))
|
||||
}
|
||||
})
|
||||
|
||||
background_tasks.add_task(asyncio.create_task, collect_for_all())
|
||||
# Lancer en arrière-plan via la boucle asyncio active (retour immédiat)
|
||||
asyncio.create_task(collect_for_all())
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Collecte des métriques lancée pour {len(hosts)} hôte(s)",
|
||||
"hosts_count": len(hosts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Docker management API routes."""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_db, verify_api_key, get_current_user, require_admin
|
||||
from app.crud.docker_container import DockerContainerRepository
|
||||
from app.crud.docker_image import DockerImageRepository
|
||||
from app.crud.docker_volume import DockerVolumeRepository
|
||||
from app.crud.docker_alert import DockerAlertRepository
|
||||
from app.crud.host import HostRepository
|
||||
from app.schemas.docker import (
|
||||
DockerHostListResponse,
|
||||
DockerHostInfo,
|
||||
EnableDockerRequest,
|
||||
DockerContainerListResponse,
|
||||
DockerContainerResponse,
|
||||
DockerImageListResponse,
|
||||
DockerImageExtendedListResponse,
|
||||
DockerImageExtendedResponse,
|
||||
DockerVolumeListResponse,
|
||||
DockerAlertListResponse,
|
||||
DockerAlertAcknowledgeRequest,
|
||||
ContainerActionResponse,
|
||||
ContainerLogsRequest,
|
||||
ContainerLogsResponse,
|
||||
ContainerInspectResponse,
|
||||
DockerCollectResponse,
|
||||
DockerCollectAllResponse,
|
||||
DockerStatsResponse,
|
||||
ImageActionResponse,
|
||||
)
|
||||
from app.services.docker_service import docker_service
|
||||
from app.services.docker_actions import docker_actions
|
||||
from app.services.docker_alerts import docker_alerts_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# === Docker Hosts ===
|
||||
|
||||
@router.get("/hosts", response_model=DockerHostListResponse)
|
||||
async def list_docker_hosts(
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""List all hosts with Docker information."""
|
||||
hosts = await docker_service.get_docker_hosts()
|
||||
enabled = sum(1 for h in hosts if h.get("docker_enabled"))
|
||||
|
||||
return DockerHostListResponse(
|
||||
hosts=[DockerHostInfo(**h) for h in hosts],
|
||||
total=len(hosts),
|
||||
enabled=enabled
|
||||
)
|
||||
|
||||
|
||||
@router.post("/hosts/{host_id}/enable")
|
||||
async def enable_docker_monitoring(
|
||||
host_id: str,
|
||||
request: EnableDockerRequest = None,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Enable or disable Docker monitoring on a host."""
|
||||
enabled = request.enabled if request else True
|
||||
success = await docker_service.enable_docker_monitoring(host_id, enabled)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
action = "enabled" if enabled else "disabled"
|
||||
return {"message": f"Docker monitoring {action} for host", "host_id": host_id}
|
||||
|
||||
|
||||
@router.post("/hosts/{host_id}/collect", response_model=DockerCollectResponse)
|
||||
async def collect_docker_now(
|
||||
host_id: str,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Force an immediate Docker collection on a host."""
|
||||
result = await docker_service.collect_docker_host(host_id)
|
||||
|
||||
return DockerCollectResponse(
|
||||
success=result.get("success", False),
|
||||
host_id=host_id,
|
||||
host_name=result.get("host_name", ""),
|
||||
message="Collection completed" if result.get("success") else "Collection failed",
|
||||
docker_version=result.get("docker_version"),
|
||||
containers_count=result.get("containers_count", 0),
|
||||
images_count=result.get("images_count", 0),
|
||||
volumes_count=result.get("volumes_count", 0),
|
||||
duration_ms=result.get("duration_ms", 0),
|
||||
error=result.get("error")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/collect-all", response_model=DockerCollectAllResponse)
|
||||
async def collect_all_docker_hosts(
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Collect Docker info from all enabled hosts."""
|
||||
result = await docker_service.collect_all_hosts()
|
||||
|
||||
normalized_results = []
|
||||
for r in result.get("results", []):
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
normalized_results.append({
|
||||
"success": bool(r.get("success", False)),
|
||||
"host_id": r.get("host_id", ""),
|
||||
"host_name": r.get("host_name", ""),
|
||||
"message": r.get("message") or ("Collection completed" if r.get("success") else "Collection failed"),
|
||||
"docker_version": r.get("docker_version"),
|
||||
"containers_count": r.get("containers_count", 0),
|
||||
"images_count": r.get("images_count", 0),
|
||||
"volumes_count": r.get("volumes_count", 0),
|
||||
"duration_ms": r.get("duration_ms", 0),
|
||||
"error": r.get("error"),
|
||||
})
|
||||
|
||||
return DockerCollectAllResponse(
|
||||
success=result.get("success", False),
|
||||
message=f"Collected from {result.get('successful', 0)}/{result.get('total_hosts', 0)} hosts",
|
||||
total_hosts=result.get("total_hosts", 0),
|
||||
successful=result.get("successful", 0),
|
||||
failed=result.get("failed", 0),
|
||||
results=[DockerCollectResponse(**r) for r in normalized_results]
|
||||
)
|
||||
|
||||
|
||||
# === Containers ===
|
||||
|
||||
@router.get("/hosts/{host_id}/containers", response_model=DockerContainerListResponse)
|
||||
async def get_containers(
|
||||
host_id: str,
|
||||
state: Optional[str] = Query(None, description="Filter by state"),
|
||||
compose_project: Optional[str] = Query(None, description="Filter by compose project"),
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List containers for a host."""
|
||||
container_repo = DockerContainerRepository(db_session)
|
||||
containers = await container_repo.list_by_host(host_id, state=state, compose_project=compose_project)
|
||||
counts = await container_repo.count_by_host(host_id)
|
||||
|
||||
return DockerContainerListResponse(
|
||||
containers=[DockerContainerResponse(
|
||||
id=c.id,
|
||||
host_id=c.host_id,
|
||||
container_id=c.container_id,
|
||||
name=c.name,
|
||||
image=c.image,
|
||||
state=c.state,
|
||||
status=c.status,
|
||||
health=c.health,
|
||||
ports=c.ports,
|
||||
labels=c.labels,
|
||||
compose_project=c.compose_project,
|
||||
created_at=c.created_at,
|
||||
last_update_at=c.last_update_at
|
||||
) for c in containers],
|
||||
total=counts.get("total", 0),
|
||||
running=counts.get("running", 0),
|
||||
stopped=counts.get("total", 0) - counts.get("running", 0)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/containers/{host_id}/{container_id}/start", response_model=ContainerActionResponse)
|
||||
async def start_container(
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Start a stopped container."""
|
||||
result = await docker_actions.start_container(host_id, container_id)
|
||||
return ContainerActionResponse(**result)
|
||||
|
||||
|
||||
@router.post("/containers/{host_id}/{container_id}/stop", response_model=ContainerActionResponse)
|
||||
async def stop_container(
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
timeout: int = Query(10, ge=1, le=300),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Stop a running container."""
|
||||
result = await docker_actions.stop_container(host_id, container_id, timeout=timeout)
|
||||
return ContainerActionResponse(**result)
|
||||
|
||||
|
||||
@router.post("/containers/{host_id}/{container_id}/restart", response_model=ContainerActionResponse)
|
||||
async def restart_container(
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
timeout: int = Query(10, ge=1, le=300),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Restart a container."""
|
||||
result = await docker_actions.restart_container(host_id, container_id, timeout=timeout)
|
||||
return ContainerActionResponse(**result)
|
||||
|
||||
|
||||
@router.post("/containers/{host_id}/{container_id}/remove", response_model=ContainerActionResponse)
|
||||
async def remove_container(
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
force: bool = Query(False, description="Force remove running container"),
|
||||
remove_volumes: bool = Query(False, description="Remove associated volumes"),
|
||||
user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Remove a container (admin only)."""
|
||||
result = await docker_actions.remove_container(
|
||||
host_id, container_id, force=force, remove_volumes=remove_volumes
|
||||
)
|
||||
return ContainerActionResponse(**result)
|
||||
|
||||
|
||||
@router.post("/containers/{host_id}/{container_id}/redeploy", response_model=ContainerActionResponse)
|
||||
async def redeploy_container(
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Redeploy a container by pulling latest image."""
|
||||
result = await docker_actions.redeploy_container(host_id, container_id)
|
||||
return ContainerActionResponse(**result)
|
||||
|
||||
|
||||
@router.get("/containers/{host_id}/{container_id}/logs", response_model=ContainerLogsResponse)
|
||||
async def get_container_logs(
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
tail: int = Query(200, ge=1, le=5000),
|
||||
timestamps: bool = Query(False),
|
||||
since: Optional[str] = Query(None),
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Get container logs."""
|
||||
result = await docker_actions.get_container_logs(
|
||||
host_id, container_id, tail=tail, timestamps=timestamps, since=since
|
||||
)
|
||||
return ContainerLogsResponse(**result)
|
||||
|
||||
|
||||
@router.get("/containers/{host_id}/{container_id}/inspect", response_model=ContainerInspectResponse)
|
||||
async def inspect_container(
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Get detailed container information."""
|
||||
result = await docker_actions.inspect_container(host_id, container_id)
|
||||
return ContainerInspectResponse(**result)
|
||||
|
||||
|
||||
# === Images ===
|
||||
|
||||
@router.get("/hosts/{host_id}/images", response_model=DockerImageExtendedListResponse)
|
||||
async def get_images(
|
||||
host_id: str,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List images for a host with usage information."""
|
||||
image_repo = DockerImageRepository(db_session)
|
||||
container_repo = DockerContainerRepository(db_session)
|
||||
|
||||
images = await image_repo.list_by_host(host_id)
|
||||
counts = await image_repo.count_by_host(host_id)
|
||||
|
||||
# Get all container images to determine which images are in use
|
||||
containers = await container_repo.list_by_host(host_id)
|
||||
used_images = set()
|
||||
for c in containers:
|
||||
if c.image:
|
||||
used_images.add(c.image)
|
||||
# Also add the image without tag for matching
|
||||
if ':' in c.image:
|
||||
used_images.add(c.image.split(':')[0])
|
||||
|
||||
unused_count = 0
|
||||
images_with_usage = []
|
||||
for img in images:
|
||||
# Check if image is in use by comparing repo_tags
|
||||
in_use = False
|
||||
if img.repo_tags:
|
||||
for tag in img.repo_tags:
|
||||
if tag in used_images:
|
||||
in_use = True
|
||||
break
|
||||
# Check without tag
|
||||
if ':' in tag and tag.split(':')[0] in used_images:
|
||||
in_use = True
|
||||
break
|
||||
|
||||
if not in_use:
|
||||
unused_count += 1
|
||||
|
||||
images_with_usage.append({
|
||||
"id": img.id,
|
||||
"host_id": img.host_id,
|
||||
"image_id": img.image_id,
|
||||
"repo_tags": img.repo_tags,
|
||||
"size": img.size,
|
||||
"created": img.created,
|
||||
"last_update_at": img.last_update_at,
|
||||
"in_use": in_use
|
||||
})
|
||||
|
||||
return DockerImageExtendedListResponse(
|
||||
images=[DockerImageExtendedResponse(**img) for img in images_with_usage],
|
||||
total=counts.get("total", 0),
|
||||
total_size=counts.get("total_size", 0),
|
||||
unused_count=unused_count
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/images/{host_id}/{image_id}", response_model=ImageActionResponse)
|
||||
async def remove_image(
|
||||
host_id: str,
|
||||
image_id: str,
|
||||
force: bool = Query(False, description="Force remove even if image is in use"),
|
||||
user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Remove a Docker image (admin only)."""
|
||||
result = await docker_actions.remove_image(host_id, image_id, force=force)
|
||||
return ImageActionResponse(**result)
|
||||
|
||||
|
||||
# === Volumes ===
|
||||
|
||||
@router.get("/hosts/{host_id}/volumes", response_model=DockerVolumeListResponse)
|
||||
async def get_volumes(
|
||||
host_id: str,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List volumes for a host."""
|
||||
volume_repo = DockerVolumeRepository(db_session)
|
||||
volumes = await volume_repo.list_by_host(host_id)
|
||||
total = await volume_repo.count_by_host(host_id)
|
||||
|
||||
return DockerVolumeListResponse(
|
||||
volumes=[{
|
||||
"id": vol.id,
|
||||
"host_id": vol.host_id,
|
||||
"name": vol.name,
|
||||
"driver": vol.driver,
|
||||
"mountpoint": vol.mountpoint,
|
||||
"scope": vol.scope,
|
||||
"last_update_at": vol.last_update_at
|
||||
} for vol in volumes],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
# === Alerts ===
|
||||
|
||||
@router.get("/alerts", response_model=DockerAlertListResponse)
|
||||
async def list_alerts(
|
||||
host_id: Optional[str] = Query(None),
|
||||
state: Optional[str] = Query("open", description="Filter by state: open, closed, acknowledged"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""List Docker alerts."""
|
||||
result = await docker_alerts_service.get_alerts(
|
||||
host_id=host_id,
|
||||
state=state,
|
||||
severity=severity,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return DockerAlertListResponse(
|
||||
alerts=result.get("alerts", []),
|
||||
total=result.get("total", 0),
|
||||
open_count=result.get("open_count", 0),
|
||||
acknowledged_count=result.get("acknowledged_count", 0)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/acknowledge")
|
||||
async def acknowledge_alert(
|
||||
alert_id: int,
|
||||
request: DockerAlertAcknowledgeRequest = None,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Acknowledge an alert."""
|
||||
username = user.get("username", "unknown")
|
||||
note = request.note if request else None
|
||||
|
||||
result = await docker_alerts_service.acknowledge_alert(alert_id, username, note)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Alert not found or already acknowledged")
|
||||
|
||||
return {"message": "Alert acknowledged", "alert": result}
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/close")
|
||||
async def close_alert(
|
||||
alert_id: int,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Close an alert manually."""
|
||||
result = await docker_alerts_service.close_alert(alert_id)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Alert not found or already closed")
|
||||
|
||||
return {"message": "Alert closed", "alert": result}
|
||||
|
||||
|
||||
# === Stats ===
|
||||
|
||||
@router.get("/stats", response_model=DockerStatsResponse)
|
||||
async def get_docker_stats(
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Get global Docker statistics."""
|
||||
stats = await docker_alerts_service.get_stats()
|
||||
return DockerStatsResponse(**stats)
|
||||
@@ -3,6 +3,7 @@ Routes API pour les health checks.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
@@ -34,6 +35,29 @@ async def global_health_check():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/runtime")
|
||||
async def runtime_info(api_key_valid: bool = Depends(verify_api_key)):
|
||||
"""Runtime diagnostics (python executable, asyncssh availability)."""
|
||||
try:
|
||||
import asyncssh # type: ignore
|
||||
asyncssh_ok = True
|
||||
asyncssh_version = getattr(asyncssh, "__version__", None)
|
||||
except Exception as e:
|
||||
asyncssh_ok = False
|
||||
asyncssh_version = None
|
||||
asyncssh_error = str(e)
|
||||
else:
|
||||
asyncssh_error = None
|
||||
|
||||
return {
|
||||
"python_executable": sys.executable,
|
||||
"python_version": sys.version,
|
||||
"asyncssh_available": asyncssh_ok,
|
||||
"asyncssh_version": asyncssh_version,
|
||||
"asyncssh_error": asyncssh_error,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{host_name}", response_model=HealthCheck)
|
||||
async def check_host_health(host_name: str, api_key_valid: bool = Depends(verify_api_key)):
|
||||
"""Effectue un health check sur un hôte spécifique."""
|
||||
|
||||
+19
-2
@@ -2,13 +2,16 @@
|
||||
Routes API pour les métriques système et des hôtes.
|
||||
"""
|
||||
|
||||
from datetime import timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import pytz
|
||||
|
||||
from app.core.dependencies import get_db, verify_api_key
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
from app.crud.host_metrics import HostMetricsRepository
|
||||
@@ -22,6 +25,15 @@ class CollectionScheduleRequest(BaseModel):
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_app_timezone(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if getattr(dt, "tzinfo", None) is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
tz = pytz.timezone(settings.scheduler_timezone)
|
||||
return dt.astimezone(tz)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_metrics(api_key_valid: bool = Depends(verify_api_key)):
|
||||
"""Récupère les métriques système globales."""
|
||||
@@ -41,6 +53,7 @@ async def get_all_hosts_metrics(
|
||||
# Convertir en dict host_id -> metrics
|
||||
result = {}
|
||||
for host_id, m in metrics_dict.items():
|
||||
collected_at = _to_app_timezone(m.collected_at)
|
||||
result[m.host_id] = {
|
||||
"host_id": m.host_id,
|
||||
"metric_type": m.metric_type,
|
||||
@@ -56,7 +69,8 @@ async def get_all_hosts_metrics(
|
||||
"disk_root_used_gb": m.disk_root_used_gb,
|
||||
"os_name": m.os_name,
|
||||
"uptime_human": m.uptime_human,
|
||||
"collected_at": m.collected_at,
|
||||
"last_collected": collected_at,
|
||||
"collected_at": collected_at,
|
||||
"collection_status": "success" if not m.error_message else "failed",
|
||||
"error_message": m.error_message,
|
||||
}
|
||||
@@ -118,6 +132,8 @@ async def get_host_metrics(
|
||||
|
||||
if not metrics:
|
||||
return {"host_id": host_id, "collection_status": "no_data"}
|
||||
|
||||
collected_at = _to_app_timezone(metrics.collected_at)
|
||||
|
||||
return {
|
||||
"host_id": metrics.host_id,
|
||||
@@ -135,7 +151,8 @@ async def get_host_metrics(
|
||||
"disk_info": metrics.disk_info,
|
||||
"os_name": metrics.os_name,
|
||||
"uptime_human": metrics.uptime_human,
|
||||
"collected_at": metrics.collected_at,
|
||||
"last_collected": collected_at,
|
||||
"collected_at": collected_at,
|
||||
"collection_status": "success" if not metrics.error_message else "failed",
|
||||
"error_message": metrics.error_message,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user