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:
@@ -0,0 +1,165 @@
|
||||
"""CRUD operations for Docker alerts."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.docker_alert import DockerAlert
|
||||
|
||||
|
||||
class DockerAlertRepository:
|
||||
"""Repository for Docker alert CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get(self, alert_id: int) -> Optional[DockerAlert]:
|
||||
"""Get an alert by its ID."""
|
||||
result = await self.session.execute(
|
||||
select(DockerAlert).where(DockerAlert.id == alert_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_open_alert(self, host_id: str, container_name: str) -> Optional[DockerAlert]:
|
||||
"""Get an open alert for a specific container."""
|
||||
result = await self.session.execute(
|
||||
select(DockerAlert).where(
|
||||
DockerAlert.host_id == host_id,
|
||||
DockerAlert.container_name == container_name,
|
||||
DockerAlert.state == "open"
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_alerts(
|
||||
self,
|
||||
host_id: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[DockerAlert]:
|
||||
"""List alerts with optional filters."""
|
||||
query = select(DockerAlert)
|
||||
|
||||
if host_id:
|
||||
query = query.where(DockerAlert.host_id == host_id)
|
||||
if state:
|
||||
query = query.where(DockerAlert.state == state)
|
||||
if severity:
|
||||
query = query.where(DockerAlert.severity == severity)
|
||||
|
||||
query = query.order_by(DockerAlert.opened_at.desc()).limit(limit).offset(offset)
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_alerts(
|
||||
self,
|
||||
host_id: Optional[str] = None,
|
||||
state: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Count alerts by state."""
|
||||
base_query = select(DockerAlert)
|
||||
if host_id:
|
||||
base_query = base_query.where(DockerAlert.host_id == host_id)
|
||||
|
||||
# Count open
|
||||
open_query = select(func.count(DockerAlert.id)).where(DockerAlert.state == "open")
|
||||
if host_id:
|
||||
open_query = open_query.where(DockerAlert.host_id == host_id)
|
||||
|
||||
# Count acknowledged
|
||||
ack_query = select(func.count(DockerAlert.id)).where(DockerAlert.state == "acknowledged")
|
||||
if host_id:
|
||||
ack_query = ack_query.where(DockerAlert.host_id == host_id)
|
||||
|
||||
# Count total
|
||||
total_query = select(func.count(DockerAlert.id))
|
||||
if host_id:
|
||||
total_query = total_query.where(DockerAlert.host_id == host_id)
|
||||
|
||||
open_result = await self.session.execute(open_query)
|
||||
ack_result = await self.session.execute(ack_query)
|
||||
total_result = await self.session.execute(total_query)
|
||||
|
||||
return {
|
||||
"total": total_result.scalar() or 0,
|
||||
"open": open_result.scalar() or 0,
|
||||
"acknowledged": ack_result.scalar() or 0
|
||||
}
|
||||
|
||||
async def count_open_by_host(self, host_id: str) -> int:
|
||||
"""Count open alerts for a host."""
|
||||
result = await self.session.execute(
|
||||
select(func.count(DockerAlert.id)).where(
|
||||
DockerAlert.host_id == host_id,
|
||||
DockerAlert.state == "open"
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def create(
|
||||
self,
|
||||
host_id: str,
|
||||
container_name: str,
|
||||
severity: str = "warning",
|
||||
message: Optional[str] = None
|
||||
) -> DockerAlert:
|
||||
"""Create a new alert."""
|
||||
alert = DockerAlert(
|
||||
host_id=host_id,
|
||||
container_name=container_name,
|
||||
severity=severity,
|
||||
state="open",
|
||||
message=message,
|
||||
opened_at=datetime.utcnow()
|
||||
)
|
||||
self.session.add(alert)
|
||||
return alert
|
||||
|
||||
async def acknowledge(
|
||||
self,
|
||||
alert_id: int,
|
||||
acknowledged_by: str
|
||||
) -> Optional[DockerAlert]:
|
||||
"""Acknowledge an alert."""
|
||||
alert = await self.get(alert_id)
|
||||
if alert and alert.state == "open":
|
||||
alert.state = "acknowledged"
|
||||
alert.acknowledged_at = datetime.utcnow()
|
||||
alert.acknowledged_by = acknowledged_by
|
||||
return alert
|
||||
return None
|
||||
|
||||
async def close(self, alert_id: int) -> Optional[DockerAlert]:
|
||||
"""Close an alert."""
|
||||
alert = await self.get(alert_id)
|
||||
if alert and alert.state in ("open", "acknowledged"):
|
||||
alert.state = "closed"
|
||||
alert.closed_at = datetime.utcnow()
|
||||
return alert
|
||||
return None
|
||||
|
||||
async def close_for_container(self, host_id: str, container_name: str) -> int:
|
||||
"""Close all open alerts for a container."""
|
||||
result = await self.session.execute(
|
||||
select(DockerAlert).where(
|
||||
DockerAlert.host_id == host_id,
|
||||
DockerAlert.container_name == container_name,
|
||||
DockerAlert.state.in_(["open", "acknowledged"])
|
||||
)
|
||||
)
|
||||
alerts = result.scalars().all()
|
||||
count = 0
|
||||
for alert in alerts:
|
||||
alert.state = "closed"
|
||||
alert.closed_at = datetime.utcnow()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def update_last_notified(self, alert_id: int) -> None:
|
||||
"""Update the last notified timestamp."""
|
||||
alert = await self.get(alert_id)
|
||||
if alert:
|
||||
alert.last_notified_at = datetime.utcnow()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""CRUD operations for Docker containers."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import case, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.docker_container import DockerContainer
|
||||
|
||||
|
||||
class DockerContainerRepository:
|
||||
"""Repository for Docker container CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get(self, container_db_id: int) -> Optional[DockerContainer]:
|
||||
"""Get a container by its database ID."""
|
||||
result = await self.session.execute(
|
||||
select(DockerContainer).where(DockerContainer.id == container_db_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_container_id(self, host_id: str, container_id: str) -> Optional[DockerContainer]:
|
||||
"""Get a container by host ID and Docker container ID."""
|
||||
result = await self.session.execute(
|
||||
select(DockerContainer).where(
|
||||
DockerContainer.host_id == host_id,
|
||||
DockerContainer.container_id == container_id
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_by_name(self, host_id: str, name: str) -> Optional[DockerContainer]:
|
||||
"""Get a container by host ID and container name."""
|
||||
result = await self.session.execute(
|
||||
select(DockerContainer).where(
|
||||
DockerContainer.host_id == host_id,
|
||||
DockerContainer.name == name
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def list_by_host(
|
||||
self,
|
||||
host_id: str,
|
||||
state: Optional[str] = None,
|
||||
compose_project: Optional[str] = None
|
||||
) -> List[DockerContainer]:
|
||||
"""List all containers for a host with optional filters."""
|
||||
query = select(DockerContainer).where(DockerContainer.host_id == host_id)
|
||||
|
||||
if state:
|
||||
query = query.where(DockerContainer.state == state)
|
||||
if compose_project:
|
||||
query = query.where(DockerContainer.compose_project == compose_project)
|
||||
|
||||
query = query.order_by(DockerContainer.name)
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_by_host(self, host_id: str) -> dict:
|
||||
"""Count containers by state for a host."""
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
func.count(DockerContainer.id).label('total'),
|
||||
func.sum(case((DockerContainer.state == 'running', 1), else_=0)).label('running')
|
||||
).where(DockerContainer.host_id == host_id)
|
||||
)
|
||||
row = result.one()
|
||||
return {
|
||||
"total": row.total or 0,
|
||||
"running": row.running or 0
|
||||
}
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
name: str,
|
||||
image: Optional[str] = None,
|
||||
state: str = "unknown",
|
||||
status: Optional[str] = None,
|
||||
health: Optional[str] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
ports: Optional[dict] = None,
|
||||
labels: Optional[dict] = None,
|
||||
compose_project: Optional[str] = None
|
||||
) -> DockerContainer:
|
||||
"""Create or update a container."""
|
||||
existing = await self.get_by_container_id(host_id, container_id)
|
||||
|
||||
if existing:
|
||||
existing.name = name
|
||||
existing.image = image
|
||||
existing.state = state
|
||||
existing.status = status
|
||||
existing.health = health
|
||||
existing.created_at = created_at
|
||||
existing.ports = ports
|
||||
existing.labels = labels
|
||||
existing.compose_project = compose_project
|
||||
existing.last_update_at = datetime.utcnow()
|
||||
return existing
|
||||
|
||||
container = DockerContainer(
|
||||
host_id=host_id,
|
||||
container_id=container_id,
|
||||
name=name,
|
||||
image=image,
|
||||
state=state,
|
||||
status=status,
|
||||
health=health,
|
||||
created_at=created_at,
|
||||
ports=ports,
|
||||
labels=labels,
|
||||
compose_project=compose_project
|
||||
)
|
||||
self.session.add(container)
|
||||
return container
|
||||
|
||||
async def delete_by_host(self, host_id: str) -> int:
|
||||
"""Delete all containers for a host."""
|
||||
result = await self.session.execute(
|
||||
delete(DockerContainer).where(DockerContainer.host_id == host_id)
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_stale(self, host_id: str, current_container_ids: List[str]) -> int:
|
||||
"""Delete containers that no longer exist on the host."""
|
||||
if not current_container_ids:
|
||||
return await self.delete_by_host(host_id)
|
||||
|
||||
result = await self.session.execute(
|
||||
delete(DockerContainer).where(
|
||||
DockerContainer.host_id == host_id,
|
||||
DockerContainer.container_id.notin_(current_container_ids)
|
||||
)
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,103 @@
|
||||
"""CRUD operations for Docker images."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.docker_image import DockerImage
|
||||
|
||||
|
||||
class DockerImageRepository:
|
||||
"""Repository for Docker image CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get(self, image_db_id: int) -> Optional[DockerImage]:
|
||||
"""Get an image by its database ID."""
|
||||
result = await self.session.execute(
|
||||
select(DockerImage).where(DockerImage.id == image_db_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_image_id(self, host_id: str, image_id: str) -> Optional[DockerImage]:
|
||||
"""Get an image by host ID and Docker image ID."""
|
||||
result = await self.session.execute(
|
||||
select(DockerImage).where(
|
||||
DockerImage.host_id == host_id,
|
||||
DockerImage.image_id == image_id
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def list_by_host(self, host_id: str) -> List[DockerImage]:
|
||||
"""List all images for a host."""
|
||||
result = await self.session.execute(
|
||||
select(DockerImage)
|
||||
.where(DockerImage.host_id == host_id)
|
||||
.order_by(DockerImage.last_update_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_by_host(self, host_id: str) -> dict:
|
||||
"""Count images and total size for a host."""
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
func.count(DockerImage.id).label('total'),
|
||||
func.coalesce(func.sum(DockerImage.size), 0).label('total_size')
|
||||
).where(DockerImage.host_id == host_id)
|
||||
)
|
||||
row = result.one()
|
||||
return {
|
||||
"total": row.total or 0,
|
||||
"total_size": row.total_size or 0
|
||||
}
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
host_id: str,
|
||||
image_id: str,
|
||||
repo_tags: Optional[List[str]] = None,
|
||||
size: Optional[int] = None,
|
||||
created: Optional[datetime] = None
|
||||
) -> DockerImage:
|
||||
"""Create or update an image."""
|
||||
existing = await self.get_by_image_id(host_id, image_id)
|
||||
|
||||
if existing:
|
||||
existing.repo_tags = repo_tags
|
||||
existing.size = size
|
||||
existing.created = created
|
||||
existing.last_update_at = datetime.utcnow()
|
||||
return existing
|
||||
|
||||
image = DockerImage(
|
||||
host_id=host_id,
|
||||
image_id=image_id,
|
||||
repo_tags=repo_tags,
|
||||
size=size,
|
||||
created=created
|
||||
)
|
||||
self.session.add(image)
|
||||
return image
|
||||
|
||||
async def delete_by_host(self, host_id: str) -> int:
|
||||
"""Delete all images for a host."""
|
||||
result = await self.session.execute(
|
||||
delete(DockerImage).where(DockerImage.host_id == host_id)
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_stale(self, host_id: str, current_image_ids: List[str]) -> int:
|
||||
"""Delete images that no longer exist on the host."""
|
||||
if not current_image_ids:
|
||||
return await self.delete_by_host(host_id)
|
||||
|
||||
result = await self.session.execute(
|
||||
delete(DockerImage).where(
|
||||
DockerImage.host_id == host_id,
|
||||
DockerImage.image_id.notin_(current_image_ids)
|
||||
)
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,96 @@
|
||||
"""CRUD operations for Docker volumes."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.docker_volume import DockerVolume
|
||||
|
||||
|
||||
class DockerVolumeRepository:
|
||||
"""Repository for Docker volume CRUD operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get(self, volume_db_id: int) -> Optional[DockerVolume]:
|
||||
"""Get a volume by its database ID."""
|
||||
result = await self.session.execute(
|
||||
select(DockerVolume).where(DockerVolume.id == volume_db_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, host_id: str, name: str) -> Optional[DockerVolume]:
|
||||
"""Get a volume by host ID and volume name."""
|
||||
result = await self.session.execute(
|
||||
select(DockerVolume).where(
|
||||
DockerVolume.host_id == host_id,
|
||||
DockerVolume.name == name
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def list_by_host(self, host_id: str) -> List[DockerVolume]:
|
||||
"""List all volumes for a host."""
|
||||
result = await self.session.execute(
|
||||
select(DockerVolume)
|
||||
.where(DockerVolume.host_id == host_id)
|
||||
.order_by(DockerVolume.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_by_host(self, host_id: str) -> int:
|
||||
"""Count volumes for a host."""
|
||||
result = await self.session.execute(
|
||||
select(func.count(DockerVolume.id)).where(DockerVolume.host_id == host_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
host_id: str,
|
||||
name: str,
|
||||
driver: Optional[str] = None,
|
||||
mountpoint: Optional[str] = None,
|
||||
scope: Optional[str] = None
|
||||
) -> DockerVolume:
|
||||
"""Create or update a volume."""
|
||||
existing = await self.get_by_name(host_id, name)
|
||||
|
||||
if existing:
|
||||
existing.driver = driver
|
||||
existing.mountpoint = mountpoint
|
||||
existing.scope = scope
|
||||
existing.last_update_at = datetime.utcnow()
|
||||
return existing
|
||||
|
||||
volume = DockerVolume(
|
||||
host_id=host_id,
|
||||
name=name,
|
||||
driver=driver,
|
||||
mountpoint=mountpoint,
|
||||
scope=scope
|
||||
)
|
||||
self.session.add(volume)
|
||||
return volume
|
||||
|
||||
async def delete_by_host(self, host_id: str) -> int:
|
||||
"""Delete all volumes for a host."""
|
||||
result = await self.session.execute(
|
||||
delete(DockerVolume).where(DockerVolume.host_id == host_id)
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_stale(self, host_id: str, current_volume_names: List[str]) -> int:
|
||||
"""Delete volumes that no longer exist on the host."""
|
||||
if not current_volume_names:
|
||||
return await self.delete_by_host(host_id)
|
||||
|
||||
result = await self.session.execute(
|
||||
delete(DockerVolume).where(
|
||||
DockerVolume.host_id == host_id,
|
||||
DockerVolume.name.notin_(current_volume_names)
|
||||
)
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -72,3 +72,13 @@ class HostRepository:
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def list_docker_enabled(self) -> list[Host]:
|
||||
"""List all hosts with Docker monitoring enabled."""
|
||||
stmt = (
|
||||
select(Host)
|
||||
.where(Host.deleted_at.is_(None), Host.docker_enabled == True)
|
||||
.order_by(Host.name)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* Docker Section Management
|
||||
* Handles Docker hosts, containers, images, volumes, and alerts
|
||||
*/
|
||||
|
||||
const dockerSection = {
|
||||
hosts: [],
|
||||
currentHostId: null,
|
||||
_initialized: false,
|
||||
stats: {
|
||||
total_hosts: 0,
|
||||
enabled_hosts: 0,
|
||||
total_containers: 0,
|
||||
running_containers: 0,
|
||||
total_images: 0,
|
||||
open_alerts: 0
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.loadDockerHosts();
|
||||
await this.loadStats();
|
||||
this.setupEventListeners();
|
||||
this.setupWebSocket();
|
||||
},
|
||||
|
||||
async ensureInit() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
await this.init();
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
// Search filter
|
||||
const searchInput = document.getElementById('docker-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
this.filterHosts(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.docker-tab').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
const tabName = e.currentTarget.dataset.tab;
|
||||
this.switchTab(tabName);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setupWebSocket() {
|
||||
if (window.dashboard && window.dashboard.ws) {
|
||||
const originalOnMessage = window.dashboard.ws.onmessage;
|
||||
window.dashboard.ws.onmessage = (event) => {
|
||||
if (originalOnMessage) originalOnMessage(event);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleWebSocketMessage(data);
|
||||
} catch (e) {
|
||||
console.error('Docker WS parse error:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
handleWebSocketMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'docker_host_updated':
|
||||
this.updateHostCard(data.host || data.data);
|
||||
break;
|
||||
case 'docker_alert_opened':
|
||||
this.showAlertNotification(data.data);
|
||||
this.updateAlertsBadge();
|
||||
break;
|
||||
case 'docker_alert_closed':
|
||||
case 'docker_alert_acknowledged':
|
||||
this.updateAlertsBadge();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchAPI(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const apiKey = localStorage.getItem('apiKey');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
} else if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(`${window.location.origin}${endpoint}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 Unauthorized - redirect to login
|
||||
if (response.status === 401) {
|
||||
this.showToast('Session expirée, reconnexion requise', 'error');
|
||||
if (window.dashboard && typeof window.dashboard.logout === 'function') {
|
||||
window.dashboard.logout();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async loadDockerHosts() {
|
||||
try {
|
||||
const response = await this.fetchAPI('/api/docker/hosts');
|
||||
this.hosts = response.hosts || [];
|
||||
this.renderHostsGrid();
|
||||
} catch (error) {
|
||||
console.error('Error loading Docker hosts:', error);
|
||||
this.renderError('Erreur lors du chargement des hosts Docker');
|
||||
}
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
try {
|
||||
const stats = await this.fetchAPI('/api/docker/stats');
|
||||
this.stats = stats;
|
||||
this.updateStatsDisplay();
|
||||
} catch (error) {
|
||||
console.error('Error loading Docker stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
updateStatsDisplay() {
|
||||
const el = (id, value) => {
|
||||
const elem = document.getElementById(id);
|
||||
if (elem) elem.textContent = value;
|
||||
};
|
||||
|
||||
el('docker-stat-hosts', this.stats.enabled_hosts || 0);
|
||||
el('docker-stat-containers', this.stats.running_containers || 0);
|
||||
el('docker-stat-images', this.stats.total_images || 0);
|
||||
el('docker-stat-alerts', this.stats.open_alerts || 0);
|
||||
|
||||
// Update alerts badge
|
||||
const badge = document.getElementById('docker-alerts-badge');
|
||||
if (badge) {
|
||||
if (this.stats.open_alerts > 0) {
|
||||
badge.textContent = this.stats.open_alerts;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderHostsGrid() {
|
||||
const grid = document.getElementById('docker-hosts-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (this.hosts.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="glass-card p-6 text-center col-span-full">
|
||||
<i class="fab fa-docker text-4xl text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-400">Aucun host Docker configuré</p>
|
||||
<p class="text-gray-500 text-sm mt-2">Activez Docker sur un host dans la section Hosts</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = this.hosts.map(host => this.renderHostCard(host)).join('');
|
||||
},
|
||||
|
||||
renderHostCard(host) {
|
||||
const statusColor = host.docker_status === 'online' ? 'green' :
|
||||
host.docker_status === 'offline' ? 'red' : 'yellow';
|
||||
const statusText = host.docker_status || 'unknown';
|
||||
|
||||
const lastCollect = host.docker_last_collect_at
|
||||
? this.formatRelativeTime(host.docker_last_collect_at)
|
||||
: 'Jamais';
|
||||
|
||||
return `
|
||||
<div class="glass-card p-4 cursor-pointer hover:border-purple-500/50 transition-all"
|
||||
data-host-id="${host.host_id}" onclick="dockerSection.viewDetails('${host.host_id}')">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-lg truncate">${host.host_name}</h3>
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-${statusColor}-500/20 text-${statusColor}-400">
|
||||
${statusText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-400 mb-3">${host.host_ip}</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 mb-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-box text-blue-400"></i>
|
||||
<span>${host.containers_running}/${host.containers_total} containers</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-layer-group text-purple-400"></i>
|
||||
<span>${host.images_total} images</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${host.open_alerts > 0 ? `
|
||||
<div class="flex items-center gap-2 text-sm text-red-400 mb-3">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>${host.open_alerts} alerte(s)</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span><i class="fas fa-clock mr-1"></i>${lastCollect}</span>
|
||||
${host.docker_version ? `<span>v${host.docker_version}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3 pt-3 border-t border-gray-700">
|
||||
<button onclick="event.stopPropagation(); dockerSection.collectNow('${host.host_id}')"
|
||||
class="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm transition-colors">
|
||||
<i class="fas fa-sync mr-1"></i>Collecter
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); dockerSection.toggleDockerEnabled('${host.host_id}', ${!host.docker_enabled})"
|
||||
class="px-3 py-1.5 ${host.docker_enabled ? 'bg-red-600/20 text-red-400 hover:bg-red-600/30' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30'} rounded text-sm transition-colors">
|
||||
<i class="fas fa-${host.docker_enabled ? 'pause' : 'play'}"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
filterHosts(query) {
|
||||
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
cards.forEach(card => {
|
||||
const hostId = card.dataset.hostId;
|
||||
const host = this.hosts.find(h => h.host_id === hostId);
|
||||
if (host) {
|
||||
const matches = host.host_name.toLowerCase().includes(lowerQuery) ||
|
||||
host.host_ip.toLowerCase().includes(lowerQuery);
|
||||
card.style.display = matches ? '' : 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async viewDetails(hostId) {
|
||||
this.currentHostId = hostId;
|
||||
const host = this.hosts.find(h => h.host_id === hostId);
|
||||
|
||||
if (!host) return;
|
||||
|
||||
document.getElementById('docker-host-name').innerHTML =
|
||||
`<i class="fab fa-docker mr-2 text-blue-400"></i>${host.host_name}`;
|
||||
|
||||
// Show modal
|
||||
document.getElementById('docker-detail-modal').classList.remove('hidden');
|
||||
|
||||
// Load containers by default
|
||||
this.switchTab('containers');
|
||||
await this.loadContainers(hostId);
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
document.getElementById('docker-detail-modal').classList.add('hidden');
|
||||
this.currentHostId = null;
|
||||
},
|
||||
|
||||
switchTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.docker-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
tab.classList.toggle('text-gray-400', tab.dataset.tab !== tabName);
|
||||
});
|
||||
|
||||
// Update tab content
|
||||
document.querySelectorAll('.docker-tab-content').forEach(content => {
|
||||
content.classList.toggle('hidden', !content.id.endsWith(tabName));
|
||||
});
|
||||
|
||||
// Load content for the tab
|
||||
if (this.currentHostId) {
|
||||
switch (tabName) {
|
||||
case 'containers':
|
||||
this.loadContainers(this.currentHostId);
|
||||
break;
|
||||
case 'images':
|
||||
this.loadImages(this.currentHostId);
|
||||
break;
|
||||
case 'volumes':
|
||||
this.loadVolumes(this.currentHostId);
|
||||
break;
|
||||
case 'host-alerts':
|
||||
this.loadHostAlerts(this.currentHostId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async loadContainers(hostId) {
|
||||
const container = document.getElementById('docker-containers-list');
|
||||
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/containers`);
|
||||
const containers = response.containers || [];
|
||||
|
||||
if (containers.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucun container</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = containers.map(c => this.renderContainerRow(c, hostId)).join('');
|
||||
} catch (error) {
|
||||
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderContainerRow(c, hostId) {
|
||||
const stateColors = {
|
||||
running: 'green',
|
||||
exited: 'red',
|
||||
paused: 'yellow',
|
||||
created: 'blue',
|
||||
dead: 'red'
|
||||
};
|
||||
const stateColor = stateColors[c.state] || 'gray';
|
||||
|
||||
const healthBadge = c.health ? `
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-${c.health === 'healthy' ? 'green' : 'red'}-500/20 text-${c.health === 'healthy' ? 'green' : 'red'}-400">
|
||||
${c.health}
|
||||
</span>
|
||||
` : '';
|
||||
|
||||
// Parse ports and create clickable links
|
||||
const portLinks = this.parsePortLinks(c.ports, hostId);
|
||||
|
||||
return `
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 flex items-center justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="w-2 h-2 rounded-full bg-${stateColor}-500"></span>
|
||||
<span class="font-medium truncate">${c.name}</span>
|
||||
${c.compose_project ? `<span class="px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">${c.compose_project}</span>` : ''}
|
||||
${healthBadge}
|
||||
${portLinks}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 truncate mt-1">${c.image}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">${c.status || c.state}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
${c.state !== 'running' ? `
|
||||
<button onclick="dockerSection.startContainer('${hostId}', '${c.container_id}')"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors text-green-400" title="Démarrer">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
` : `
|
||||
<button onclick="dockerSection.stopContainer('${hostId}', '${c.container_id}')"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors text-red-400" title="Arrêter">
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
`}
|
||||
<button onclick="dockerSection.restartContainer('${hostId}', '${c.container_id}')"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors text-yellow-400" title="Redémarrer">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
<button onclick="dockerSection.showLogs('${hostId}', '${c.container_id}', '${c.name}')"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Logs">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</button>
|
||||
<button onclick="dockerSection.showInspect('${hostId}', '${c.container_id}', '${c.name}')"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors text-cyan-400" title="Inspect">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
parsePortLinks(ports, hostId) {
|
||||
if (!ports) return '';
|
||||
|
||||
// Get host IP for building URLs
|
||||
const host = this.hosts.find(h => h.host_id === hostId);
|
||||
const hostIp = host ? host.host_ip : 'localhost';
|
||||
|
||||
// Parse ports from the raw string or object
|
||||
const portStr = ports.raw || (typeof ports === 'string' ? ports : '');
|
||||
if (!portStr) return '';
|
||||
|
||||
// Extract port mappings like "0.0.0.0:8080->80/tcp" or "8080/tcp"
|
||||
const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g;
|
||||
const links = [];
|
||||
let match;
|
||||
|
||||
while ((match = portRegex.exec(portStr)) !== null) {
|
||||
const bindIp = match[1] || '0.0.0.0';
|
||||
const hostPort = match[2];
|
||||
|
||||
// Skip if bound to 127.0.0.1 only (not accessible externally)
|
||||
if (bindIp === '127.0.0.1') continue;
|
||||
|
||||
// Determine protocol (https for common secure ports)
|
||||
const protocol = ['443', '8443', '9443'].includes(hostPort) ? 'https' : 'http';
|
||||
const url = `${protocol}://${hostIp}:${hostPort}`;
|
||||
|
||||
links.push(`
|
||||
<a href="${url}" target="_blank" rel="noopener noreferrer"
|
||||
onclick="event.stopPropagation()"
|
||||
class="px-2 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
|
||||
title="Ouvrir ${url}">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>${hostPort}
|
||||
</a>
|
||||
`);
|
||||
}
|
||||
|
||||
return links.join('');
|
||||
},
|
||||
|
||||
async loadImages(hostId) {
|
||||
const container = document.getElementById('docker-images-list');
|
||||
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/images`);
|
||||
if (!response) return; // 401 handled
|
||||
|
||||
const images = response.images || [];
|
||||
|
||||
if (images.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucune image</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show unused count if any
|
||||
const unusedInfo = response.unused_count > 0
|
||||
? `<div class="text-sm text-yellow-400 mb-3"><i class="fas fa-info-circle mr-1"></i>${response.unused_count} image(s) non utilisée(s)</div>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = unusedInfo + images.map(img => {
|
||||
const unusedBadge = !img.in_use
|
||||
? `<span class="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400 cursor-help" title="Cette image n'est utilisée par aucun container">Unused</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 flex items-center justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium">${img.repo_tags?.[0] || img.image_id.substring(0, 12)}</span>
|
||||
${unusedBadge}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mt-1">
|
||||
<span class="mr-4"><i class="fas fa-hdd mr-1"></i>${this.formatBytes(img.size)}</span>
|
||||
<span><i class="fas fa-clock mr-1"></i>${this.formatRelativeTime(img.created)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick="dockerSection.removeImage('${hostId}', '${img.image_id}', ${!img.in_use})"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors ${img.in_use ? 'text-gray-500' : 'text-red-400'}"
|
||||
title="${img.in_use ? 'Supprimer (forcé car en cours d\'utilisation)' : 'Supprimer l\'image'}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
async loadVolumes(hostId) {
|
||||
const container = document.getElementById('docker-volumes-list');
|
||||
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/volumes`);
|
||||
const volumes = response.volumes || [];
|
||||
|
||||
if (volumes.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucun volume</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = volumes.map(vol => `
|
||||
<div class="bg-gray-800/50 rounded-lg p-3">
|
||||
<div class="font-medium">${vol.name}</div>
|
||||
<div class="text-sm text-gray-400 mt-1">
|
||||
<span class="mr-4"><i class="fas fa-cog mr-1"></i>${vol.driver}</span>
|
||||
<span class="truncate"><i class="fas fa-folder mr-1"></i>${vol.mountpoint}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
async loadHostAlerts(hostId) {
|
||||
const container = document.getElementById('docker-host-alerts-list');
|
||||
container.innerHTML = '<div class="loading-spinner mx-auto"></div>';
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/alerts?host_id=${hostId}`);
|
||||
const alerts = response.alerts || [];
|
||||
|
||||
if (alerts.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-center py-4">Aucune alerte</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = alerts.map(alert => this.renderAlertRow(alert)).join('');
|
||||
} catch (error) {
|
||||
container.innerHTML = `<p class="text-red-400 text-center py-4">Erreur: ${error.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderAlertRow(alert) {
|
||||
const severityColors = {
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
critical: 'red'
|
||||
};
|
||||
const color = severityColors[alert.severity] || 'gray';
|
||||
|
||||
return `
|
||||
<div class="bg-gray-800/50 rounded-lg p-3 border-l-4 border-${color}-500">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-exclamation-triangle text-${color}-400"></i>
|
||||
<span class="font-medium">${alert.container_name}</span>
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-${color}-500/20 text-${color}-400">${alert.severity}</span>
|
||||
</div>
|
||||
${alert.state === 'open' ? `
|
||||
<button onclick="dockerSection.acknowledgeAlert(${alert.id})"
|
||||
class="px-2 py-1 text-xs bg-gray-700 hover:bg-gray-600 rounded transition-colors">
|
||||
Acquitter
|
||||
</button>
|
||||
` : `
|
||||
<span class="text-xs text-gray-500">${alert.state}</span>
|
||||
`}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mt-2">${alert.message}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">${this.formatRelativeTime(alert.opened_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Container Actions
|
||||
async startContainer(hostId, containerId) {
|
||||
await this.executeAction(hostId, containerId, 'start');
|
||||
},
|
||||
|
||||
async stopContainer(hostId, containerId) {
|
||||
await this.executeAction(hostId, containerId, 'stop');
|
||||
},
|
||||
|
||||
async restartContainer(hostId, containerId) {
|
||||
await this.executeAction(hostId, containerId, 'restart');
|
||||
},
|
||||
|
||||
async executeAction(hostId, containerId, action) {
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showToast(`Container ${action} successful`, 'success');
|
||||
// Refresh containers list
|
||||
setTimeout(() => this.loadContainers(hostId), 1000);
|
||||
} else {
|
||||
this.showToast(`Failed: ${response.error || response.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async showLogs(hostId, containerId, containerName) {
|
||||
document.getElementById('docker-logs-title').innerHTML =
|
||||
`<i class="fas fa-file-alt mr-2 text-green-400"></i>${containerName} - Logs`;
|
||||
document.getElementById('docker-logs-content').textContent = 'Chargement des logs...';
|
||||
document.getElementById('docker-logs-modal').classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/logs?tail=500`);
|
||||
document.getElementById('docker-logs-content').textContent = response.logs || 'Aucun log disponible';
|
||||
} catch (error) {
|
||||
document.getElementById('docker-logs-content').textContent = `Erreur: ${error.message}`;
|
||||
}
|
||||
},
|
||||
|
||||
closeLogsModal() {
|
||||
document.getElementById('docker-logs-modal').classList.add('hidden');
|
||||
},
|
||||
|
||||
async showInspect(hostId, containerId, containerName) {
|
||||
document.getElementById('docker-logs-title').innerHTML =
|
||||
`<i class="fas fa-search mr-2 text-cyan-400"></i>${containerName} - Inspect`;
|
||||
document.getElementById('docker-logs-content').textContent = 'Chargement des informations...';
|
||||
document.getElementById('docker-logs-modal').classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/inspect`);
|
||||
if (!response) return; // 401 handled
|
||||
|
||||
// Format JSON nicely
|
||||
const inspectData = response.inspect_data || {};
|
||||
document.getElementById('docker-logs-content').textContent =
|
||||
JSON.stringify(inspectData, null, 2);
|
||||
} catch (error) {
|
||||
document.getElementById('docker-logs-content').textContent = `Erreur: ${error.message}`;
|
||||
}
|
||||
},
|
||||
|
||||
async removeImage(hostId, imageId, canDeleteDirectly = false) {
|
||||
const force = !canDeleteDirectly;
|
||||
const confirmMsg = force
|
||||
? 'Cette image est utilisée par un ou plusieurs containers. Voulez-vous forcer la suppression ?'
|
||||
: 'Voulez-vous supprimer cette image ?';
|
||||
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(`/api/docker/images/${hostId}/${imageId}?force=${force}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response) return; // 401 handled
|
||||
|
||||
if (response.success) {
|
||||
this.showToast('Image supprimée avec succès', 'success');
|
||||
// Refresh images list
|
||||
await this.loadImages(hostId);
|
||||
} else {
|
||||
this.showToast(`Erreur: ${response.error || response.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(`Erreur: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async collectNow(hostId) {
|
||||
try {
|
||||
this.showToast('Collection en cours...', 'info');
|
||||
const response = await this.fetchAPI(`/api/docker/hosts/${hostId}/collect`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showToast(`Collection terminée: ${response.containers_count} containers`, 'success');
|
||||
await this.loadDockerHosts();
|
||||
await this.loadStats();
|
||||
} else {
|
||||
this.showToast(`Erreur: ${response.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(`Erreur: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async collectAll() {
|
||||
try {
|
||||
this.showToast('Collection de tous les hosts...', 'info');
|
||||
const response = await this.fetchAPI('/api/docker/collect-all', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
this.showToast(`Collecte terminée: ${response.successful}/${response.total_hosts} hosts`,
|
||||
response.failed > 0 ? 'warning' : 'success');
|
||||
await this.loadDockerHosts();
|
||||
await this.loadStats();
|
||||
} catch (error) {
|
||||
this.showToast(`Erreur: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleDockerEnabled(hostId, enabled) {
|
||||
try {
|
||||
await this.fetchAPI(`/api/docker/hosts/${hostId}/enable`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled })
|
||||
});
|
||||
|
||||
this.showToast(`Docker monitoring ${enabled ? 'activé' : 'désactivé'}`, 'success');
|
||||
await this.loadDockerHosts();
|
||||
await this.loadStats();
|
||||
} catch (error) {
|
||||
this.showToast(`Erreur: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async acknowledgeAlert(alertId) {
|
||||
try {
|
||||
await this.fetchAPI(`/api/docker/alerts/${alertId}/acknowledge`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
this.showToast('Alerte acquittée', 'success');
|
||||
if (this.currentHostId) {
|
||||
await this.loadHostAlerts(this.currentHostId);
|
||||
}
|
||||
await this.loadStats();
|
||||
} catch (error) {
|
||||
this.showToast(`Erreur: ${error.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
showAlerts() {
|
||||
// Navigate to alerts tab in modal or show alerts modal
|
||||
if (this.currentHostId) {
|
||||
this.switchTab('host-alerts');
|
||||
}
|
||||
},
|
||||
|
||||
updateHostCard(hostData) {
|
||||
const card = document.querySelector(`[data-host-id="${hostData.host_id}"]`);
|
||||
if (card) {
|
||||
card.outerHTML = this.renderHostCard(hostData);
|
||||
}
|
||||
},
|
||||
|
||||
async updateAlertsBadge() {
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
showAlertNotification(alert) {
|
||||
this.showToast(`Alerte Docker: ${alert.container_name} - ${alert.message}`, 'warning');
|
||||
},
|
||||
|
||||
renderError(message) {
|
||||
const grid = document.getElementById('docker-hosts-grid');
|
||||
if (grid) {
|
||||
grid.innerHTML = `
|
||||
<div class="glass-card p-6 text-center col-span-full">
|
||||
<i class="fas fa-exclamation-circle text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-red-400">${message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
// Use existing toast system or create simple one
|
||||
if (window.dashboard && window.dashboard.showToast) {
|
||||
window.dashboard.showToast(message, type);
|
||||
} else {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
},
|
||||
|
||||
formatRelativeTime(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'À l\'instant';
|
||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
||||
return `Il y a ${diffDays}j`;
|
||||
},
|
||||
|
||||
formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
};
|
||||
|
||||
function setupDockerAutoInit() {
|
||||
// Initialize when Docker page is shown
|
||||
const dockerPage = document.getElementById('page-docker');
|
||||
if (!dockerPage) return;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
mutation.target.id === 'page-docker' &&
|
||||
mutation.target.classList.contains('active')
|
||||
) {
|
||||
dockerSection.ensureInit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(dockerPage, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Also initialize if already on Docker page
|
||||
if (dockerPage.classList.contains('active')) {
|
||||
dockerSection.ensureInit();
|
||||
}
|
||||
}
|
||||
|
||||
// If this script is loaded after DOMContentLoaded (common when injected late),
|
||||
// a DOMContentLoaded listener would never fire. Handle both cases.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupDockerAutoInit);
|
||||
} else {
|
||||
setupDockerAutoInit();
|
||||
}
|
||||
@@ -163,6 +163,29 @@ def create_app() -> FastAPI:
|
||||
# Démarrer le scheduler
|
||||
await scheduler_service.start_async()
|
||||
|
||||
# Ajouter les jobs Docker au scheduler
|
||||
from app.services.docker_service import docker_service
|
||||
from app.services.docker_alerts import docker_alerts_service
|
||||
|
||||
scheduler_service.scheduler.add_job(
|
||||
docker_service.collect_all_hosts,
|
||||
trigger="interval",
|
||||
seconds=60, # Toutes les minutes
|
||||
id="docker_collect",
|
||||
name="Docker Metrics Collection",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler_service.scheduler.add_job(
|
||||
docker_alerts_service.check_all_alerts,
|
||||
trigger="interval",
|
||||
seconds=30, # Toutes les 30 secondes
|
||||
id="docker_alerts",
|
||||
name="Docker Alerts Check",
|
||||
replace_existing=True
|
||||
)
|
||||
print("🐳 Docker monitoring jobs scheduled")
|
||||
|
||||
# Afficher l'état du service de notification
|
||||
ntfy_status = "activé" if notification_service.enabled else "désactivé"
|
||||
print(f"🔔 Service de notification ntfy: {ntfy_status} ({notification_service.config.base_url})")
|
||||
|
||||
+249
-18
@@ -676,6 +676,14 @@
|
||||
|
||||
body.light-theme .text-gray-300,
|
||||
body.light-theme .text-gray-400,
|
||||
body.light-theme .text-gray-500 {
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
|
||||
body.light-theme .text-gray-400 {
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
body.light-theme .text-gray-500 {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
@@ -684,6 +692,22 @@
|
||||
body.light-theme .bg-gray-800 {
|
||||
background-color: #e5e5e5 !important;
|
||||
}
|
||||
|
||||
body.light-theme .bg-gray-800\/40 {
|
||||
background-color: rgba(229, 231, 235, 0.75) !important;
|
||||
}
|
||||
|
||||
body.light-theme .bg-gray-800\/60 {
|
||||
background-color: rgba(229, 231, 235, 0.9) !important;
|
||||
}
|
||||
|
||||
body.light-theme .border-gray-700\/60 {
|
||||
border-color: #d1d5db !important;
|
||||
}
|
||||
|
||||
body.light-theme .border-red-700\/60 {
|
||||
border-color: #fca5a5 !important;
|
||||
}
|
||||
|
||||
body.light-theme .bg-black {
|
||||
background-color: #f5f5f5 !important;
|
||||
@@ -761,13 +785,48 @@
|
||||
background-color: #7c3aed !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Navigation active state */
|
||||
.nav-link.active {
|
||||
color: #7c3aed !important;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #7c3aed;
|
||||
padding-bottom: 2px;
|
||||
.desktop-nav-links .nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.desktop-nav-links .nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.desktop-nav-links .nav-link.active {
|
||||
color: #c4b5fd !important;
|
||||
background: rgba(124, 58, 237, 0.18);
|
||||
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.25) inset;
|
||||
}
|
||||
|
||||
body.light-theme .desktop-nav-links .nav-link:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
body.light-theme .desktop-nav-links .nav-link.active {
|
||||
color: #6d28d9 !important;
|
||||
background: rgba(124, 58, 237, 0.14);
|
||||
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.22) inset;
|
||||
}
|
||||
|
||||
/* Page transitions */
|
||||
@@ -974,6 +1033,15 @@
|
||||
background: linear-gradient(180deg, #f9fafb 0%, #e5e7eb 100%);
|
||||
}
|
||||
|
||||
body.light-theme #page-hosts > div,
|
||||
body.light-theme #page-playbooks > div,
|
||||
body.light-theme #page-schedules > div,
|
||||
body.light-theme #page-alerts > div,
|
||||
body.light-theme #page-docker > div,
|
||||
body.light-theme #page-configuration > div {
|
||||
background: linear-gradient(180deg, #f9fafb 0%, #e5e7eb 100%) !important;
|
||||
}
|
||||
|
||||
/* Badges "Ready" / "Non configuré" en haut du bloc Hosts */
|
||||
body.light-theme .bg-green-600\/20 {
|
||||
background-color: #bbf7d0 !important; /* vert clair plein */
|
||||
@@ -2801,6 +2869,10 @@
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<span>Logs</span>
|
||||
</button>
|
||||
<button type="button" data-page="docker" class="mobile-nav-link" onclick="mobileNavigateTo('docker'); return false;">
|
||||
<i class="fab fa-docker"></i>
|
||||
<span>Docker</span>
|
||||
</button>
|
||||
<button type="button" data-page="alerts" class="mobile-nav-link" onclick="mobileNavigateTo('alerts'); return false;">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span>Alertes</span>
|
||||
@@ -2841,15 +2913,46 @@
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="desktop-nav-links">
|
||||
<a href="#" data-page="dashboard" class="nav-link text-gray-300 hover:text-white transition-colors">Dashboard</a>
|
||||
<a href="#" data-page="hosts" class="nav-link text-gray-300 hover:text-white transition-colors">Hosts</a>
|
||||
<a href="#" data-page="playbooks" class="nav-link text-gray-300 hover:text-white transition-colors">Playbooks</a>
|
||||
<a href="#" data-page="tasks" class="nav-link text-gray-300 hover:text-white transition-colors">Tasks</a>
|
||||
<a href="#" data-page="schedules" class="nav-link text-gray-300 hover:text-white transition-colors">Schedules</a>
|
||||
<a href="#" data-page="logs" class="nav-link text-gray-300 hover:text-white transition-colors">Logs</a>
|
||||
<a href="#" data-page="alerts" class="nav-link text-gray-300 hover:text-white transition-colors">Alertes</a>
|
||||
<a href="#" data-page="configuration" class="nav-link text-gray-300 hover:text-white transition-colors">Configuration</a>
|
||||
<a href="#" data-page="help" class="nav-link text-gray-300 hover:text-white transition-colors">Aide</a>
|
||||
<a href="#" data-page="dashboard" class="nav-link text-gray-300 hover:text-white transition-colors" title="Dashboard" aria-label="Dashboard">
|
||||
<i class="fas fa-gauge-high"></i>
|
||||
<span class="sr-only">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" data-page="hosts" class="nav-link text-gray-300 hover:text-white transition-colors" title="Hosts" aria-label="Hosts">
|
||||
<i class="fas fa-server"></i>
|
||||
<span class="sr-only">Hosts</span>
|
||||
</a>
|
||||
<a href="#" data-page="playbooks" class="nav-link text-gray-300 hover:text-white transition-colors" title="Playbooks" aria-label="Playbooks">
|
||||
<i class="fas fa-book"></i>
|
||||
<span class="sr-only">Playbooks</span>
|
||||
</a>
|
||||
<a href="#" data-page="tasks" class="nav-link text-gray-300 hover:text-white transition-colors" title="Tasks" aria-label="Tasks">
|
||||
<i class="fas fa-list-check"></i>
|
||||
<span class="sr-only">Tasks</span>
|
||||
</a>
|
||||
<a href="#" data-page="schedules" class="nav-link text-gray-300 hover:text-white transition-colors" title="Schedules" aria-label="Schedules">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
<span class="sr-only">Schedules</span>
|
||||
</a>
|
||||
<a href="#" data-page="logs" class="nav-link text-gray-300 hover:text-white transition-colors" title="Logs" aria-label="Logs">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<span class="sr-only">Logs</span>
|
||||
</a>
|
||||
<a href="#" data-page="docker" class="nav-link text-gray-300 hover:text-white transition-colors" title="Docker" aria-label="Docker">
|
||||
<i class="fab fa-docker"></i>
|
||||
<span class="sr-only">Docker</span>
|
||||
</a>
|
||||
<a href="#" data-page="alerts" class="nav-link text-gray-300 hover:text-white transition-colors" title="Alertes" aria-label="Alertes">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span class="sr-only">Alertes</span>
|
||||
</a>
|
||||
<a href="#" data-page="configuration" class="nav-link text-gray-300 hover:text-white transition-colors" title="Configuration" aria-label="Configuration">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
<span class="sr-only">Configuration</span>
|
||||
</a>
|
||||
<a href="#" data-page="help" class="nav-link text-gray-300 hover:text-white transition-colors" title="Aide" aria-label="Aide">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span class="sr-only">Aide</span>
|
||||
</a>
|
||||
<button id="theme-toggle" class="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors touch-target">
|
||||
<i class="fas fa-moon text-gray-300"></i>
|
||||
</button>
|
||||
@@ -3589,6 +3692,135 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== PAGE: DOCKER ==================== -->
|
||||
<section id="page-docker" class="page-section">
|
||||
<div class="pt-20 sm:pt-24 pb-8 sm:pb-16 min-h-screen bg-gradient-to-b from-gray-900 to-black">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold gradient-text fade-in">
|
||||
<i class="fab fa-docker mr-2"></i>Docker Hosts
|
||||
</h2>
|
||||
<p class="text-gray-400 mt-2">Surveillance et gestion des containers Docker</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<div class="metric-card">
|
||||
<div class="text-2xl font-bold text-purple-400" id="docker-stat-hosts">0</div>
|
||||
<div class="text-gray-400 text-sm">Hosts Docker</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="text-2xl font-bold text-green-400" id="docker-stat-containers">0</div>
|
||||
<div class="text-gray-400 text-sm">Containers</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="text-2xl font-bold text-blue-400" id="docker-stat-images">0</div>
|
||||
<div class="text-gray-400 text-sm">Images</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="text-2xl font-bold text-red-400" id="docker-stat-alerts">0</div>
|
||||
<div class="text-gray-400 text-sm">Alertes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="flex flex-wrap gap-4 mb-6 items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button onclick="dockerSection.collectAll()" class="btn-primary flex items-center gap-2">
|
||||
<i class="fas fa-sync"></i>
|
||||
<span class="hidden sm:inline">Collecter Tout</span>
|
||||
</button>
|
||||
<button onclick="dockerSection.showAlerts()" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-2">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span class="hidden sm:inline">Alertes Docker</span>
|
||||
<span id="docker-alerts-badge" class="hidden px-2 py-0.5 bg-red-600 text-white text-xs rounded-full">0</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" id="docker-search" placeholder="Rechercher un host..."
|
||||
class="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 w-full sm:w-64">
|
||||
</div>
|
||||
|
||||
<!-- Docker Hosts Grid -->
|
||||
<div id="docker-hosts-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Docker host cards will be rendered here by JS -->
|
||||
<div class="glass-card p-6 text-center col-span-full">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-400">Chargement des hosts Docker...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END PAGE: DOCKER -->
|
||||
|
||||
<!-- Docker Host Detail Modal -->
|
||||
<div id="docker-detail-modal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||||
<div class="glass-card w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-700">
|
||||
<h3 id="docker-host-name" class="text-xl font-bold"><i class="fab fa-docker mr-2 text-blue-400"></i>Host Docker</h3>
|
||||
<button onclick="dockerSection.closeModal()" class="p-2 hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-700">
|
||||
<button class="docker-tab active px-4 py-3 text-sm font-medium" data-tab="containers">
|
||||
<i class="fas fa-box mr-2"></i>Containers
|
||||
</button>
|
||||
<button class="docker-tab px-4 py-3 text-sm font-medium text-gray-400" data-tab="images">
|
||||
<i class="fas fa-layer-group mr-2"></i>Images
|
||||
</button>
|
||||
<button class="docker-tab px-4 py-3 text-sm font-medium text-gray-400" data-tab="volumes">
|
||||
<i class="fas fa-database mr-2"></i>Volumes
|
||||
</button>
|
||||
<button class="docker-tab px-4 py-3 text-sm font-medium text-gray-400" data-tab="host-alerts">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>Alertes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div id="docker-tab-containers" class="docker-tab-content">
|
||||
<div id="docker-containers-list" class="space-y-2">
|
||||
<!-- Containers will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
<div id="docker-tab-images" class="docker-tab-content hidden">
|
||||
<div id="docker-images-list" class="space-y-2">
|
||||
<!-- Images will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
<div id="docker-tab-volumes" class="docker-tab-content hidden">
|
||||
<div id="docker-volumes-list" class="space-y-2">
|
||||
<!-- Volumes will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
<div id="docker-tab-host-alerts" class="docker-tab-content hidden">
|
||||
<div id="docker-host-alerts-list" class="space-y-2">
|
||||
<!-- Host alerts will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Logs Modal -->
|
||||
<div id="docker-logs-modal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||||
<div class="glass-card w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-700">
|
||||
<h3 id="docker-logs-title" class="text-xl font-bold"><i class="fas fa-file-alt mr-2 text-green-400"></i>Container Logs</h3>
|
||||
<button onclick="dockerSection.closeLogsModal()" class="p-2 hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<pre id="docker-logs-content" class="bg-black/50 p-4 rounded-lg text-sm font-mono text-gray-300 whitespace-pre-wrap overflow-x-auto"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="page-configuration" class="page-section">
|
||||
<div class="pt-20 sm:pt-24 pb-8 sm:pb-16 min-h-screen bg-gradient-to-b from-gray-900 to-black">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6">
|
||||
@@ -4109,10 +4341,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Setup desktop theme toggles
|
||||
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
|
||||
document.getElementById('theme-toggle-mobile')?.addEventListener('click', toggleTheme);
|
||||
|
||||
// Initialize mobile nav links state
|
||||
updateMobileNavLinks(currentPage);
|
||||
});
|
||||
@@ -4459,5 +4687,8 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Docker Section JavaScript -->
|
||||
<script src="/static/docker_section.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+81
-36
@@ -364,7 +364,37 @@ class DashboardManager {
|
||||
try {
|
||||
const response = await fetch(url, { ...defaultOptions, ...options });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
// Handle 401 Unauthorized - redirect to login
|
||||
if (response.status === 401) {
|
||||
this.showNotification('Session expirée, reconnexion requise', 'error');
|
||||
this.logout();
|
||||
const err = new Error('Session expirée');
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let errorDetail = null;
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
errorDetail = await response.json();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
errorDetail = text ? { detail: text } : null;
|
||||
}
|
||||
} catch (_) {
|
||||
errorDetail = null;
|
||||
}
|
||||
|
||||
const serverMessage =
|
||||
(errorDetail && (errorDetail.detail || errorDetail.message || errorDetail.error))
|
||||
? (errorDetail.detail || errorDetail.message || errorDetail.error)
|
||||
: response.statusText;
|
||||
|
||||
const err = new Error(`HTTP ${response.status}: ${serverMessage || 'Erreur inconnue'}`);
|
||||
err.status = response.status;
|
||||
err.detail = errorDetail;
|
||||
throw err;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
@@ -583,6 +613,18 @@ class DashboardManager {
|
||||
case 'schedule_run_finished':
|
||||
this.handleScheduleRunFinished(data.data);
|
||||
break;
|
||||
case 'metrics_collection_complete':
|
||||
this.showNotification(
|
||||
`Collecte des métriques terminée: ${data.data?.success || 0}/${data.data?.total || 0} réussi(es)`,
|
||||
(data.data && data.data.failed && data.data.failed > 0) ? 'warning' : 'success'
|
||||
);
|
||||
this.loadHostMetrics().then(() => {
|
||||
this.renderHosts();
|
||||
});
|
||||
this.loadLogs().then(() => {
|
||||
this.renderLogs();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,13 +959,17 @@ class DashboardManager {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Theme toggle
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
this.toggleTheme();
|
||||
});
|
||||
}
|
||||
// Theme toggle (desktop + mobile)
|
||||
const onToggleTheme = () => {
|
||||
this.toggleTheme();
|
||||
};
|
||||
|
||||
document.getElementById('theme-toggle')?.addEventListener('click', onToggleTheme);
|
||||
document.getElementById('theme-toggle-mobile')?.addEventListener('click', onToggleTheme);
|
||||
document.getElementById('mobile-theme-toggle')?.addEventListener('click', onToggleTheme);
|
||||
|
||||
// Expose for inline handlers (e.g. mobile nav button)
|
||||
window.toggleTheme = onToggleTheme;
|
||||
|
||||
// Metrics collection configuration (Configuration page)
|
||||
const configPage = document.getElementById('page-configuration');
|
||||
@@ -1179,26 +1225,35 @@ class DashboardManager {
|
||||
|
||||
toggleTheme() {
|
||||
const body = document.body;
|
||||
const currentTheme = body.classList.contains('light-theme') ? 'light' : 'dark';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
if (newTheme === 'light') {
|
||||
body.classList.add('light-theme');
|
||||
document.getElementById('theme-toggle').innerHTML = '<i class="fas fa-sun text-yellow-400"></i>';
|
||||
} else {
|
||||
body.classList.remove('light-theme');
|
||||
document.getElementById('theme-toggle').innerHTML = '<i class="fas fa-moon text-gray-300"></i>';
|
||||
const isLight = body.classList.toggle('light-theme');
|
||||
|
||||
const themeIcons = document.querySelectorAll('#theme-toggle i, #theme-toggle-mobile i, #mobile-theme-icon');
|
||||
themeIcons.forEach(icon => {
|
||||
icon.className = isLight ? 'fas fa-sun text-yellow-400' : 'fas fa-moon text-gray-300';
|
||||
});
|
||||
|
||||
const mobileLabel = document.getElementById('mobile-theme-label');
|
||||
if (mobileLabel) {
|
||||
mobileLabel.textContent = isLight ? 'Thème clair' : 'Thème sombre';
|
||||
}
|
||||
|
||||
// Persist theme preference
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
loadThemePreference() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
document.getElementById('theme-toggle').innerHTML = '<i class="fas fa-sun text-yellow-400"></i>';
|
||||
|
||||
const themeIcons = document.querySelectorAll('#theme-toggle i, #theme-toggle-mobile i, #mobile-theme-icon');
|
||||
themeIcons.forEach(icon => {
|
||||
icon.className = 'fas fa-sun text-yellow-400';
|
||||
});
|
||||
|
||||
const mobileLabel = document.getElementById('mobile-theme-label');
|
||||
if (mobileLabel) {
|
||||
mobileLabel.textContent = 'Thème clair';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2083,20 +2138,8 @@ class DashboardManager {
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
let message = `Métriques collectées pour ${result.hosts_collected} hôte(s) en ${result.execution_time?.toFixed(1) || 0}s`;
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
message += ` (${result.errors.length} erreur(s))`;
|
||||
console.warn('Erreurs de collecte:', result.errors);
|
||||
}
|
||||
|
||||
this.showNotification(message, result.errors && result.errors.length > 0 ? 'warning' : 'success');
|
||||
|
||||
// Reload host metrics and logs
|
||||
await this.loadHostMetrics();
|
||||
await this.loadLogs();
|
||||
this.renderHosts();
|
||||
this.renderLogs();
|
||||
const message = result.message || `Collecte des métriques lancée pour ${result.hosts_count || 0} hôte(s)`;
|
||||
this.showNotification(message, 'success');
|
||||
} else {
|
||||
const errorMsg = result.error_detail || result.errors?.[0] || 'Erreur inconnue';
|
||||
console.error('Échec de la collecte:', errorMsg);
|
||||
@@ -2104,7 +2147,9 @@ class DashboardManager {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error collecting metrics:', error);
|
||||
const errorMsg = error.detail || error.message || 'Erreur inconnue';
|
||||
const errorMsg = (error.detail && (error.detail.detail || error.detail.message || error.detail.error))
|
||||
? (error.detail.detail || error.detail.message || error.detail.error)
|
||||
: (error.message || 'Erreur inconnue');
|
||||
this.showNotification(`Erreur lors de la collecte des métriques: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
this.metricsLoading = false;
|
||||
|
||||
@@ -9,6 +9,10 @@ from .user import User, UserRole
|
||||
from .host_metrics import HostMetrics
|
||||
from .alert import Alert
|
||||
from .app_setting import AppSetting
|
||||
from .docker_container import DockerContainer
|
||||
from .docker_image import DockerImage
|
||||
from .docker_volume import DockerVolume
|
||||
from .docker_alert import DockerAlert
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -23,4 +27,8 @@ __all__ = [
|
||||
"UserRole",
|
||||
"HostMetrics",
|
||||
"AppSetting",
|
||||
"DockerContainer",
|
||||
"DockerImage",
|
||||
"DockerVolume",
|
||||
"DockerAlert",
|
||||
]
|
||||
|
||||
+13
-1
@@ -112,7 +112,19 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables (mostly for dev/tests; migrations should be handled by Alembic)."""
|
||||
from . import host, task, schedule, schedule_run, log, alert, app_setting # noqa: F401
|
||||
from . import (
|
||||
host,
|
||||
task,
|
||||
schedule,
|
||||
schedule_run,
|
||||
log,
|
||||
alert,
|
||||
app_setting,
|
||||
docker_container,
|
||||
docker_image,
|
||||
docker_volume,
|
||||
docker_alert,
|
||||
) # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Docker Alert model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class DockerAlert(Base):
|
||||
"""Model representing a Docker alert for container issues."""
|
||||
__tablename__ = "docker_alerts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||
container_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="warning") # warning/error/critical
|
||||
state: Mapped[str] = mapped_column(String(20), nullable=False, default="open") # open/closed/acknowledged
|
||||
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
acknowledged_by: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
last_notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationship to host
|
||||
host: Mapped["Host"] = relationship("Host", back_populates="docker_alerts")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_docker_alerts_state_host", "state", "host_id"),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<DockerAlert id={self.id} container={self.container_name} severity={self.severity}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"host_id": self.host_id,
|
||||
"container_name": self.container_name,
|
||||
"severity": self.severity,
|
||||
"state": self.state,
|
||||
"message": self.message,
|
||||
"opened_at": self.opened_at.isoformat() if self.opened_at else None,
|
||||
"closed_at": self.closed_at.isoformat() if self.closed_at else None,
|
||||
"acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None,
|
||||
"acknowledged_by": self.acknowledged_by,
|
||||
"last_notified_at": self.last_notified_at.isoformat() if self.last_notified_at else None,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Docker Container model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class DockerContainer(Base):
|
||||
"""Model representing a Docker container on a host."""
|
||||
__tablename__ = "docker_containers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
image: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
state: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown") # running/exited/paused/created/dead
|
||||
status: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Up 2 hours, Exited (0) 5 minutes ago
|
||||
health: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # healthy/unhealthy/starting/none
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
ports: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
labels: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
compose_project: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # com.docker.compose.project
|
||||
last_update_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationship to host
|
||||
host: Mapped["Host"] = relationship("Host", back_populates="docker_containers")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('host_id', 'container_id', name='uq_docker_containers_host_container'),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<DockerContainer id={self.id} name={self.name} state={self.state}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"host_id": self.host_id,
|
||||
"container_id": self.container_id,
|
||||
"name": self.name,
|
||||
"image": self.image,
|
||||
"state": self.state,
|
||||
"status": self.status,
|
||||
"health": self.health,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"ports": self.ports,
|
||||
"labels": self.labels,
|
||||
"compose_project": self.compose_project,
|
||||
"last_update_at": self.last_update_at.isoformat() if self.last_update_at else None,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Docker Image model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class DockerImage(Base):
|
||||
"""Model representing a Docker image on a host."""
|
||||
__tablename__ = "docker_images"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||
image_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
repo_tags: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # ["nginx:latest", "nginx:1.25"]
|
||||
size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||
created: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_update_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationship to host
|
||||
host: Mapped["Host"] = relationship("Host", back_populates="docker_images")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('host_id', 'image_id', name='uq_docker_images_host_image'),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
tags = self.repo_tags[0] if self.repo_tags else self.image_id[:12]
|
||||
return f"<DockerImage id={self.id} tags={tags}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"host_id": self.host_id,
|
||||
"image_id": self.image_id,
|
||||
"repo_tags": self.repo_tags,
|
||||
"size": self.size,
|
||||
"created": self.created.isoformat() if self.created else None,
|
||||
"last_update_at": self.last_update_at.isoformat() if self.last_update_at else None,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Docker Volume model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class DockerVolume(Base):
|
||||
"""Model representing a Docker volume on a host."""
|
||||
__tablename__ = "docker_volumes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
driver: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
mountpoint: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
scope: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # local/global
|
||||
last_update_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationship to host
|
||||
host: Mapped["Host"] = relationship("Host", back_populates="docker_volumes")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('host_id', 'name', name='uq_docker_volumes_host_name'),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<DockerVolume id={self.id} name={self.name}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"host_id": self.host_id,
|
||||
"name": self.name,
|
||||
"driver": self.driver,
|
||||
"mountpoint": self.mountpoint,
|
||||
"scope": self.scope,
|
||||
"last_update_at": self.last_update_at.isoformat() if self.last_update_at else None,
|
||||
}
|
||||
@@ -24,12 +24,30 @@ class Host(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Docker-related fields
|
||||
docker_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
||||
docker_version: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
docker_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # online/offline/error
|
||||
docker_last_collect_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
bootstrap_statuses: Mapped[List["BootstrapStatus"]] = relationship(
|
||||
"BootstrapStatus", back_populates="host", cascade="all, delete-orphan"
|
||||
)
|
||||
metrics: Mapped[List["HostMetrics"]] = relationship(
|
||||
"HostMetrics", back_populates="host", cascade="all, delete-orphan"
|
||||
)
|
||||
docker_containers: Mapped[List["DockerContainer"]] = relationship(
|
||||
"DockerContainer", back_populates="host", cascade="all, delete-orphan"
|
||||
)
|
||||
docker_images: Mapped[List["DockerImage"]] = relationship(
|
||||
"DockerImage", back_populates="host", cascade="all, delete-orphan"
|
||||
)
|
||||
docker_volumes: Mapped[List["DockerVolume"]] = relationship(
|
||||
"DockerVolume", back_populates="host", cascade="all, delete-orphan"
|
||||
)
|
||||
docker_alerts: Mapped[List["DockerAlert"]] = relationship(
|
||||
"DockerAlert", back_populates="host", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f"<Host id={self.id} name={self.name} ip={self.ip_address}>"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Pydantic schemas for Docker management."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
# === Container Schemas ===
|
||||
|
||||
class DockerContainerBase(BaseModel):
|
||||
"""Base schema for Docker container."""
|
||||
container_id: str = Field(..., description="Docker container ID")
|
||||
name: str = Field(..., description="Container name")
|
||||
image: Optional[str] = Field(None, description="Image name")
|
||||
state: Literal["running", "exited", "paused", "created", "dead", "restarting", "removing", "unknown"] = Field(
|
||||
"unknown", description="Container state"
|
||||
)
|
||||
status: Optional[str] = Field(None, description="Container status string")
|
||||
health: Optional[Literal["healthy", "unhealthy", "starting", "none"]] = Field(
|
||||
None, description="Health check status"
|
||||
)
|
||||
ports: Optional[Dict[str, Any]] = Field(None, description="Port mappings")
|
||||
labels: Optional[Dict[str, str]] = Field(None, description="Container labels")
|
||||
compose_project: Optional[str] = Field(None, description="Docker Compose project name")
|
||||
|
||||
|
||||
class DockerContainerResponse(DockerContainerBase):
|
||||
"""Response schema for Docker container."""
|
||||
id: int
|
||||
host_id: str
|
||||
created_at: Optional[datetime] = None
|
||||
last_update_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DockerContainerListResponse(BaseModel):
|
||||
"""Response for listing containers."""
|
||||
containers: List[DockerContainerResponse]
|
||||
total: int
|
||||
running: int
|
||||
stopped: int
|
||||
|
||||
|
||||
# === Image Schemas ===
|
||||
|
||||
class DockerImageBase(BaseModel):
|
||||
"""Base schema for Docker image."""
|
||||
image_id: str = Field(..., description="Docker image ID")
|
||||
repo_tags: Optional[List[str]] = Field(None, description="Repository tags")
|
||||
size: Optional[int] = Field(None, description="Image size in bytes")
|
||||
|
||||
|
||||
class DockerImageResponse(DockerImageBase):
|
||||
"""Response schema for Docker image."""
|
||||
id: int
|
||||
host_id: str
|
||||
created: Optional[datetime] = None
|
||||
last_update_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DockerImageListResponse(BaseModel):
|
||||
"""Response for listing images."""
|
||||
images: List[DockerImageResponse]
|
||||
total: int
|
||||
total_size: int
|
||||
|
||||
|
||||
# === Volume Schemas ===
|
||||
|
||||
class DockerVolumeBase(BaseModel):
|
||||
"""Base schema for Docker volume."""
|
||||
name: str = Field(..., description="Volume name")
|
||||
driver: Optional[str] = Field(None, description="Volume driver")
|
||||
mountpoint: Optional[str] = Field(None, description="Mount point path")
|
||||
scope: Optional[str] = Field(None, description="Volume scope")
|
||||
|
||||
|
||||
class DockerVolumeResponse(DockerVolumeBase):
|
||||
"""Response schema for Docker volume."""
|
||||
id: int
|
||||
host_id: str
|
||||
last_update_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DockerVolumeListResponse(BaseModel):
|
||||
"""Response for listing volumes."""
|
||||
volumes: List[DockerVolumeResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# === Alert Schemas ===
|
||||
|
||||
class DockerAlertBase(BaseModel):
|
||||
"""Base schema for Docker alert."""
|
||||
container_name: str = Field(..., description="Container name")
|
||||
severity: Literal["warning", "error", "critical"] = Field("warning", description="Alert severity")
|
||||
message: Optional[str] = Field(None, description="Alert message")
|
||||
|
||||
|
||||
class DockerAlertResponse(DockerAlertBase):
|
||||
"""Response schema for Docker alert."""
|
||||
id: int
|
||||
host_id: str
|
||||
state: Literal["open", "closed", "acknowledged"] = "open"
|
||||
opened_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
acknowledged_at: Optional[datetime] = None
|
||||
acknowledged_by: Optional[str] = None
|
||||
last_notified_at: Optional[datetime] = None
|
||||
host_name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DockerAlertListResponse(BaseModel):
|
||||
"""Response for listing alerts."""
|
||||
alerts: List[DockerAlertResponse]
|
||||
total: int
|
||||
open_count: int
|
||||
acknowledged_count: int
|
||||
|
||||
|
||||
class DockerAlertAcknowledgeRequest(BaseModel):
|
||||
"""Request to acknowledge an alert."""
|
||||
note: Optional[str] = Field(None, description="Optional note for acknowledgement")
|
||||
|
||||
|
||||
# === Docker Host Schemas ===
|
||||
|
||||
class DockerHostInfo(BaseModel):
|
||||
"""Docker information for a host."""
|
||||
host_id: str
|
||||
host_name: str
|
||||
host_ip: str
|
||||
docker_enabled: bool = False
|
||||
docker_version: Optional[str] = None
|
||||
docker_status: Optional[Literal["online", "offline", "error"]] = None
|
||||
docker_last_collect_at: Optional[datetime] = None
|
||||
containers_total: int = 0
|
||||
containers_running: int = 0
|
||||
images_total: int = 0
|
||||
volumes_total: int = 0
|
||||
open_alerts: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DockerHostListResponse(BaseModel):
|
||||
"""Response for listing Docker hosts."""
|
||||
hosts: List[DockerHostInfo]
|
||||
total: int
|
||||
enabled: int
|
||||
|
||||
|
||||
class EnableDockerRequest(BaseModel):
|
||||
"""Request to enable Docker monitoring on a host."""
|
||||
enabled: bool = Field(True, description="Enable or disable Docker monitoring")
|
||||
|
||||
|
||||
# === Container Action Schemas ===
|
||||
|
||||
class ContainerActionResponse(BaseModel):
|
||||
"""Response for container actions."""
|
||||
success: bool
|
||||
message: str
|
||||
container_id: str
|
||||
action: str
|
||||
output: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ContainerLogsRequest(BaseModel):
|
||||
"""Request for container logs."""
|
||||
tail: int = Field(200, ge=1, le=5000, description="Number of lines to retrieve")
|
||||
timestamps: bool = Field(False, description="Include timestamps")
|
||||
since: Optional[str] = Field(None, description="Show logs since timestamp or relative time")
|
||||
|
||||
|
||||
class ContainerLogsResponse(BaseModel):
|
||||
"""Response for container logs."""
|
||||
container_id: str
|
||||
container_name: str
|
||||
logs: str
|
||||
lines: int
|
||||
|
||||
|
||||
class ContainerInspectResponse(BaseModel):
|
||||
"""Response for container inspect."""
|
||||
container_id: str
|
||||
container_name: str
|
||||
inspect_data: Dict[str, Any]
|
||||
|
||||
|
||||
# === Collection Schemas ===
|
||||
|
||||
class DockerCollectRequest(BaseModel):
|
||||
"""Request to trigger Docker collection."""
|
||||
force: bool = Field(False, description="Force collection even if recently collected")
|
||||
|
||||
|
||||
class DockerCollectResponse(BaseModel):
|
||||
"""Response for Docker collection."""
|
||||
success: bool
|
||||
host_id: str
|
||||
host_name: str
|
||||
message: str
|
||||
docker_version: Optional[str] = None
|
||||
containers_count: int = 0
|
||||
images_count: int = 0
|
||||
volumes_count: int = 0
|
||||
duration_ms: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class DockerCollectAllResponse(BaseModel):
|
||||
"""Response for collecting all Docker hosts."""
|
||||
success: bool
|
||||
message: str
|
||||
total_hosts: int
|
||||
successful: int
|
||||
failed: int
|
||||
results: List[DockerCollectResponse]
|
||||
|
||||
|
||||
# === Stats Schemas ===
|
||||
|
||||
class DockerStatsResponse(BaseModel):
|
||||
"""Global Docker statistics."""
|
||||
total_hosts: int
|
||||
enabled_hosts: int
|
||||
online_hosts: int
|
||||
total_containers: int
|
||||
running_containers: int
|
||||
total_images: int
|
||||
total_volumes: int
|
||||
open_alerts: int
|
||||
last_collection: Optional[datetime] = None
|
||||
|
||||
|
||||
# === Image Action Schemas ===
|
||||
|
||||
class ImageActionResponse(BaseModel):
|
||||
"""Response for image actions."""
|
||||
success: bool
|
||||
message: str
|
||||
image_id: str
|
||||
action: str
|
||||
output: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# === Extended Image Response with usage info ===
|
||||
|
||||
class DockerImageExtendedResponse(DockerImageBase):
|
||||
"""Response schema for Docker image with usage info."""
|
||||
id: int
|
||||
host_id: str
|
||||
created: Optional[datetime] = None
|
||||
last_update_at: Optional[datetime] = None
|
||||
in_use: bool = Field(True, description="Whether the image is used by at least one container")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DockerImageExtendedListResponse(BaseModel):
|
||||
"""Response for listing images with usage info."""
|
||||
images: List[DockerImageExtendedResponse]
|
||||
total: int
|
||||
total_size: int
|
||||
unused_count: int = 0
|
||||
@@ -11,6 +11,7 @@ mais pas dans la section Tasks (pour éviter de polluer l'interface).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
@@ -107,6 +108,16 @@ BUILTIN_PLAYBOOKS: Dict[str, BuiltinPlaybookDefinition] = {
|
||||
class BuiltinPlaybookService:
|
||||
"""Service pour gérer et exécuter les builtin playbooks."""
|
||||
|
||||
def _loads_jsonish(self, payload: str) -> dict:
|
||||
payload = payload.strip()
|
||||
payload = payload.replace('\\"', '"').replace('\\n', '\n')
|
||||
payload = re.sub(r"\\\s*\n", "", payload)
|
||||
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return ast.literal_eval(payload)
|
||||
|
||||
def __init__(self, ansible_dir: Path, ansible_service=None):
|
||||
"""
|
||||
Args:
|
||||
@@ -266,16 +277,18 @@ class BuiltinPlaybookService:
|
||||
matches = re.findall(pattern, stdout, re.DOTALL)
|
||||
|
||||
print(f"[BUILTIN] Found {len(matches)} METRICS_JSON matches")
|
||||
|
||||
parse_errors: list[str] = []
|
||||
|
||||
for match in matches:
|
||||
try:
|
||||
data = json.loads(match.strip())
|
||||
data = self._loads_jsonish(match)
|
||||
host = data.get("host", "unknown")
|
||||
metrics = data.get("data", {})
|
||||
metrics_by_host[host] = metrics
|
||||
print(f"[BUILTIN] Parsed metrics for host: {host}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[BUILTIN] JSON decode error: {e}")
|
||||
except (ValueError, SyntaxError) as e:
|
||||
parse_errors.append(str(e))
|
||||
continue
|
||||
|
||||
# Fallback: essayer de parser les debug outputs Ansible standards
|
||||
@@ -295,18 +308,20 @@ class BuiltinPlaybookService:
|
||||
try:
|
||||
# Le JSON est échappé dans le msg, il faut le décoder
|
||||
unescaped = match.replace('\\"', '"').replace('\\n', '\n')
|
||||
data = json.loads(unescaped.strip())
|
||||
data = self._loads_jsonish(unescaped)
|
||||
host = data.get("host", "unknown")
|
||||
metrics = data.get("data", {})
|
||||
metrics_by_host[host] = metrics
|
||||
print(f"[BUILTIN] Parsed metrics from msg for host: {host}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[BUILTIN] JSON decode error in msg pattern: {e}")
|
||||
except (ValueError, SyntaxError) as e:
|
||||
parse_errors.append(str(e))
|
||||
continue
|
||||
|
||||
print(f"[BUILTIN] Total hosts with metrics: {len(metrics_by_host)}")
|
||||
if not metrics_by_host and stdout:
|
||||
# Log un extrait du stdout pour debug
|
||||
if parse_errors:
|
||||
print(f"[BUILTIN] JSON decode error: {parse_errors[-1]}")
|
||||
print(f"[BUILTIN] Stdout sample (first 500 chars): {stdout[:500]}")
|
||||
|
||||
return metrics_by_host
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""Docker container actions service.
|
||||
|
||||
Provides methods to execute actions on Docker containers via SSH.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
try:
|
||||
import asyncssh
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
asyncssh = None
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.database import async_session_maker
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.docker_container import DockerContainerRepository
|
||||
|
||||
logger = logging.getLogger("homelab.docker.actions")
|
||||
|
||||
# Standard log prefix
|
||||
LOG_PREFIX = "[DOCKER]"
|
||||
|
||||
|
||||
class DockerActionError(Exception):
|
||||
"""Error executing Docker action."""
|
||||
pass
|
||||
|
||||
|
||||
class DockerActionsService:
|
||||
"""Service for executing Docker container actions via SSH."""
|
||||
|
||||
def __init__(self):
|
||||
self.ssh_key_path = settings.ssh_key_path
|
||||
self.ssh_user = settings.ssh_user
|
||||
self.connect_timeout = 5
|
||||
self.exec_timeout = 30 # Longer timeout for actions
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _get_lock(self, container_key: str) -> asyncio.Lock:
|
||||
"""Get or create a lock for a container to prevent concurrent actions."""
|
||||
if container_key not in self._locks:
|
||||
self._locks[container_key] = asyncio.Lock()
|
||||
return self._locks[container_key]
|
||||
|
||||
async def _ssh_connect(self, host_ip: str) -> asyncssh.SSHClientConnection:
|
||||
"""Establish SSH connection to a host."""
|
||||
if asyncssh is None:
|
||||
raise DockerActionError(
|
||||
"Missing dependency: asyncssh. Install it to enable Docker container actions over SSH."
|
||||
)
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncssh.connect(
|
||||
host_ip,
|
||||
username=self.ssh_user,
|
||||
client_keys=[self.ssh_key_path],
|
||||
known_hosts=None,
|
||||
),
|
||||
timeout=self.connect_timeout
|
||||
)
|
||||
return conn
|
||||
except asyncio.TimeoutError:
|
||||
raise DockerActionError(f"SSH connection timeout to {host_ip}")
|
||||
except asyncssh.Error as e:
|
||||
raise DockerActionError(f"SSH error: {e}")
|
||||
except Exception as e:
|
||||
raise DockerActionError(f"Connection failed: {e}")
|
||||
|
||||
async def _ssh_exec(
|
||||
self,
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
command: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a command and return result dict."""
|
||||
timeout = timeout or self.exec_timeout
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(command, check=False),
|
||||
timeout=timeout
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout or "",
|
||||
"stderr": result.stderr or "",
|
||||
"exit_code": result.exit_status,
|
||||
"success": result.exit_status == 0
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
raise DockerActionError(f"Command timeout")
|
||||
except Exception as e:
|
||||
raise DockerActionError(f"Execution error: {e}")
|
||||
|
||||
async def _run_docker(
|
||||
self,
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
cmd: str,
|
||||
use_sudo_state: Dict[str, Optional[bool]],
|
||||
timeout: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a docker command, preferring sudo -n to avoid docker.sock permission issues.
|
||||
|
||||
use_sudo_state is a mutable dict holding a cached decision for the current connection.
|
||||
"""
|
||||
|
||||
def _is_sudo_unavailable(stderr: str) -> bool:
|
||||
s = (stderr or "").lower()
|
||||
return any(
|
||||
token in s
|
||||
for token in (
|
||||
"sudo: a password is required",
|
||||
"sudo: no tty present",
|
||||
"not in the sudoers",
|
||||
"sudo: permission denied",
|
||||
"sudo: command not found",
|
||||
)
|
||||
)
|
||||
|
||||
use_sudo = use_sudo_state.get("value")
|
||||
|
||||
if use_sudo is True:
|
||||
return await self._ssh_exec(conn, f"sudo -n {cmd}", timeout=timeout)
|
||||
if use_sudo is False:
|
||||
return await self._ssh_exec(conn, cmd, timeout=timeout)
|
||||
|
||||
# First try with sudo -n
|
||||
sudo_res = await self._ssh_exec(conn, f"sudo -n {cmd}", timeout=timeout)
|
||||
if sudo_res["success"]:
|
||||
use_sudo_state["value"] = True
|
||||
return sudo_res
|
||||
|
||||
# If sudo is unavailable, fall back to plain docker
|
||||
if _is_sudo_unavailable(sudo_res.get("stderr", "")):
|
||||
use_sudo_state["value"] = False
|
||||
return await self._ssh_exec(conn, cmd, timeout=timeout)
|
||||
|
||||
# Sudo ran but failed; try plain docker (rootless docker)
|
||||
plain_res = await self._ssh_exec(conn, cmd, timeout=timeout)
|
||||
if plain_res["success"]:
|
||||
use_sudo_state["value"] = False
|
||||
return plain_res
|
||||
|
||||
# Keep sudo as preferred (best chance for next commands)
|
||||
use_sudo_state["value"] = True
|
||||
merged = (plain_res.get("stderr") or "").strip()
|
||||
sudo_err = (sudo_res.get("stderr") or "").strip()
|
||||
if sudo_err:
|
||||
merged = (merged + "\n" if merged else "") + f"sudo_err: {sudo_err}"
|
||||
plain_res["stderr"] = merged
|
||||
return plain_res
|
||||
|
||||
async def _get_host_info(self, host_id: str) -> Dict[str, Any]:
|
||||
"""Get host information from database."""
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
host = await host_repo.get(host_id)
|
||||
|
||||
if not host:
|
||||
raise DockerActionError(f"Host not found: {host_id}")
|
||||
|
||||
return {
|
||||
"id": host.id,
|
||||
"name": host.name,
|
||||
"ip": host.ip_address
|
||||
}
|
||||
|
||||
async def start_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Start a stopped container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._run_docker(conn, f"docker start {container_id}", use_sudo_state)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container started successfully" if result["success"] else "Failed to start container",
|
||||
"container_id": container_id,
|
||||
"action": "start",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "start",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def stop_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
timeout: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""Stop a running container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"sudo -n docker stop -t {timeout} {container_id}",
|
||||
timeout=timeout + 10
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
# Use the common docker runner to handle sudo fallback and rootless docker
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker stop -t {timeout} {container_id}",
|
||||
use_sudo_state,
|
||||
timeout=timeout + 10,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container stopped successfully" if result["success"] else "Failed to stop container",
|
||||
"container_id": container_id,
|
||||
"action": "stop",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "stop",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def restart_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
timeout: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""Restart a container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"sudo -n docker restart -t {timeout} {container_id}",
|
||||
timeout=timeout + 15
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker restart -t {timeout} {container_id}",
|
||||
use_sudo_state,
|
||||
timeout=timeout + 15,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container restarted successfully" if result["success"] else "Failed to restart container",
|
||||
"container_id": container_id,
|
||||
"action": "restart",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "restart",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def remove_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
force: bool = False,
|
||||
remove_volumes: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Remove a container."""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
flags = []
|
||||
if force:
|
||||
flags.append("-f")
|
||||
if remove_volumes:
|
||||
flags.append("-v")
|
||||
|
||||
flags_str = " ".join(flags)
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker rm {flags_str} {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container removed successfully" if result["success"] else "Failed to remove container",
|
||||
"container_id": container_id,
|
||||
"action": "remove",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "remove",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def get_container_logs(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str,
|
||||
tail: int = 200,
|
||||
timestamps: bool = False,
|
||||
since: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get container logs."""
|
||||
host = await self._get_host_info(host_id)
|
||||
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
flags = [f"--tail {tail}"]
|
||||
if timestamps:
|
||||
flags.append("--timestamps")
|
||||
if since:
|
||||
flags.append(f"--since {since}")
|
||||
|
||||
flags_str = " ".join(flags)
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker logs {flags_str} {container_id} 2>&1",
|
||||
use_sudo_state,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Get container name
|
||||
name_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{.Name}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
container_name = name_result["stdout"].strip().lstrip("/")
|
||||
|
||||
logs = result["stdout"]
|
||||
lines = len(logs.split('\n')) if logs else 0
|
||||
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": container_name,
|
||||
"logs": logs,
|
||||
"lines": lines
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": "",
|
||||
"logs": f"Error retrieving logs: {e}",
|
||||
"lines": 0
|
||||
}
|
||||
|
||||
async def inspect_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Get detailed container information."""
|
||||
host = await self._get_host_info(host_id)
|
||||
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
import json
|
||||
try:
|
||||
inspect_data = json.loads(result["stdout"])
|
||||
if isinstance(inspect_data, list) and len(inspect_data) > 0:
|
||||
inspect_data = inspect_data[0]
|
||||
|
||||
container_name = inspect_data.get("Name", "").lstrip("/")
|
||||
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": container_name,
|
||||
"inspect_data": inspect_data
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": "",
|
||||
"inspect_data": {"error": result["stderr"] or "Failed to parse inspect data"}
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"container_name": "",
|
||||
"inspect_data": {"error": str(e)}
|
||||
}
|
||||
|
||||
async def redeploy_container(
|
||||
self,
|
||||
host_id: str,
|
||||
container_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Redeploy a container by pulling latest image and recreating.
|
||||
|
||||
This only works for containers started with docker-compose or with
|
||||
enough metadata to recreate them.
|
||||
"""
|
||||
host = await self._get_host_info(host_id)
|
||||
lock_key = f"{host_id}:{container_id}"
|
||||
|
||||
async with self._get_lock(lock_key):
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
# Get container info first
|
||||
inspect_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{.Config.Image}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
if not inspect_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get container image",
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": None,
|
||||
"error": inspect_result["stderr"]
|
||||
}
|
||||
|
||||
image = inspect_result["stdout"].strip()
|
||||
|
||||
# Check if it's a compose container
|
||||
labels_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
compose_dir = labels_result["stdout"].strip()
|
||||
|
||||
if compose_dir:
|
||||
# Use docker-compose to redeploy
|
||||
service_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.service\"}}}}' {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
service_name = service_result["stdout"].strip()
|
||||
|
||||
# Pull and recreate with docker-compose
|
||||
# Keep working directory change in shell, run docker via sudo -n
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"cd {compose_dir} && sudo -n docker compose pull {service_name} && sudo -n docker compose up -d {service_name}",
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
# Fallback: try without sudo (rootless docker / sudo unavailable)
|
||||
result = await self._ssh_exec(
|
||||
conn,
|
||||
f"cd {compose_dir} && docker compose pull {service_name} && docker compose up -d {service_name}",
|
||||
timeout=120,
|
||||
)
|
||||
else:
|
||||
# Simple pull and restart
|
||||
pull_result = await self._run_docker(
|
||||
conn,
|
||||
f"docker pull {image}",
|
||||
use_sudo_state,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if not pull_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to pull image",
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": pull_result["stdout"],
|
||||
"error": pull_result["stderr"]
|
||||
}
|
||||
|
||||
# Restart container with new image
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker restart {container_id}",
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Container redeployed successfully" if result["success"] else "Failed to redeploy container",
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"container_id": container_id,
|
||||
"action": "redeploy",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def remove_image(
|
||||
self,
|
||||
host_id: str,
|
||||
image_id: str,
|
||||
force: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Remove a Docker image."""
|
||||
host = await self._get_host_info(host_id)
|
||||
|
||||
try:
|
||||
conn = await self._ssh_connect(host["ip"])
|
||||
try:
|
||||
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
|
||||
flags = "-f" if force else ""
|
||||
result = await self._run_docker(
|
||||
conn,
|
||||
f"docker rmi {flags} {image_id}".strip(),
|
||||
use_sudo_state,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Image removed successfully" if result["success"] else "Failed to remove image",
|
||||
"image_id": image_id,
|
||||
"action": "remove",
|
||||
"output": result["stdout"].strip(),
|
||||
"error": result["stderr"].strip() if not result["success"] else None
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
except DockerActionError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"image_id": image_id,
|
||||
"action": "remove",
|
||||
"output": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
docker_actions = DockerActionsService()
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Docker alerts service.
|
||||
|
||||
Handles detection of container issues and alert notifications.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.models.database import async_session_maker
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.docker_container import DockerContainerRepository
|
||||
from app.crud.docker_alert import DockerAlertRepository
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.websocket_service import ws_manager
|
||||
|
||||
logger = logging.getLogger("homelab.docker.alerts")
|
||||
|
||||
# Cooldown period between notifications for the same alert (5 minutes)
|
||||
NOTIFICATION_COOLDOWN_SECONDS = 300
|
||||
|
||||
|
||||
class DockerAlertsService:
|
||||
"""Service for detecting Docker container issues and managing alerts."""
|
||||
|
||||
def __init__(self):
|
||||
self._last_check: Optional[datetime] = None
|
||||
|
||||
async def check_all_alerts(self) -> Dict[str, Any]:
|
||||
"""Check all Docker-enabled hosts for container issues.
|
||||
|
||||
Returns:
|
||||
Summary of alerts opened/closed
|
||||
"""
|
||||
result = {
|
||||
"hosts_checked": 0,
|
||||
"alerts_opened": 0,
|
||||
"alerts_closed": 0,
|
||||
"notifications_sent": 0
|
||||
}
|
||||
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
container_repo = DockerContainerRepository(session)
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
|
||||
# Get all Docker-enabled hosts
|
||||
hosts = await host_repo.list_docker_enabled()
|
||||
result["hosts_checked"] = len(hosts)
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
# Get all containers for this host
|
||||
containers = await container_repo.list_by_host(host.id)
|
||||
|
||||
for container in containers:
|
||||
await self._check_container(
|
||||
session, host, container, alert_repo, result
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking alerts for host {host.id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
self._last_check = datetime.now(timezone.utc)
|
||||
return result
|
||||
|
||||
async def _check_container(
|
||||
self,
|
||||
session,
|
||||
host,
|
||||
container,
|
||||
alert_repo: DockerAlertRepository,
|
||||
result: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Check a single container for issues."""
|
||||
labels = container.labels or {}
|
||||
|
||||
# Only monitor containers with homelab.monitor=true label
|
||||
should_monitor = labels.get("homelab.monitor", "").lower() == "true"
|
||||
if not should_monitor:
|
||||
return
|
||||
|
||||
expected_state = labels.get("homelab.desired", "running")
|
||||
current_state = container.state
|
||||
health = container.health
|
||||
|
||||
alert_needed = False
|
||||
severity = "warning"
|
||||
message = None
|
||||
|
||||
# Case 1: Container should be running but isn't
|
||||
if expected_state == "running" and current_state != "running":
|
||||
alert_needed = True
|
||||
severity = "error"
|
||||
message = f"Container {container.name} is {current_state}, expected running"
|
||||
|
||||
# Case 2: Container is unhealthy
|
||||
elif health == "unhealthy":
|
||||
alert_needed = True
|
||||
severity = "warning"
|
||||
message = f"Container {container.name} health check failing"
|
||||
|
||||
# Case 3: Container is healthy and running - close any open alerts
|
||||
elif current_state == "running" and health in ("healthy", None, "none"):
|
||||
closed = await alert_repo.close_for_container(host.id, container.name)
|
||||
if closed > 0:
|
||||
result["alerts_closed"] += closed
|
||||
logger.info(f"Closed {closed} alert(s) for {container.name} on {host.name}")
|
||||
|
||||
# Broadcast alert closure
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_closed",
|
||||
"data": {
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
"container_name": container.name
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
if alert_needed:
|
||||
await self._open_or_update_alert(
|
||||
session, host, container.name, severity, message, alert_repo, result
|
||||
)
|
||||
|
||||
async def _open_or_update_alert(
|
||||
self,
|
||||
session,
|
||||
host,
|
||||
container_name: str,
|
||||
severity: str,
|
||||
message: str,
|
||||
alert_repo: DockerAlertRepository,
|
||||
result: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Open a new alert or update existing one."""
|
||||
# Check for existing open alert
|
||||
existing = await alert_repo.get_open_alert(host.id, container_name)
|
||||
|
||||
if existing:
|
||||
# Check if we should send another notification
|
||||
should_notify = False
|
||||
if existing.last_notified_at:
|
||||
cooldown_elapsed = (
|
||||
datetime.now(timezone.utc) - existing.last_notified_at.replace(tzinfo=timezone.utc)
|
||||
).total_seconds() > NOTIFICATION_COOLDOWN_SECONDS
|
||||
should_notify = cooldown_elapsed
|
||||
else:
|
||||
should_notify = True
|
||||
|
||||
if should_notify:
|
||||
await self._send_notification(host, container_name, severity, message)
|
||||
await alert_repo.update_last_notified(existing.id)
|
||||
result["notifications_sent"] += 1
|
||||
else:
|
||||
# Create new alert
|
||||
alert = await alert_repo.create(
|
||||
host_id=host.id,
|
||||
container_name=container_name,
|
||||
severity=severity,
|
||||
message=message
|
||||
)
|
||||
result["alerts_opened"] += 1
|
||||
logger.info(f"Opened alert for {container_name} on {host.name}: {message}")
|
||||
|
||||
# Send notification
|
||||
await self._send_notification(host, container_name, severity, message)
|
||||
await alert_repo.update_last_notified(alert.id)
|
||||
result["notifications_sent"] += 1
|
||||
|
||||
# Broadcast new alert via WebSocket
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_opened",
|
||||
"data": {
|
||||
"id": alert.id,
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
"container_name": container_name,
|
||||
"severity": severity,
|
||||
"message": message,
|
||||
"opened_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
})
|
||||
|
||||
async def _send_notification(
|
||||
self,
|
||||
host,
|
||||
container_name: str,
|
||||
severity: str,
|
||||
message: str
|
||||
) -> None:
|
||||
"""Send ntfy notification for an alert."""
|
||||
try:
|
||||
priority_map = {
|
||||
"warning": 3,
|
||||
"error": 4,
|
||||
"critical": 5
|
||||
}
|
||||
priority = priority_map.get(severity, 3)
|
||||
|
||||
emoji = "🚨" if severity == "critical" else "⚠️" if severity == "error" else "🔔"
|
||||
|
||||
await notification_service.send(
|
||||
topic="homelab-docker",
|
||||
title=f"{emoji} Docker Alert - {host.name}",
|
||||
message=f"{container_name}: {message}",
|
||||
priority=priority,
|
||||
tags=["docker", severity]
|
||||
)
|
||||
logger.info(f"Sent notification for {container_name} on {host.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification: {e}")
|
||||
|
||||
async def acknowledge_alert(
|
||||
self,
|
||||
alert_id: int,
|
||||
acknowledged_by: str,
|
||||
note: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Acknowledge an alert."""
|
||||
async with async_session_maker() as session:
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
host_repo = HostRepository(session)
|
||||
|
||||
alert = await alert_repo.acknowledge(alert_id, acknowledged_by)
|
||||
|
||||
if alert:
|
||||
host = await host_repo.get(alert.host_id)
|
||||
await session.commit()
|
||||
|
||||
# Broadcast acknowledgement
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_acknowledged",
|
||||
"data": {
|
||||
"id": alert.id,
|
||||
"host_id": alert.host_id,
|
||||
"host_name": host.name if host else "",
|
||||
"container_name": alert.container_name,
|
||||
"acknowledged_by": acknowledged_by,
|
||||
"acknowledged_at": alert.acknowledged_at.isoformat() if alert.acknowledged_at else None
|
||||
}
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
return None
|
||||
|
||||
async def close_alert(self, alert_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Manually close an alert."""
|
||||
async with async_session_maker() as session:
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
host_repo = HostRepository(session)
|
||||
|
||||
alert = await alert_repo.close(alert_id)
|
||||
|
||||
if alert:
|
||||
host = await host_repo.get(alert.host_id)
|
||||
await session.commit()
|
||||
|
||||
# Broadcast closure
|
||||
await ws_manager.broadcast({
|
||||
"type": "docker_alert_closed",
|
||||
"data": {
|
||||
"id": alert.id,
|
||||
"host_id": alert.host_id,
|
||||
"host_name": host.name if host else "",
|
||||
"container_name": alert.container_name
|
||||
}
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
return None
|
||||
|
||||
async def get_alerts(
|
||||
self,
|
||||
host_id: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""Get alerts with optional filters."""
|
||||
async with async_session_maker() as session:
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
host_repo = HostRepository(session)
|
||||
|
||||
alerts = await alert_repo.list_alerts(
|
||||
host_id=host_id,
|
||||
state=state,
|
||||
severity=severity,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
counts = await alert_repo.count_alerts(host_id=host_id)
|
||||
|
||||
# Enrich with host names
|
||||
result = []
|
||||
for alert in alerts:
|
||||
alert_dict = alert.to_dict()
|
||||
host = await host_repo.get(alert.host_id)
|
||||
alert_dict["host_name"] = host.name if host else ""
|
||||
result.append(alert_dict)
|
||||
|
||||
return {
|
||||
"alerts": result,
|
||||
"total": counts["total"],
|
||||
"open_count": counts["open"],
|
||||
"acknowledged_count": counts["acknowledged"]
|
||||
}
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get global Docker statistics."""
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select, func
|
||||
from app.models import Host, DockerContainer, DockerImage, DockerVolume, DockerAlert
|
||||
|
||||
# Count hosts
|
||||
total_hosts = await session.execute(
|
||||
select(func.count(Host.id)).where(Host.deleted_at.is_(None))
|
||||
)
|
||||
enabled_hosts = await session.execute(
|
||||
select(func.count(Host.id)).where(
|
||||
Host.deleted_at.is_(None),
|
||||
Host.docker_enabled == True
|
||||
)
|
||||
)
|
||||
online_hosts = await session.execute(
|
||||
select(func.count(Host.id)).where(
|
||||
Host.deleted_at.is_(None),
|
||||
Host.docker_enabled == True,
|
||||
Host.docker_status == "online"
|
||||
)
|
||||
)
|
||||
|
||||
# Count containers
|
||||
total_containers = await session.execute(
|
||||
select(func.count(DockerContainer.id))
|
||||
)
|
||||
running_containers = await session.execute(
|
||||
select(func.count(DockerContainer.id)).where(
|
||||
DockerContainer.state == "running"
|
||||
)
|
||||
)
|
||||
|
||||
# Count images and volumes
|
||||
total_images = await session.execute(
|
||||
select(func.count(DockerImage.id))
|
||||
)
|
||||
total_volumes = await session.execute(
|
||||
select(func.count(DockerVolume.id))
|
||||
)
|
||||
|
||||
# Count open alerts
|
||||
open_alerts = await session.execute(
|
||||
select(func.count(DockerAlert.id)).where(
|
||||
DockerAlert.state == "open"
|
||||
)
|
||||
)
|
||||
|
||||
# Last collection time
|
||||
last_collect = await session.execute(
|
||||
select(func.max(Host.docker_last_collect_at)).where(
|
||||
Host.docker_enabled == True
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_hosts": total_hosts.scalar() or 0,
|
||||
"enabled_hosts": enabled_hosts.scalar() or 0,
|
||||
"online_hosts": online_hosts.scalar() or 0,
|
||||
"total_containers": total_containers.scalar() or 0,
|
||||
"running_containers": running_containers.scalar() or 0,
|
||||
"total_images": total_images.scalar() or 0,
|
||||
"total_volumes": total_volumes.scalar() or 0,
|
||||
"open_alerts": open_alerts.scalar() or 0,
|
||||
"last_collection": last_collect.scalar()
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
docker_alerts_service = DockerAlertsService()
|
||||
@@ -0,0 +1,610 @@
|
||||
"""Docker collection service using SSH.
|
||||
|
||||
Collects Docker information from hosts via SSH commands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
|
||||
try:
|
||||
import asyncssh
|
||||
_asyncssh_import_error: Optional[BaseException] = None
|
||||
except ModuleNotFoundError as e: # pragma: no cover
|
||||
asyncssh = None
|
||||
_asyncssh_import_error = e
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.database import async_session_maker
|
||||
from app.crud.host import HostRepository
|
||||
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.services.ansible_service import ansible_service
|
||||
|
||||
logger = logging.getLogger("homelab.docker")
|
||||
|
||||
# Log prefix for standardized output
|
||||
LOG_PREFIX = "[DOCKER]"
|
||||
|
||||
|
||||
class SSHConnectionError(Exception):
|
||||
"""Error connecting to host via SSH."""
|
||||
pass
|
||||
|
||||
|
||||
class DockerNotAvailableError(Exception):
|
||||
"""Docker is not available on the host."""
|
||||
pass
|
||||
|
||||
|
||||
class DockerService:
|
||||
"""Service for collecting Docker information via SSH."""
|
||||
|
||||
def __init__(self):
|
||||
self.ssh_key_path = settings.ssh_key_path
|
||||
self.ssh_user = settings.ssh_user
|
||||
self.ssh_remote_user = settings.ssh_remote_user
|
||||
self.connect_timeout = 5
|
||||
self.exec_timeout = 15
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _get_lock(self, host_id: str) -> asyncio.Lock:
|
||||
"""Get or create a lock for a host to prevent concurrent operations."""
|
||||
if host_id not in self._locks:
|
||||
self._locks[host_id] = asyncio.Lock()
|
||||
return self._locks[host_id]
|
||||
|
||||
async def _ssh_connect(self, host_ip: str) -> asyncssh.SSHClientConnection:
|
||||
"""Establish SSH connection to a host."""
|
||||
if asyncssh is None:
|
||||
err = None
|
||||
try:
|
||||
err = str(_asyncssh_import_error) if _asyncssh_import_error else None
|
||||
except Exception:
|
||||
err = None
|
||||
raise SSHConnectionError(
|
||||
"Missing dependency: asyncssh. Install it to enable Docker SSH collection. "
|
||||
f"(python={sys.executable}, import_error={err})"
|
||||
)
|
||||
last_err: Optional[BaseException] = None
|
||||
tried_users: List[str] = []
|
||||
for username in [self.ssh_remote_user, self.ssh_user]:
|
||||
if not username or username in tried_users:
|
||||
continue
|
||||
tried_users.append(username)
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncssh.connect(
|
||||
host_ip,
|
||||
username=username,
|
||||
client_keys=[self.ssh_key_path],
|
||||
known_hosts=None, # Accept any host key (homelab environment)
|
||||
),
|
||||
timeout=self.connect_timeout
|
||||
)
|
||||
return conn
|
||||
except asyncio.TimeoutError as e:
|
||||
last_err = e
|
||||
continue
|
||||
except asyncssh.Error as e:
|
||||
last_err = e
|
||||
continue
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
|
||||
if isinstance(last_err, asyncio.TimeoutError):
|
||||
raise SSHConnectionError(f"SSH connection timeout to {host_ip} (users tried: {', '.join(tried_users)})")
|
||||
raise SSHConnectionError(
|
||||
f"SSH error connecting to {host_ip} (users tried: {', '.join(tried_users)}): {last_err}"
|
||||
)
|
||||
|
||||
async def _ssh_exec(
|
||||
self,
|
||||
conn: asyncssh.SSHClientConnection,
|
||||
command: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> Tuple[str, str, int]:
|
||||
"""Execute a command via SSH and return stdout, stderr, exit_code."""
|
||||
timeout = timeout or self.exec_timeout
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(command, check=False),
|
||||
timeout=timeout
|
||||
)
|
||||
return result.stdout or "", result.stderr or "", result.exit_status
|
||||
except asyncio.TimeoutError:
|
||||
raise SSHConnectionError(f"Command timeout: {command[:50]}...")
|
||||
except Exception as e:
|
||||
raise SSHConnectionError(f"Command execution error: {e}")
|
||||
|
||||
async def _parse_docker_json_lines(self, output: str) -> List[Dict[str, Any]]:
|
||||
"""Parse Docker JSON output (one JSON object per line)."""
|
||||
results = []
|
||||
for line in output.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
results.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return results
|
||||
|
||||
async def collect_docker_host(self, host_id: str) -> Dict[str, Any]:
|
||||
"""Collect Docker information from a single host.
|
||||
|
||||
Returns:
|
||||
Dict with collection results including success status, counts, etc.
|
||||
"""
|
||||
start_time = time.time()
|
||||
result = {
|
||||
"success": False,
|
||||
"host_id": host_id,
|
||||
"host_name": "",
|
||||
"message": "",
|
||||
"docker_version": None,
|
||||
"containers_count": 0,
|
||||
"images_count": 0,
|
||||
"volumes_count": 0,
|
||||
"error": None,
|
||||
"duration_ms": 0
|
||||
}
|
||||
|
||||
async with self._get_lock(host_id):
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
# Get host info
|
||||
host_repo = HostRepository(session)
|
||||
host = await host_repo.get(host_id)
|
||||
|
||||
if not host:
|
||||
result["error"] = "Host not found"
|
||||
result["message"] = "Host not found"
|
||||
return result
|
||||
|
||||
result["host_name"] = host.name
|
||||
host_ip = host.ip_address
|
||||
|
||||
print(f"{LOG_PREFIX} Starting collection for host {host.name} ({host_id[:8]}...) at {host_ip}")
|
||||
|
||||
# Connect via SSH
|
||||
conn = await self._ssh_connect(host_ip)
|
||||
|
||||
try:
|
||||
use_sudo: Optional[bool] = None
|
||||
|
||||
async def run_docker(cmd: str) -> Tuple[str, str, int]:
|
||||
nonlocal use_sudo
|
||||
|
||||
def _is_sudo_unavailable(stderr: str) -> bool:
|
||||
s = (stderr or "").lower()
|
||||
return any(
|
||||
token in s
|
||||
for token in (
|
||||
"sudo: a password is required",
|
||||
"sudo: no tty present",
|
||||
"not in the sudoers",
|
||||
"sudo: permission denied",
|
||||
"sudo: command not found",
|
||||
)
|
||||
)
|
||||
|
||||
# If we've already determined sudo behavior, reuse it.
|
||||
if use_sudo is True:
|
||||
return await self._ssh_exec(conn, f"sudo -n {cmd}")
|
||||
if use_sudo is False:
|
||||
return await self._ssh_exec(conn, cmd)
|
||||
|
||||
# First call: prefer sudo -n (passwordless sudo) to avoid docker.sock permission issues.
|
||||
sudo_out, sudo_err, sudo_code = await self._ssh_exec(conn, f"sudo -n {cmd}")
|
||||
if sudo_code == 0:
|
||||
use_sudo = True
|
||||
return sudo_out, sudo_err, sudo_code
|
||||
|
||||
# If sudo is not available, fall back to non-sudo.
|
||||
if _is_sudo_unavailable(sudo_err):
|
||||
use_sudo = False
|
||||
return await self._ssh_exec(conn, cmd)
|
||||
|
||||
# Sudo ran but command failed; try non-sudo as a fallback (e.g., rootless docker).
|
||||
plain_out, plain_err, plain_code = await self._ssh_exec(conn, cmd)
|
||||
if plain_code == 0:
|
||||
use_sudo = False
|
||||
return plain_out, plain_err, plain_code
|
||||
|
||||
# Keep sudo as preferred for subsequent commands (best chance to avoid docker.sock issues)
|
||||
use_sudo = True
|
||||
merged_err = (plain_err or "").strip()
|
||||
if sudo_err:
|
||||
merged_err = (merged_err + "\n" if merged_err else "") + f"sudo_err: {(sudo_err or '').strip()}"
|
||||
return plain_out, merged_err, plain_code
|
||||
|
||||
# Get Docker version
|
||||
base_version_cmd = "docker version --format '{{.Server.Version}}'"
|
||||
version_out, version_err, version_code = await run_docker(base_version_cmd)
|
||||
|
||||
if version_code != 0:
|
||||
err = (version_err or "").strip()
|
||||
if "permission denied" in err.lower() and "docker.sock" in err.lower():
|
||||
raise DockerNotAvailableError(
|
||||
"Docker not available: permission denied while trying to connect to the Docker daemon socket. "
|
||||
"Fix one of: set SSH_REMOTE_USER=root, add the SSH user to the 'docker' group, "
|
||||
"or allow passwordless sudo for docker (sudo -n). "
|
||||
f"Raw error: {err}"
|
||||
)
|
||||
raise DockerNotAvailableError(f"Docker not available: {err}")
|
||||
|
||||
docker_version = version_out.strip()
|
||||
result["docker_version"] = docker_version
|
||||
print(f"{LOG_PREFIX} {host.name}: Docker version {docker_version}, using sudo={use_sudo}")
|
||||
|
||||
# Collect containers
|
||||
containers_out, _, _ = await run_docker(
|
||||
"docker ps -a --format '{{json .}}' --no-trunc"
|
||||
)
|
||||
containers_data = await self._parse_docker_json_lines(containers_out)
|
||||
print(f"{LOG_PREFIX} {host.name}: Found {len(containers_data)} container(s)")
|
||||
|
||||
# Collect images
|
||||
images_out, _, _ = await run_docker(
|
||||
"docker images --format '{{json .}}'"
|
||||
)
|
||||
images_data = await self._parse_docker_json_lines(images_out)
|
||||
print(f"{LOG_PREFIX} {host.name}: Found {len(images_data)} image(s)")
|
||||
|
||||
# Collect volumes
|
||||
volumes_out, _, _ = await run_docker(
|
||||
"docker volume ls --format '{{json .}}'"
|
||||
)
|
||||
volumes_data = await self._parse_docker_json_lines(volumes_out)
|
||||
print(f"{LOG_PREFIX} {host.name}: Found {len(volumes_data)} volume(s)")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Store in database
|
||||
container_repo = DockerContainerRepository(session)
|
||||
image_repo = DockerImageRepository(session)
|
||||
volume_repo = DockerVolumeRepository(session)
|
||||
|
||||
# Process containers
|
||||
container_ids = []
|
||||
for c in containers_data:
|
||||
container_id = c.get("ID", "")
|
||||
container_ids.append(container_id)
|
||||
|
||||
# Parse created time
|
||||
created_at = None
|
||||
created_str = c.get("CreatedAt", "")
|
||||
if created_str:
|
||||
try:
|
||||
# Docker format: "2024-01-15 10:30:00 -0500 EST"
|
||||
created_at = datetime.fromisoformat(
|
||||
created_str.split(" ")[0] + "T" + created_str.split(" ")[1]
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Extract compose project from labels
|
||||
compose_project = None
|
||||
labels = c.get("Labels", "")
|
||||
if labels:
|
||||
for label in labels.split(","):
|
||||
if "com.docker.compose.project=" in label:
|
||||
compose_project = label.split("=", 1)[1]
|
||||
break
|
||||
|
||||
# Parse labels into dict
|
||||
labels_dict = {}
|
||||
if labels:
|
||||
for label in labels.split(","):
|
||||
if "=" in label:
|
||||
k, v = label.split("=", 1)
|
||||
labels_dict[k] = v
|
||||
|
||||
# Parse ports
|
||||
ports_str = c.get("Ports", "")
|
||||
ports = {"raw": ports_str} if ports_str else None
|
||||
|
||||
# Determine health from status
|
||||
status = c.get("Status", "")
|
||||
health = None
|
||||
if "(healthy)" in status.lower():
|
||||
health = "healthy"
|
||||
elif "(unhealthy)" in status.lower():
|
||||
health = "unhealthy"
|
||||
elif "(health: starting)" in status.lower():
|
||||
health = "starting"
|
||||
|
||||
await container_repo.upsert(
|
||||
host_id=host_id,
|
||||
container_id=container_id,
|
||||
name=c.get("Names", "").lstrip("/"),
|
||||
image=c.get("Image", ""),
|
||||
state=c.get("State", "unknown").lower(),
|
||||
status=status,
|
||||
health=health,
|
||||
created_at=created_at,
|
||||
ports=ports,
|
||||
labels=labels_dict if labels_dict else None,
|
||||
compose_project=compose_project
|
||||
)
|
||||
|
||||
# Remove stale containers
|
||||
await container_repo.delete_stale(host_id, container_ids)
|
||||
result["containers_count"] = len(containers_data)
|
||||
|
||||
# Process images
|
||||
image_ids = []
|
||||
for img in images_data:
|
||||
image_id = img.get("ID", "")
|
||||
image_ids.append(image_id)
|
||||
|
||||
# Parse size (e.g., "150MB" -> bytes)
|
||||
size_str = img.get("Size", "0")
|
||||
size = self._parse_size(size_str)
|
||||
|
||||
# Parse created time
|
||||
created = None
|
||||
created_str = img.get("CreatedAt", "")
|
||||
if created_str:
|
||||
try:
|
||||
created = datetime.fromisoformat(
|
||||
created_str.split(" ")[0] + "T" + created_str.split(" ")[1]
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Get repo tags
|
||||
repo = img.get("Repository", "<none>")
|
||||
tag = img.get("Tag", "<none>")
|
||||
repo_tags = None
|
||||
if repo != "<none>":
|
||||
repo_tags = [f"{repo}:{tag}"]
|
||||
|
||||
await image_repo.upsert(
|
||||
host_id=host_id,
|
||||
image_id=image_id,
|
||||
repo_tags=repo_tags,
|
||||
size=size,
|
||||
created=created
|
||||
)
|
||||
|
||||
await image_repo.delete_stale(host_id, image_ids)
|
||||
result["images_count"] = len(images_data)
|
||||
|
||||
# Process volumes
|
||||
volume_names = []
|
||||
for vol in volumes_data:
|
||||
name = vol.get("Name", "")
|
||||
volume_names.append(name)
|
||||
|
||||
await volume_repo.upsert(
|
||||
host_id=host_id,
|
||||
name=name,
|
||||
driver=vol.get("Driver", "local"),
|
||||
mountpoint=vol.get("Mountpoint", ""),
|
||||
scope=vol.get("Scope", "local")
|
||||
)
|
||||
|
||||
await volume_repo.delete_stale(host_id, volume_names)
|
||||
result["volumes_count"] = len(volumes_data)
|
||||
|
||||
# Update host Docker status
|
||||
await host_repo.update(
|
||||
host,
|
||||
docker_version=docker_version,
|
||||
docker_status="online",
|
||||
docker_last_collect_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
result["success"] = True
|
||||
result["message"] = "Collection completed"
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
print(f"{LOG_PREFIX} {host.name}: Collection completed in {duration_ms}ms - {len(containers_data)} containers, {len(images_data)} images, {len(volumes_data)} volumes")
|
||||
|
||||
except SSHConnectionError as e:
|
||||
result["error"] = str(e)
|
||||
result["message"] = "Collection failed"
|
||||
# Update host status to offline
|
||||
await host_repo.update(host, docker_status="offline")
|
||||
await session.commit()
|
||||
print(f"{LOG_PREFIX} {result.get('host_name', host_id)}: SSH connection error - {e}")
|
||||
logger.warning(f"{LOG_PREFIX} SSH error collecting Docker from {host_id}: {e}")
|
||||
|
||||
except DockerNotAvailableError as e:
|
||||
result["error"] = str(e)
|
||||
result["message"] = "Collection failed"
|
||||
await host_repo.update(host, docker_status="error")
|
||||
await session.commit()
|
||||
print(f"{LOG_PREFIX} {result.get('host_name', host_id)}: Docker not available - {e}")
|
||||
logger.warning(f"{LOG_PREFIX} Docker not available on {host_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
result["message"] = "Collection failed"
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
print(f"{LOG_PREFIX} {result.get('host_name', host_id)}: Collection error - {e}")
|
||||
logger.error(f"{LOG_PREFIX} Error collecting Docker from {host_id}: {e}\n{tb}")
|
||||
await session.rollback()
|
||||
|
||||
result["duration_ms"] = int((time.time() - start_time) * 1000)
|
||||
if not result.get("message"):
|
||||
result["message"] = "Collection completed" if result.get("success") else "Collection failed"
|
||||
return result
|
||||
|
||||
async def collect_all_hosts(self) -> Dict[str, Any]:
|
||||
"""Collect Docker information from all enabled hosts.
|
||||
|
||||
Returns:
|
||||
Summary of collection results
|
||||
"""
|
||||
results = {
|
||||
"success": True,
|
||||
"total_hosts": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"results": []
|
||||
}
|
||||
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
hosts = await host_repo.list_docker_enabled()
|
||||
results["total_hosts"] = len(hosts)
|
||||
|
||||
# Collect from all hosts concurrently (with limit)
|
||||
semaphore = asyncio.Semaphore(5) # Max 5 concurrent collections
|
||||
|
||||
async def collect_with_semaphore(host):
|
||||
async with semaphore:
|
||||
return await self.collect_docker_host(host.id)
|
||||
|
||||
if hosts:
|
||||
host_results = await asyncio.gather(
|
||||
*[collect_with_semaphore(host) for host in hosts],
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
for r in host_results:
|
||||
if isinstance(r, Exception):
|
||||
results["failed"] += 1
|
||||
results["results"].append({
|
||||
"success": False,
|
||||
"host_id": "",
|
||||
"host_name": "",
|
||||
"message": "Collection failed",
|
||||
"docker_version": None,
|
||||
"containers_count": 0,
|
||||
"images_count": 0,
|
||||
"volumes_count": 0,
|
||||
"duration_ms": 0,
|
||||
"error": str(r)
|
||||
})
|
||||
elif r.get("success"):
|
||||
results["successful"] += 1
|
||||
results["results"].append(r)
|
||||
else:
|
||||
results["failed"] += 1
|
||||
results["results"].append(r)
|
||||
|
||||
results["success"] = results["failed"] == 0
|
||||
return results
|
||||
|
||||
def _parse_size(self, size_str: str) -> int:
|
||||
"""Parse Docker size string to bytes."""
|
||||
if not size_str:
|
||||
return 0
|
||||
|
||||
size_str = size_str.upper().strip()
|
||||
multipliers = {
|
||||
"TB": 1024 ** 4,
|
||||
"GB": 1024 ** 3,
|
||||
"MB": 1024 ** 2,
|
||||
"KB": 1024,
|
||||
"B": 1,
|
||||
}
|
||||
|
||||
# Important: match longer suffixes first, otherwise 'B' would match 'MB'
|
||||
for suffix in ("TB", "GB", "MB", "KB", "B"):
|
||||
mult = multipliers[suffix]
|
||||
if size_str.endswith(suffix):
|
||||
try:
|
||||
num = float(size_str[:-len(suffix)])
|
||||
return int(num * mult)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
try:
|
||||
return int(size_str)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
async def get_docker_hosts(self) -> List[Dict[str, Any]]:
|
||||
"""Get all hosts with Docker info."""
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
container_repo = DockerContainerRepository(session)
|
||||
image_repo = DockerImageRepository(session)
|
||||
volume_repo = DockerVolumeRepository(session)
|
||||
alert_repo = DockerAlertRepository(session)
|
||||
|
||||
# Best-effort: read inventory and only show hosts in role_docker group.
|
||||
# Visibility is controlled by inventory membership; docker_enabled remains a user toggle.
|
||||
docker_host_names: Optional[set[str]] = None
|
||||
try:
|
||||
inv_hosts = ansible_service.get_hosts_from_inventory()
|
||||
docker_host_names = {
|
||||
h.name
|
||||
for h in inv_hosts
|
||||
if "role_docker" in (getattr(h, "groups", None) or [])
|
||||
}
|
||||
except Exception:
|
||||
# Inventory read is best-effort; do not break Docker UI
|
||||
docker_host_names = None
|
||||
|
||||
# Return all active hosts so the UI can show both enabled and disabled
|
||||
# Docker monitoring states (pause/play).
|
||||
hosts = await host_repo.list(limit=1000)
|
||||
hosts = [h for h in hosts if not h.deleted_at]
|
||||
if docker_host_names is not None:
|
||||
hosts = [h for h in hosts if h.name in docker_host_names]
|
||||
hosts = sorted(hosts, key=lambda h: (h.name or ""))
|
||||
result = []
|
||||
|
||||
for host in hosts:
|
||||
if host.deleted_at:
|
||||
continue
|
||||
|
||||
containers = await container_repo.count_by_host(host.id)
|
||||
images = await image_repo.count_by_host(host.id)
|
||||
volumes = await volume_repo.count_by_host(host.id)
|
||||
open_alerts = await alert_repo.count_open_by_host(host.id)
|
||||
|
||||
result.append({
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
"host_ip": host.ip_address,
|
||||
"docker_enabled": host.docker_enabled,
|
||||
"docker_version": host.docker_version,
|
||||
"docker_status": host.docker_status,
|
||||
"docker_last_collect_at": host.docker_last_collect_at,
|
||||
"containers_total": containers.get("total", 0),
|
||||
"containers_running": containers.get("running", 0),
|
||||
"images_total": images.get("total", 0),
|
||||
"volumes_total": volumes,
|
||||
"open_alerts": open_alerts
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
async def enable_docker_monitoring(self, host_id: str, enabled: bool = True) -> bool:
|
||||
"""Enable or disable Docker monitoring on a host."""
|
||||
async with async_session_maker() as session:
|
||||
host_repo = HostRepository(session)
|
||||
host = await host_repo.get(host_id)
|
||||
|
||||
if not host:
|
||||
return False
|
||||
|
||||
await host_repo.update(host, docker_enabled=enabled)
|
||||
await session.commit()
|
||||
|
||||
# If enabling, trigger an immediate collection
|
||||
if enabled:
|
||||
asyncio.create_task(self.collect_docker_host(host_id))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
docker_service = DockerService()
|
||||
@@ -23,6 +23,9 @@ from app.schemas.schedule_api import (
|
||||
ScheduleUpdateRequest,
|
||||
ScheduleStats,
|
||||
)
|
||||
from app.schemas.task_api import Task
|
||||
from app.services.task_log_service import TaskLogService
|
||||
from app.services.websocket_service import ws_manager
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
@@ -33,6 +36,7 @@ class SchedulerService:
|
||||
self._schedules_cache: Dict[str, Schedule] = {}
|
||||
self._timezone = pytz.timezone(settings.scheduler_timezone)
|
||||
self._started = False
|
||||
self._task_log_service = TaskLogService(settings.tasks_logs_dir)
|
||||
|
||||
@property
|
||||
def scheduler(self) -> AsyncIOScheduler:
|
||||
@@ -56,14 +60,14 @@ class SchedulerService:
|
||||
await self._load_active_schedules_from_db()
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
print(f"⏰ Scheduler démarré avec {len(self._schedules_cache)} schedule(s)")
|
||||
print(f" Scheduler démarré avec {len(self._schedules_cache)} schedule(s)")
|
||||
|
||||
def shutdown(self):
|
||||
"""Arrête le scheduler proprement."""
|
||||
if self._scheduler and self._started:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._started = False
|
||||
print("⏰ Scheduler arrêté")
|
||||
print(" Scheduler arrêté")
|
||||
|
||||
async def _load_active_schedules_from_db(self):
|
||||
"""Charge les schedules actifs depuis la base de données."""
|
||||
@@ -201,10 +205,50 @@ class SchedulerService:
|
||||
status="running"
|
||||
)
|
||||
|
||||
task_id: Optional[str] = None
|
||||
|
||||
task_name = f"[Planifié] {schedule.name}"
|
||||
target = schedule.target
|
||||
|
||||
# Créer une Task (pour aligner l'affichage UI sur /api/tasks/logs)
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.task import TaskRepository
|
||||
|
||||
task_id = uuid.uuid4().hex
|
||||
run.task_id = task_id
|
||||
|
||||
repo = TaskRepository(session)
|
||||
await repo.create(
|
||||
id=task_id,
|
||||
action=task_name,
|
||||
target=target,
|
||||
playbook=schedule.playbook,
|
||||
status="running",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_created",
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"name": task_name,
|
||||
"host": target,
|
||||
"status": "running",
|
||||
"progress": 0,
|
||||
"start_time": start_time,
|
||||
"end_time": None,
|
||||
"duration": None,
|
||||
"output": None,
|
||||
"error": None,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erreur création Task pour schedule {schedule_id}: {e}")
|
||||
|
||||
try:
|
||||
# Importer les services nécessaires
|
||||
from app.services.ansible_service import ansible_service
|
||||
from app.services.websocket_service import ws_manager
|
||||
from app.services.notification_service import notification_service
|
||||
|
||||
# Mettre à jour le statut
|
||||
@@ -254,6 +298,69 @@ class SchedulerService:
|
||||
await self._persist_run(run)
|
||||
await self._update_schedule_stats_in_db(schedule)
|
||||
|
||||
# Mettre à jour la Task + générer le log markdown
|
||||
try:
|
||||
if task_id:
|
||||
status = "completed" if result.get("success") else "failed"
|
||||
|
||||
mem_task = Task(
|
||||
id=task_id,
|
||||
name=task_name,
|
||||
host=target,
|
||||
status=status,
|
||||
progress=100,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
duration=f"{duration:.1f}s",
|
||||
output=result.get("stdout", ""),
|
||||
error=result.get("stderr", "") if not result.get("success") else None,
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.task import TaskRepository
|
||||
|
||||
repo = TaskRepository(session)
|
||||
db_task = await repo.get(task_id)
|
||||
if db_task:
|
||||
await repo.update(
|
||||
db_task,
|
||||
status=status,
|
||||
completed_at=end_time,
|
||||
error_message=mem_task.error,
|
||||
result_data={"output": (result.get("stdout", "") or "")[:5000]},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
log_path = self._task_log_service.save_task_log(
|
||||
task=mem_task,
|
||||
output=result.get("stdout", ""),
|
||||
error=result.get("stderr", ""),
|
||||
source_type="scheduled",
|
||||
)
|
||||
created_log = self._task_log_service.index_log_file(log_path)
|
||||
if created_log:
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_log_created",
|
||||
"data": created_log.dict()
|
||||
})
|
||||
except Exception as log_error:
|
||||
print(f"Erreur sauvegarde log markdown (schedule): {log_error}")
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_completed" if status == "completed" else "task_failed",
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"status": status,
|
||||
"progress": 100,
|
||||
"duration": f"{duration:.1f}s",
|
||||
"success": result.get("success", False),
|
||||
"error": mem_task.error,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erreur finalisation Task pour schedule {schedule_id}: {e}")
|
||||
|
||||
# Mettre à jour next_run
|
||||
job = self.scheduler.get_job(f"schedule_{schedule_id}")
|
||||
if job and job.next_run_time:
|
||||
@@ -304,6 +411,62 @@ class SchedulerService:
|
||||
await self._persist_run(run)
|
||||
await self._update_schedule_stats_in_db(schedule)
|
||||
|
||||
# Mettre à jour la Task + générer le log markdown (échec)
|
||||
try:
|
||||
if task_id:
|
||||
mem_task = Task(
|
||||
id=task_id,
|
||||
name=task_name,
|
||||
host=target,
|
||||
status="failed",
|
||||
progress=100,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
duration=f"{duration:.1f}s",
|
||||
output="",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.task import TaskRepository
|
||||
|
||||
repo = TaskRepository(session)
|
||||
db_task = await repo.get(task_id)
|
||||
if db_task:
|
||||
await repo.update(
|
||||
db_task,
|
||||
status="failed",
|
||||
completed_at=end_time,
|
||||
error_message=str(e),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
log_path = self._task_log_service.save_task_log(
|
||||
task=mem_task,
|
||||
error=str(e),
|
||||
source_type="scheduled",
|
||||
)
|
||||
created_log = self._task_log_service.index_log_file(log_path)
|
||||
if created_log:
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_log_created",
|
||||
"data": created_log.dict()
|
||||
})
|
||||
except Exception as log_error:
|
||||
print(f"Erreur sauvegarde log markdown (schedule fail): {log_error}")
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "task_failed",
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
}
|
||||
})
|
||||
except Exception as inner_e:
|
||||
print(f"Erreur finalisation Task (exception) pour schedule {schedule_id}: {inner_e}")
|
||||
|
||||
print(f"Erreur exécution schedule {schedule_id}: {e}")
|
||||
|
||||
async def _persist_run(self, run: ScheduleRun):
|
||||
|
||||
Reference in New Issue
Block a user