feat: première version fonctionnelle

This commit is contained in:
2025-08-27 06:30:16 +02:00
commit cf8a37f183
39 changed files with 2730 additions and 0 deletions

17
backend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
wakeonlan \
iputils-ping \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -0,0 +1 @@
# API package

166
backend/app/api/proxmox.py Normal file
View File

@@ -0,0 +1,166 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db, ProxmoxCluster
from app.models.schemas import ProxmoxCluster as ProxmoxClusterSchema, ProxmoxClusterCreate, ProxmoxVM
from app.services.proxmox_service import ProxmoxService
from app.services.logging_service import LoggingService
router = APIRouter()
@router.get("/clusters", response_model=List[ProxmoxClusterSchema])
async def get_clusters(db: Session = Depends(get_db)):
clusters = db.query(ProxmoxCluster).all()
return clusters
@router.post("/clusters", response_model=ProxmoxClusterSchema)
async def create_cluster(cluster: ProxmoxClusterCreate, db: Session = Depends(get_db)):
try:
proxmox_service = ProxmoxService(
host=cluster.host,
user=cluster.username,
password=cluster.password,
port=cluster.port,
verify_ssl=cluster.verify_ssl
)
if not await proxmox_service.test_connection():
raise HTTPException(status_code=400, detail="Cannot connect to Proxmox cluster")
db_cluster = ProxmoxCluster(**cluster.dict())
db.add(db_cluster)
db.commit()
db.refresh(db_cluster)
# Log cluster creation
LoggingService.log_proxmox_cluster_action(
db=db,
action="create",
cluster_id=db_cluster.id,
cluster_name=db_cluster.name,
success=True,
message=f"Proxmox cluster '{db_cluster.name}' created successfully"
)
return db_cluster
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error creating cluster: {str(e)}")
@router.get("/clusters/{cluster_id}", response_model=ProxmoxClusterSchema)
async def get_cluster(cluster_id: int, db: Session = Depends(get_db)):
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
return cluster
@router.delete("/clusters/{cluster_id}")
async def delete_cluster(cluster_id: int, db: Session = Depends(get_db)):
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
# Log cluster deletion before deleting
LoggingService.log_proxmox_cluster_action(
db=db,
action="delete",
cluster_id=cluster.id,
cluster_name=cluster.name,
success=True,
message=f"Proxmox cluster '{cluster.name}' deleted successfully"
)
db.delete(cluster)
db.commit()
return {"message": "Cluster deleted successfully"}
@router.get("/clusters/{cluster_id}/vms", response_model=List[ProxmoxVM])
async def get_cluster_vms(cluster_id: int, db: Session = Depends(get_db)):
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
try:
proxmox_service = ProxmoxService(
host=cluster.host,
user=cluster.username,
password=cluster.password,
port=cluster.port,
verify_ssl=cluster.verify_ssl
)
vms = await proxmox_service.get_vms()
return vms
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error getting VMs: {str(e)}")
@router.post("/clusters/{cluster_id}/vms/{vmid}/start")
async def start_vm(cluster_id: int, vmid: str, node: str, vm_type: str = "qemu", db: Session = Depends(get_db)):
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
proxmox_service = ProxmoxService(
host=cluster.host,
user=cluster.username,
password=cluster.password,
port=cluster.port,
verify_ssl=cluster.verify_ssl
)
success = await proxmox_service.start_vm(node, vmid, vm_type)
# Get VM name from the cluster's VMs
vms = await proxmox_service.get_vms()
vm_name = next((vm.name for vm in vms if vm.vmid == vmid), f"VM-{vmid}")
# Log VM start action
LoggingService.log_proxmox_vm_action(
db=db,
action="start",
vmid=vmid,
vm_name=vm_name,
node=node,
success=success,
message=f"VM {vm_name} ({'started' if success else 'failed to start'}) on node {node}"
)
if not success:
raise HTTPException(status_code=500, detail="Failed to start VM")
return {"message": f"VM {vmid} start command sent", "success": True}
@router.post("/clusters/{cluster_id}/vms/{vmid}/stop")
async def stop_vm(cluster_id: int, vmid: str, node: str, vm_type: str = "qemu", db: Session = Depends(get_db)):
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
proxmox_service = ProxmoxService(
host=cluster.host,
user=cluster.username,
password=cluster.password,
port=cluster.port,
verify_ssl=cluster.verify_ssl
)
success = await proxmox_service.stop_vm(node, vmid, vm_type)
# Get VM name from the cluster's VMs
vms = await proxmox_service.get_vms()
vm_name = next((vm.name for vm in vms if vm.vmid == vmid), f"VM-{vmid}")
# Log VM stop action
LoggingService.log_proxmox_vm_action(
db=db,
action="stop",
vmid=vmid,
vm_name=vm_name,
node=node,
success=success,
message=f"VM {vm_name} ({'stopped' if success else 'failed to stop'}) on node {node}"
)
if not success:
raise HTTPException(status_code=500, detail="Failed to stop VM")
return {"message": f"VM {vmid} stop command sent", "success": True}

View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db, Server
from app.models.schemas import Server as ServerSchema, ServerCreate
from app.services.wol_service import WolService
from app.services.logging_service import LoggingService
router = APIRouter()
@router.get("/", response_model=List[ServerSchema])
async def get_servers(db: Session = Depends(get_db)):
servers = db.query(Server).all()
return servers
@router.post("/", response_model=ServerSchema)
async def create_server(server: ServerCreate, db: Session = Depends(get_db)):
db_server = Server(**server.dict())
db.add(db_server)
db.commit()
db.refresh(db_server)
# Log server creation
LoggingService.log_server_action(
db=db,
action="create",
server_id=db_server.id,
server_name=db_server.name,
success=True,
message=f"Server '{db_server.name}' created successfully"
)
return db_server
@router.get("/{server_id}", response_model=ServerSchema)
async def get_server(server_id: int, db: Session = Depends(get_db)):
server = db.query(Server).filter(Server.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
return server
@router.put("/{server_id}", response_model=ServerSchema)
async def update_server(server_id: int, server_update: ServerCreate, db: Session = Depends(get_db)):
server = db.query(Server).filter(Server.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
old_name = server.name
for key, value in server_update.dict().items():
setattr(server, key, value)
db.commit()
db.refresh(server)
# Log server update
LoggingService.log_server_action(
db=db,
action="update",
server_id=server.id,
server_name=server.name,
success=True,
message=f"Server '{old_name}' updated successfully"
)
return server
@router.delete("/{server_id}")
async def delete_server(server_id: int, db: Session = Depends(get_db)):
server = db.query(Server).filter(Server.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
# Log server deletion before deleting
LoggingService.log_server_action(
db=db,
action="delete",
server_id=server.id,
server_name=server.name,
success=True,
message=f"Server '{server.name}' deleted successfully"
)
db.delete(server)
db.commit()
return {"message": "Server deleted successfully"}
@router.post("/check-status")
async def check_all_servers_status(db: Session = Depends(get_db)):
await WolService.check_all_servers_status(db)
return {"message": "Server status checked for all servers"}

50
backend/app/api/wol.py Normal file
View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db, WolLog, ActionLog
from app.models.schemas import WolLog as WolLogSchema, ActionLog as ActionLogSchema
from app.services.wol_service import WolService
router = APIRouter()
@router.post("/wake/{server_id}")
async def wake_server(server_id: int, db: Session = Depends(get_db)):
success = await WolService.wake_server(db, server_id)
if not success:
raise HTTPException(status_code=404, detail="Server not found or WOL failed")
return {"message": f"WOL packet sent to server {server_id}", "success": True}
@router.post("/ping/{server_id}")
async def ping_server(server_id: int, db: Session = Depends(get_db)):
from app.database import Server
server = db.query(Server).filter(Server.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
is_online = await WolService.ping_server(server.ip_address)
server.is_online = is_online
db.commit()
return {"server_id": server_id, "is_online": is_online}
@router.get("/logs", response_model=List[WolLogSchema])
async def get_wol_logs(db: Session = Depends(get_db), limit: int = 50):
logs = db.query(WolLog).order_by(WolLog.timestamp.desc()).limit(limit).all()
return logs
@router.get("/logs/{server_id}", response_model=List[WolLogSchema])
async def get_server_wol_logs(server_id: int, db: Session = Depends(get_db), limit: int = 20):
logs = db.query(WolLog).filter(WolLog.server_id == server_id).order_by(WolLog.timestamp.desc()).limit(limit).all()
return logs
@router.get("/all-logs", response_model=List[ActionLogSchema])
async def get_all_action_logs(db: Session = Depends(get_db), limit: int = 100):
"""Get all action logs (WOL, Proxmox, Server actions)"""
logs = db.query(ActionLog).order_by(ActionLog.timestamp.desc()).limit(limit).all()
return logs
@router.get("/all-logs/{action_type}", response_model=List[ActionLogSchema])
async def get_action_logs_by_type(action_type: str, db: Session = Depends(get_db), limit: int = 50):
"""Get action logs filtered by type (wol, proxmox, server)"""
logs = db.query(ActionLog).filter(ActionLog.action_type == action_type).order_by(ActionLog.timestamp.desc()).limit(limit).all()
return logs

69
backend/app/database.py Normal file
View File

@@ -0,0 +1,69 @@
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime
import os
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./zebra.db")
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Server(Base):
__tablename__ = "servers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
ip_address = Column(String, nullable=False)
mac_address = Column(String, nullable=False)
description = Column(String)
is_online = Column(Boolean, default=False)
last_ping = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
class ProxmoxCluster(Base):
__tablename__ = "proxmox_clusters"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
host = Column(String, nullable=False)
username = Column(String, nullable=False)
password = Column(String, nullable=False)
port = Column(Integer, default=8006)
verify_ssl = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
class ActionLog(Base):
__tablename__ = "action_logs"
id = Column(Integer, primary_key=True, index=True)
action_type = Column(String, nullable=False) # 'wol', 'proxmox', 'server'
target_id = Column(Integer, nullable=True) # server_id, cluster_id, vm_id, etc.
target_name = Column(String, nullable=True) # server name, vm name, etc.
action = Column(String, nullable=False) # 'wake', 'start', 'stop', 'create', 'delete', etc.
timestamp = Column(DateTime, default=datetime.utcnow)
success = Column(Boolean, default=True)
message = Column(String)
details = Column(String, nullable=True) # JSON string for additional data
# Keep WolLog for backward compatibility
class WolLog(Base):
__tablename__ = "wol_logs"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False)
action = Column(String, nullable=False)
timestamp = Column(DateTime, default=datetime.utcnow)
success = Column(Boolean, default=True)
message = Column(String)
def init_db():
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

44
backend/app/main.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.database import init_db
from app.api import servers, proxmox, wol
import traceback
app = FastAPI(title="Zebra Power", description="Wake-on-LAN and Proxmox Management API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": f"Internal server error: {str(exc)}"},
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
}
)
app.include_router(servers.router, prefix="/api/servers", tags=["servers"])
app.include_router(proxmox.router, prefix="/api/proxmox", tags=["proxmox"])
app.include_router(wol.router, prefix="/api/wol", tags=["wol"])
@app.on_event("startup")
async def startup_event():
init_db()
@app.get("/")
async def root():
return {"message": "Zebra Power API is running"}
@app.get("/api/health")
async def health_check():
return {"status": "healthy"}

View File

@@ -0,0 +1 @@
# Models package

View File

@@ -0,0 +1,75 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class ServerBase(BaseModel):
name: str
ip_address: str
mac_address: str
description: Optional[str] = None
class ServerCreate(ServerBase):
pass
class Server(ServerBase):
id: int
is_online: bool
last_ping: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class ProxmoxClusterBase(BaseModel):
name: str
host: str
username: str
password: str
port: int = 8006
verify_ssl: bool = True
class ProxmoxClusterCreate(ProxmoxClusterBase):
pass
class ProxmoxCluster(ProxmoxClusterBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ProxmoxVM(BaseModel):
vmid: str
name: str
status: str
node: str
type: str
class WolLogCreate(BaseModel):
server_id: int
action: str
success: bool = True
message: Optional[str] = None
class WolLog(WolLogCreate):
id: int
timestamp: datetime
class Config:
from_attributes = True
class ActionLogCreate(BaseModel):
action_type: str
target_id: Optional[int] = None
target_name: Optional[str] = None
action: str
success: bool = True
message: Optional[str] = None
details: Optional[str] = None
class ActionLog(ActionLogCreate):
id: int
timestamp: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1 @@
# Services package

View File

@@ -0,0 +1,82 @@
from sqlalchemy.orm import Session
from app.database import ActionLog
from typing import Optional
import json
class LoggingService:
@staticmethod
def log_action(
db: Session,
action_type: str,
action: str,
success: bool = True,
message: Optional[str] = None,
target_id: Optional[int] = None,
target_name: Optional[str] = None,
details: Optional[dict] = None
):
"""Log an action to the unified action log"""
log_entry = ActionLog(
action_type=action_type,
target_id=target_id,
target_name=target_name,
action=action,
success=success,
message=message,
details=json.dumps(details) if details else None
)
db.add(log_entry)
db.commit()
@staticmethod
def log_wol_action(db: Session, server_id: int, server_name: str, success: bool, message: str):
"""Log WOL action"""
LoggingService.log_action(
db=db,
action_type="wol",
action="wake",
success=success,
message=message,
target_id=server_id,
target_name=server_name
)
@staticmethod
def log_proxmox_vm_action(db: Session, action: str, vmid: str, vm_name: str, node: str, success: bool, message: str):
"""Log Proxmox VM actions (start/stop)"""
LoggingService.log_action(
db=db,
action_type="proxmox",
action=action,
success=success,
message=message,
target_id=int(vmid) if vmid.isdigit() else None,
target_name=vm_name,
details={"node": node, "vmid": vmid}
)
@staticmethod
def log_proxmox_cluster_action(db: Session, action: str, cluster_id: int, cluster_name: str, success: bool, message: str):
"""Log Proxmox cluster actions (create/delete)"""
LoggingService.log_action(
db=db,
action_type="proxmox",
action=action,
success=success,
message=message,
target_id=cluster_id,
target_name=cluster_name
)
@staticmethod
def log_server_action(db: Session, action: str, server_id: int, server_name: str, success: bool, message: str):
"""Log server actions (create/update/delete)"""
LoggingService.log_action(
db=db,
action_type="server",
action=action,
success=success,
message=message,
target_id=server_id,
target_name=server_name
)

View File

@@ -0,0 +1,114 @@
from proxmoxer import ProxmoxAPI
from typing import List, Optional
from app.models.schemas import ProxmoxVM
import logging
logger = logging.getLogger(__name__)
class ProxmoxService:
def __init__(self, host: str, user: str, password: str, port: int = 8006, verify_ssl: bool = True):
self.host = host
self.user = user
self.password = password
self.port = port
self.verify_ssl = verify_ssl
self._proxmox = None
def _get_connection(self):
if not self._proxmox:
try:
self._proxmox = ProxmoxAPI(
self.host,
user=self.user,
password=self.password,
port=self.port,
verify_ssl=self.verify_ssl,
timeout=10
)
except Exception as e:
logger.error(f"Failed to connect to Proxmox {self.host}: {str(e)}")
raise ConnectionError(f"Cannot connect to Proxmox: {str(e)}")
return self._proxmox
async def test_connection(self) -> bool:
try:
proxmox = self._get_connection()
proxmox.version.get()
return True
except Exception as e:
logger.error(f"Proxmox connection test failed: {str(e)}")
return False
async def get_nodes(self) -> List[dict]:
try:
proxmox = self._get_connection()
return proxmox.nodes.get()
except Exception as e:
logger.error(f"Failed to get nodes: {str(e)}")
return []
async def get_vms(self, node: Optional[str] = None) -> List[ProxmoxVM]:
try:
proxmox = self._get_connection()
vms = []
if node:
nodes = [node]
else:
nodes_list = await self.get_nodes()
nodes = [n['node'] for n in nodes_list]
for node_name in nodes:
try:
qemu_vms = proxmox.nodes(node_name).qemu.get()
for vm in qemu_vms:
if vm.get('template', 0) != 1:
vms.append(ProxmoxVM(
vmid=str(vm['vmid']),
name=vm.get('name', f"VM-{vm['vmid']}"),
status=vm.get('status', 'unknown'),
node=node_name,
type='qemu'
))
lxc_containers = proxmox.nodes(node_name).lxc.get()
for container in lxc_containers:
vms.append(ProxmoxVM(
vmid=str(container['vmid']),
name=container.get('name', f"CT-{container['vmid']}"),
status=container.get('status', 'unknown'),
node=node_name,
type='lxc'
))
except Exception as e:
logger.error(f"Failed to get VMs from node {node_name}: {str(e)}")
continue
return vms
except Exception as e:
logger.error(f"Failed to get VMs: {str(e)}")
return []
async def start_vm(self, node: str, vmid: str, vm_type: str = 'qemu') -> bool:
try:
proxmox = self._get_connection()
if vm_type == 'lxc':
proxmox.nodes(node).lxc(vmid).status.start.post()
else:
proxmox.nodes(node).qemu(vmid).status.start.post()
return True
except Exception as e:
logger.error(f"Failed to start VM {vmid}: {str(e)}")
return False
async def stop_vm(self, node: str, vmid: str, vm_type: str = 'qemu') -> bool:
try:
proxmox = self._get_connection()
if vm_type == 'lxc':
proxmox.nodes(node).lxc(vmid).status.shutdown.post()
else:
proxmox.nodes(node).qemu(vmid).status.shutdown.post()
return True
except Exception as e:
logger.error(f"Failed to stop VM {vmid}: {str(e)}")
return False

View File

@@ -0,0 +1,89 @@
import asyncio
import subprocess
from typing import List
from sqlalchemy.orm import Session
from app.database import Server, WolLog
from app.models.schemas import WolLogCreate
from app.services.logging_service import LoggingService
import logging
logger = logging.getLogger(__name__)
class WolService:
@staticmethod
async def send_wol_packet(mac_address: str) -> bool:
try:
result = subprocess.run(
["wakeonlan", mac_address],
capture_output=True,
text=True,
timeout=10
)
return result.returncode == 0
except subprocess.TimeoutExpired:
logger.error(f"WOL timeout for MAC: {mac_address}")
return False
except Exception as e:
logger.error(f"WOL error for MAC {mac_address}: {str(e)}")
return False
@staticmethod
async def ping_server(ip_address: str) -> bool:
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "3", ip_address],
capture_output=True,
text=True,
timeout=5
)
return result.returncode == 0
except subprocess.TimeoutExpired:
return False
except Exception as e:
logger.error(f"Ping error for IP {ip_address}: {str(e)}")
return False
@staticmethod
async def wake_server(db: Session, server_id: int) -> bool:
server = db.query(Server).filter(Server.id == server_id).first()
if not server:
return False
success = await WolService.send_wol_packet(server.mac_address)
# Log to both WolLog (backward compatibility) and ActionLog (new unified system)
log_entry = WolLog(
server_id=server_id,
action="wake",
success=success,
message=f"WOL packet sent to {server.mac_address}" if success else "Failed to send WOL packet"
)
db.add(log_entry)
# Log to unified action log
LoggingService.log_wol_action(
db=db,
server_id=server_id,
server_name=server.name,
success=success,
message=f"WOL packet {'sent successfully' if success else 'failed'} to {server.name} ({server.mac_address})"
)
return success
@staticmethod
async def check_all_servers_status(db: Session) -> None:
servers = db.query(Server).all()
tasks = []
for server in servers:
tasks.append(WolService.ping_server(server.ip_address))
results = await asyncio.gather(*tasks)
from datetime import datetime
for server, is_online in zip(servers, results):
server.is_online = is_online
server.last_ping = datetime.utcnow()
db.commit()

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.0
python-multipart==0.0.6
proxmoxer==2.0.1
requests==2.31.0
wakeonlan==3.1.0
httpx==0.25.2