Merge pull request #6 from markmorado/claude/code-review-audit-cxCWB

Claude/code review audit cx cwb
This commit is contained in:
Dilshod Rasulov
2026-03-07 22:39:45 +05:00
committed by GitHub
8 changed files with 2218 additions and 2869 deletions

View File

@@ -30,7 +30,7 @@ templates = Jinja2Templates(directory="app/templates")
# ==================== HTML Pages ====================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def ipam_dashboard(request: Request):
def ipam_dashboard(request: Request):
"""IPAM Dashboard page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
@@ -44,7 +44,7 @@ async def ipam_dashboard(request: Request):
@router.get("/networks", response_class=HTMLResponse, include_in_schema=False)
async def ipam_networks_page(request: Request):
def ipam_networks_page(request: Request):
"""IPAM Networks management page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
@@ -58,7 +58,7 @@ async def ipam_networks_page(request: Request):
@router.get("/network/{network_id}", response_class=HTMLResponse, include_in_schema=False)
async def ipam_network_detail(request: Request, network_id: int):
def ipam_network_detail(request: Request, network_id: int):
"""IPAM Network detail page with IP grid"""
from ..i18n import t
lang = request.cookies.get("language", "en")
@@ -73,7 +73,7 @@ async def ipam_network_detail(request: Request, network_id: int):
@router.get("/allocations", response_class=HTMLResponse, include_in_schema=False)
async def ipam_allocations_page(request: Request):
def ipam_allocations_page(request: Request):
"""IPAM Allocations page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
@@ -87,7 +87,7 @@ async def ipam_allocations_page(request: Request):
@router.get("/history", response_class=HTMLResponse, include_in_schema=False)
async def ipam_history_page(request: Request):
def ipam_history_page(request: Request):
"""IPAM History page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
@@ -103,7 +103,7 @@ async def ipam_history_page(request: Request):
# ==================== Network API ====================
@router.get("/api/networks", response_model=List[IPAMNetworkResponse])
async def get_networks(
def get_networks(
is_active: Optional[bool] = None,
proxmox_server_id: Optional[int] = None,
db: Session = Depends(get_db),
@@ -146,7 +146,7 @@ async def get_networks(
@router.get("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
async def get_network(
def get_network(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
@@ -169,7 +169,7 @@ async def get_network(
@router.get("/api/networks/{network_id}/stats", response_model=IPAMNetworkStats)
async def get_network_stats(
def get_network_stats(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
@@ -185,7 +185,7 @@ async def get_network_stats(
@router.get("/api/networks/{network_id}/ip-map")
async def get_network_ip_map(
def get_network_ip_map(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
@@ -203,7 +203,7 @@ async def get_network_ip_map(
@router.post("/api/networks", response_model=IPAMNetworkResponse)
async def create_network(
def create_network(
network_data: IPAMNetworkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
@@ -224,7 +224,7 @@ async def create_network(
@router.put("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
async def update_network(
def update_network(
network_id: int,
network_data: IPAMNetworkUpdate,
db: Session = Depends(get_db),
@@ -247,7 +247,7 @@ async def update_network(
@router.delete("/api/networks/{network_id}")
async def delete_network(
def delete_network(
network_id: int,
force: bool = False,
db: Session = Depends(get_db),
@@ -280,7 +280,7 @@ async def delete_network(
# ==================== Pool API ====================
@router.get("/api/pools", response_model=List[IPAMPoolResponse])
async def get_pools(
def get_pools(
network_id: Optional[int] = None,
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
@@ -314,7 +314,7 @@ async def get_pools(
@router.post("/api/pools", response_model=IPAMPoolResponse)
async def create_pool(
def create_pool(
pool_data: IPAMPoolCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
@@ -345,7 +345,7 @@ async def create_pool(
@router.put("/api/pools/{pool_id}", response_model=IPAMPoolResponse)
async def update_pool(
def update_pool(
pool_id: int,
pool_data: IPAMPoolUpdate,
db: Session = Depends(get_db),
@@ -380,7 +380,7 @@ async def update_pool(
@router.delete("/api/pools/{pool_id}")
async def delete_pool(
def delete_pool(
pool_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
@@ -402,7 +402,7 @@ async def delete_pool(
# ==================== Allocation API ====================
@router.get("/api/allocations", response_model=List[IPAMAllocationResponse])
async def get_allocations(
def get_allocations(
network_id: Optional[int] = None,
pool_id: Optional[int] = None,
status: Optional[str] = None,
@@ -471,7 +471,7 @@ async def get_allocations(
@router.get("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
async def get_allocation(
def get_allocation(
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
@@ -492,7 +492,7 @@ async def get_allocation(
@router.post("/api/allocations", response_model=IPAMAllocationResponse)
async def create_allocation(
def create_allocation(
alloc_data: IPAMAllocationCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
@@ -525,7 +525,7 @@ async def create_allocation(
@router.post("/api/allocations/auto", response_model=IPAMAllocationResponse)
async def auto_allocate_ip(
def auto_allocate_ip(
request: IPAMAutoAllocateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
@@ -550,7 +550,7 @@ async def auto_allocate_ip(
@router.put("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
async def update_allocation(
def update_allocation(
allocation_id: int,
alloc_data: IPAMAllocationUpdate,
db: Session = Depends(get_db),
@@ -575,7 +575,7 @@ async def update_allocation(
@router.delete("/api/allocations/{allocation_id}")
async def delete_allocation(
def delete_allocation(
allocation_id: int,
reason: Optional[str] = None,
db: Session = Depends(get_db),
@@ -600,7 +600,7 @@ async def delete_allocation(
@router.get("/api/allocations/next-available/{network_id}")
async def get_next_available_ip(
def get_next_available_ip(
network_id: int,
pool_id: Optional[int] = None,
db: Session = Depends(get_db),
@@ -619,7 +619,7 @@ async def get_next_available_ip(
# ==================== History API ====================
@router.get("/api/history", response_model=List[IPAMHistoryResponse])
async def get_history(
def get_history(
network_id: Optional[int] = None,
ip_address: Optional[str] = None,
action: Optional[str] = None,
@@ -643,7 +643,7 @@ async def get_history(
@router.get("/api/history/ip/{ip_address}", response_model=List[IPAMHistoryResponse])
async def get_ip_history(
def get_ip_history(
ip_address: str,
limit: int = Query(default=50, le=200),
db: Session = Depends(get_db),
@@ -657,7 +657,7 @@ async def get_ip_history(
# ==================== Utility API ====================
@router.get("/api/conflicts")
async def detect_conflicts(
def detect_conflicts(
network_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
@@ -669,7 +669,7 @@ async def detect_conflicts(
@router.get("/api/summary")
async def get_ipam_summary(
def get_ipam_summary(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
@@ -712,7 +712,7 @@ async def get_ipam_summary(
# ==================== Maintenance API ====================
@router.get("/api/orphans")
async def get_orphan_allocations(
def get_orphan_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
@@ -815,7 +815,7 @@ async def cleanup_orphan_allocations(
@router.get("/api/unlinked")
async def get_unlinked_allocations(
def get_unlinked_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
@@ -860,7 +860,7 @@ async def get_unlinked_allocations(
@router.post("/api/link-allocations")
async def link_allocations_to_vms(
def link_allocations_to_vms(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from .servers import router as servers_router
from .sdn import router as sdn_router
from .snapshots import router as snapshots_router
from .vms import router as vms_router
from .tasks import router as tasks_router
router = APIRouter()
router.include_router(servers_router)
router.include_router(sdn_router)
router.include_router(snapshots_router)
router.include_router(vms_router)
router.include_router(tasks_router)

View File

@@ -0,0 +1,416 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
import ssl
import asyncio
import httpx
import websockets
from ..db import get_db
from ..models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ..schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ..proxmox_client import ProxmoxClient, get_proxmox_resources
from ..auth import get_current_user, PermissionChecker, require_permission, check_permission
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..ipam_service import IPAMService
templates = Jinja2Templates(directory="app/templates")
# ==================== Helper Functions for User Isolation ====================
def check_vm_access(db: Session, current_user: User, server_id: int, vmid: int) -> bool:
"""
Check if user has access to a specific VM instance.
Admins and users with 'vms:view' permission can access all VMs.
Users with 'user' role can only access VMs where they are the owner.
Returns True if access is allowed, False otherwise.
"""
# Admin role has full access
if current_user.role and current_user.role.name == 'admin':
return True
# Check permissions
if current_user.role and current_user.role.permissions:
perms = current_user.role.permissions
# If user has full vms:view, they can access all VMs
has_full_view = perms.get('vms.view', False) or perms.get('vms:view', False)
has_own_only = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
if has_full_view and not has_own_only:
return True
# For limited users, check ownership
if current_user.role and current_user.role.name == 'user':
instance = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if instance and instance.owner_id == current_user.id:
return True
return False
# Default: allow access (for backwards compatibility)
return True
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):
"""
Require user to have access to VM, raise 403 if not.
"""
if not check_vm_access(db, current_user, server_id, vmid):
raise HTTPException(
status_code=403,
detail="You do not have access to this virtual machine"
)
def _get_proxmox_client(server: ProxmoxServer) -> ProxmoxClient:
"""Build a ProxmoxClient from a ProxmoxServer model (password or token auth)."""
if server.use_password:
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
def get_next_vmid(db: Session, server_id: int) -> int:
"""Get next available VMID from Proxmox server (sequential)"""
# Get server from DB
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
# Create Proxmox client and get next VMID from Proxmox API
client = _get_proxmox_client(server)
# Get next VMID from Proxmox (it returns sequential IDs)
vmid = client.get_next_vmid()
if vmid:
return vmid
except Exception as e:
logger.warning(f"Could not get VMID from Proxmox, falling back to DB: {e}")
# Fallback: get from DB if Proxmox not available
used_vmids = set(
row[0] for row in db.query(VMInstance.vmid)
.filter(VMInstance.server_id == server_id, VMInstance.deleted_at == None)
.all()
)
# Find first available VMID starting from 100
for vmid in range(100, 999999):
if vmid not in used_vmids:
return vmid
raise HTTPException(status_code=500, detail="No available VMID")
def archive_and_delete_snapshots(
db: Session,
client: ProxmoxClient,
server_id: int,
server_name: str,
vmid: int,
vm_name: str,
vm_type: str,
node: str,
deleted_by: str,
deletion_reason: str = None
) -> dict:
"""
Archive all snapshots to database before deleting them.
This function:
1. Gets list of all snapshots for the VM/container
2. Saves each snapshot's configuration to vm_snapshot_archives table
3. Explicitly deletes each snapshot one by one
4. Logs each deletion
Args:
db: Database session
client: ProxmoxClient instance
server_id: Proxmox server ID
server_name: Proxmox server name for logging
vmid: VM/container ID
vm_name: VM/container name
vm_type: 'qemu' for VM, 'lxc' for container
node: Proxmox node name
deleted_by: Username who initiated deletion
deletion_reason: Reason for deletion
Returns:
Dict with archived count, deleted count, and any errors
"""
result = {
"archived": 0,
"deleted": 0,
"errors": [],
"snapshots": []
}
try:
# Get all snapshots
if vm_type == 'lxc':
snapshots = client.get_container_snapshots(node, vmid)
else:
snapshots = client.get_vm_snapshots(node, vmid)
# Filter out 'current' state which is not a real snapshot
snapshots = [s for s in snapshots if s.get('name') != 'current']
if not snapshots:
logger.info(f"No snapshots found for {vm_type} {vmid}")
return result
logger.info(f"Found {len(snapshots)} snapshots to archive for {vm_type} {vmid}")
# Archive and delete each snapshot
for snap in snapshots:
snapname = snap.get('name')
if not snapname:
continue
# Create archive record
try:
archive = VMSnapshotArchive(
server_id=server_id,
server_name=server_name,
vmid=vmid,
vm_name=vm_name,
vm_type=vm_type,
node=node,
snapname=snapname,
description=snap.get('description'),
snaptime=snap.get('snaptime'),
parent=snap.get('parent'),
vmstate=bool(snap.get('vmstate')),
snapshot_config=snap,
deleted_by=deleted_by,
deletion_reason=deletion_reason
)
db.add(archive)
db.flush() # Get ID immediately
result["archived"] += 1
result["snapshots"].append(snapname)
logger.info(f"Archived snapshot {snapname} for {vm_type} {vmid}")
except Exception as e:
logger.error(f"Error archiving snapshot {snapname}: {e}")
result["errors"].append(f"Archive {snapname}: {str(e)}")
# Delete snapshot from Proxmox
try:
if vm_type == 'lxc':
delete_result = client.delete_container_snapshot(node, vmid, snapname, force=True)
else:
delete_result = client.delete_vm_snapshot(node, vmid, snapname, force=True)
if delete_result.get('success'):
result["deleted"] += 1
# Log the deletion
LoggingService.log_proxmox_action(
db=db,
action="snapshot_delete",
resource_type=vm_type,
resource_id=vmid,
username=deleted_by,
resource_name=snapname,
server_id=server_id,
server_name=server_name,
node_name=node,
details={
"reason": f"Pre-deletion cleanup for {vm_type} {vmid}",
"cascade_delete": True
},
success=True
)
logger.info(f"Deleted snapshot {snapname} for {vm_type} {vmid}")
else:
error_msg = delete_result.get('error', 'Unknown error')
result["errors"].append(f"Delete {snapname}: {error_msg}")
logger.warning(f"Failed to delete snapshot {snapname}: {error_msg}")
except Exception as e:
logger.error(f"Error deleting snapshot {snapname}: {e}")
result["errors"].append(f"Delete {snapname}: {str(e)}")
# Commit archive records
db.commit()
logger.info(f"Snapshot cleanup for {vm_type} {vmid}: archived={result['archived']}, deleted={result['deleted']}")
except Exception as e:
logger.error(f"Error in archive_and_delete_snapshots for {vm_type} {vmid}: {e}")
result["errors"].append(f"General error: {str(e)}")
return result
def save_vm_instance(
db: Session,
server_id: int,
vmid: int,
node: str,
vm_type: str,
name: str,
cores: int = None,
memory: int = None,
disk_size: int = None,
ip_address: str = None,
ip_prefix: int = 24,
gateway: str = None,
nameserver: str = None,
cloud_init_user: str = None,
cloud_init_password: str = None,
ssh_keys: str = None,
template_id: int = None,
template_name: str = None,
description: str = None,
extra_config: dict = None,
owner_id: int = None
) -> VMInstance:
"""Save or update VM instance configuration"""
# Проверяем, существует ли уже активная запись
existing = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at == None
).first()
if existing:
# Обновляем существующую запись
existing.node = node
existing.vm_type = vm_type
existing.name = name
existing.cores = cores
existing.memory = memory
existing.disk_size = disk_size
existing.ip_address = ip_address
existing.ip_prefix = ip_prefix
existing.gateway = gateway
existing.nameserver = nameserver
existing.cloud_init_user = cloud_init_user
existing.cloud_init_password = cloud_init_password
existing.ssh_keys = ssh_keys
existing.template_id = template_id
existing.template_name = template_name
existing.description = description
existing.extra_config = extra_config
existing.updated_at = func.now()
db.commit()
db.refresh(existing)
return existing
# Проверяем, есть ли удалённая запись с тем же vmid (soft-deleted)
deleted_existing = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at != None
).first()
if deleted_existing:
# Восстанавливаем и обновляем удалённую запись
deleted_existing.node = node
deleted_existing.vm_type = vm_type
deleted_existing.name = name
deleted_existing.cores = cores
deleted_existing.memory = memory
deleted_existing.disk_size = disk_size
deleted_existing.ip_address = ip_address
deleted_existing.ip_prefix = ip_prefix
deleted_existing.gateway = gateway
deleted_existing.nameserver = nameserver
deleted_existing.cloud_init_user = cloud_init_user
deleted_existing.cloud_init_password = cloud_init_password
deleted_existing.ssh_keys = ssh_keys
deleted_existing.template_id = template_id
deleted_existing.template_name = template_name
deleted_existing.description = description
deleted_existing.extra_config = extra_config
deleted_existing.owner_id = owner_id
deleted_existing.status = 'unknown'
deleted_existing.deleted_at = None # Восстанавливаем запись
deleted_existing.updated_at = func.now()
db.commit()
db.refresh(deleted_existing)
return deleted_existing
# Создаем новую запись
instance = VMInstance(
server_id=server_id,
vmid=vmid,
node=node,
vm_type=vm_type,
name=name,
cores=cores,
memory=memory,
disk_size=disk_size,
ip_address=ip_address,
ip_prefix=ip_prefix,
gateway=gateway,
nameserver=nameserver,
cloud_init_user=cloud_init_user,
cloud_init_password=cloud_init_password,
ssh_keys=ssh_keys,
template_id=template_id,
template_name=template_name,
description=description,
extra_config=extra_config,
owner_id=owner_id
)
db.add(instance)
db.commit()
db.refresh(instance)
return instance
def get_vm_instance(db: Session, server_id: int, vmid: int) -> VMInstance:
"""Get VM instance configuration"""
return db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at == None
).first()
def soft_delete_vm_instance(db: Session, server_id: int, vmid: int):
"""Soft delete VM instance (mark as deleted) and release IPAM"""
instance = get_vm_instance(db, server_id, vmid)
if instance:
# Release IPAM allocation
try:
ipam = IPAMService(db)
released = ipam.release_ip_by_vmid(
proxmox_server_id=server_id,
proxmox_vmid=vmid,
released_by="system",
reason="VM/Container soft deleted"
)
if released:
logger.info(f"Released IPAM for soft-deleted instance {vmid} on server {server_id}")
except Exception as e:
logger.warning(f"Failed to release IPAM for soft-deleted instance: {e}")
instance.deleted_at = func.now()
db.commit()

View File

@@ -0,0 +1,326 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
import ssl
import asyncio
import httpx
import websockets
from ..db import get_db
from ..models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ..schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ..proxmox_client import ProxmoxClient, get_proxmox_resources
from ..auth import get_current_user, PermissionChecker, require_permission, check_permission
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
templates)
router = APIRouter()
# ==================== SDN (Software Defined Networking) ====================
@router.get("/api/servers/{server_id}/sdn/status")
def get_sdn_status(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Check if SDN is available on the server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
is_available = client.sdn_is_available()
pending = client.get_sdn_pending() if is_available else []
return JSONResponse(content={
"server_id": server_id,
"sdn_available": is_available,
"pending_changes": len(pending) > 0,
"pending": pending
})
except Exception as e:
logger.error(f"Error checking SDN status for server {server_id}: {e}")
return JSONResponse(content={
"server_id": server_id,
"sdn_available": False,
"error": str(e)
})
@router.get("/api/servers/{server_id}/sdn/zones")
def get_sdn_zones(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Get all SDN zones"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
zones = client.get_sdn_zones()
return JSONResponse(content={"zones": zones})
except Exception as e:
logger.error(f"Error getting SDN zones for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/sdn/zones")
async def create_sdn_zone(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Create a new SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
zone_name = data.get('zone')
zone_type = data.get('type', 'simple')
if not zone_name:
raise HTTPException(status_code=400, detail="Zone name is required")
try:
client = _get_proxmox_client(server)
# Pass additional options from request
kwargs = {k: v for k, v in data.items() if k not in ['zone', 'type']}
result = client.create_sdn_zone(zone_name, zone_type, **kwargs)
if result.get('success'):
logger.info(f"User {current_user.username} created SDN zone: {zone_name}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create zone'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating SDN zone: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/servers/{server_id}/sdn/zones/{zone}")
def delete_sdn_zone(
server_id: int,
zone: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Delete an SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_sdn_zone(zone)
if result.get('success'):
logger.info(f"User {current_user.username} deleted SDN zone: {zone}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete zone'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting SDN zone: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/servers/{server_id}/sdn/vnets")
def get_sdn_vnets(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Get all SDN VNets"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
vnets = client.get_sdn_vnets()
return JSONResponse(content={"vnets": vnets})
except Exception as e:
logger.error(f"Error getting SDN vnets for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/sdn/vnets")
async def create_sdn_vnet(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Create a new SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
vnet_name = data.get('vnet')
zone = data.get('zone')
tag = data.get('tag')
alias = data.get('alias')
vlanaware = data.get('vlanaware', False)
if not vnet_name or not zone:
raise HTTPException(status_code=400, detail="VNet name and zone are required")
try:
client = _get_proxmox_client(server)
result = client.create_sdn_vnet(vnet_name, zone, tag=tag, alias=alias, vlanaware=vlanaware)
if result.get('success'):
logger.info(f"User {current_user.username} created SDN vnet: {vnet_name} in zone {zone}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create vnet'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating SDN vnet: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/servers/{server_id}/sdn/vnets/{vnet}")
def delete_sdn_vnet(
server_id: int,
vnet: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Delete an SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_sdn_vnet(vnet)
if result.get('success'):
logger.info(f"User {current_user.username} deleted SDN vnet: {vnet}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete vnet'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting SDN vnet: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/servers/{server_id}/sdn/vnets/{vnet}/subnets")
def get_sdn_subnets(
server_id: int,
vnet: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Get subnets for a VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
subnets = client.get_sdn_subnets(vnet)
return JSONResponse(content={"subnets": subnets})
except Exception as e:
logger.error(f"Error getting SDN subnets for vnet {vnet}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/sdn/vnets/{vnet}/subnets")
async def create_sdn_subnet(
server_id: int,
vnet: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Create a subnet in a VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
subnet = data.get('subnet')
gateway = data.get('gateway')
snat = data.get('snat', False)
dnszoneprefix = data.get('dnszoneprefix')
if not subnet:
raise HTTPException(status_code=400, detail="Subnet CIDR is required")
try:
client = _get_proxmox_client(server)
result = client.create_sdn_subnet(vnet, subnet, gateway=gateway, snat=snat, dnszoneprefix=dnszoneprefix)
if result.get('success'):
logger.info(f"User {current_user.username} created subnet {subnet} in vnet {vnet}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create subnet'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating SDN subnet: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/sdn/apply")
def apply_sdn_changes(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.manage"))
):
"""Apply pending SDN changes"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.apply_sdn_changes()
if result.get('success'):
logger.info(f"User {current_user.username} applied SDN changes on server {server.name}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to apply SDN changes'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error applying SDN changes: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,774 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
import ssl
import asyncio
import httpx
import websockets
from ..db import get_db
from ..models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ..schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ..proxmox_client import ProxmoxClient, get_proxmox_resources
from ..auth import get_current_user, PermissionChecker, require_permission, check_permission
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
templates)
router = APIRouter()
# ==================== HTML Pages ====================
@router.get("/vms", response_class=HTMLResponse, include_in_schema=False)
def vms_page(request: Request, db: Session = Depends(get_db)):
"""Страница управления Proxmox серверами, VM и LXC"""
from ..i18n import t
lang = request.cookies.get("language", "en")
proxmox_servers = db.query(ProxmoxServer).all()
context = {
"request": request,
"proxmox_servers": proxmox_servers,
"page_title": t('nav_proxmox', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("proxmox_vms.html", context)
@router.get("/server/{server_id}", response_class=HTMLResponse, include_in_schema=False)
def server_detail_page(request: Request, server_id: int, db: Session = Depends(get_db)):
"""Страница детального просмотра VM/LXC конкретного Proxmox сервера"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
context = {
"request": request,
"server": server,
"page_title": server.name,
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("proxmox_server_detail.html", context)
@router.get("/server/{server_id}/instance/{vmid}", response_class=HTMLResponse, include_in_schema=False)
def instance_detail_page(request: Request, server_id: int, vmid: int, type: str = "qemu", node: str = "", db: Session = Depends(get_db)):
"""Страница детального просмотра конкретной VM или LXC"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
context = {
"request": request,
"server": server,
"vmid": vmid,
"type": type,
"node": node,
"page_title": f"VM {vmid}",
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("instance_detail.html", context)
# ==================== Proxmox Server CRUD ====================
@router.get("/api/servers", response_model=List[ProxmoxServerResponse])
def list_proxmox_servers(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Получить список всех Proxmox серверов"""
servers = db.query(ProxmoxServer).all()
return servers
@router.post("/api/servers", response_model=ProxmoxServerResponse, status_code=status.HTTP_201_CREATED)
def create_proxmox_server(
server_data: ProxmoxServerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.servers.add"))
):
"""Добавить новый Proxmox сервер"""
# Разрешаем несколько серверов с одинаковым IP (например, разные порты или кластеры)
# Проверяем только дубликаты имён
existing = db.query(ProxmoxServer).filter(ProxmoxServer.name == server_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Server with name '{server_data.name}' already exists"
)
server = ProxmoxServer(**server_data.model_dump())
db.add(server)
db.commit()
db.refresh(server)
logger.info(f"User {current_user.username} added Proxmox server: {server.name} ({server.ip_address})")
return server
@router.post("/api/servers/auto-setup", status_code=status.HTTP_201_CREATED)
async def auto_setup_proxmox_server(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.servers.add"))
):
"""
Автоматическая настройка Proxmox сервера:
1. Проверяем подключение по логину/паролю
2. Создаем API Token автоматически
3. Сохраняем сервер в базу с токеном и паролем (для VNC)
"""
import requests as http_requests
import uuid
# Получаем данные из body
data = await request.json()
name = data.get('name')
hostname = data.get('hostname')
ip_address = data.get('ip_address')
port = data.get('port', 8006)
api_user = data.get('api_user', 'root@pam')
password = data.get('password')
verify_ssl = data.get('verify_ssl', True)
description = data.get('description')
if not all([name, ip_address, password]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать: name, ip_address, password"
)
# Разрешаем несколько серверов с одинаковым IP
# Проверяем только дубликаты имён
existing = db.query(ProxmoxServer).filter(ProxmoxServer.name == name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Сервер с именем '{name}' уже существует"
)
base_url = f"https://{ip_address}:{port}"
# 1. Получить auth ticket
logger.info(f"Attempting to connect to Proxmox at {base_url}")
try:
auth_response = http_requests.post(
f"{base_url}/api2/json/access/ticket",
data={"username": api_user, "password": password},
verify=verify_ssl,
timeout=15
)
if auth_response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка авторизации в Proxmox: неверный логин или пароль"
)
auth_data = auth_response.json().get("data", {})
ticket = auth_data.get("ticket")
csrf_token = auth_data.get("CSRFPreventionToken")
if not ticket:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Не удалось получить auth ticket от Proxmox"
)
logger.info(f"Successfully authenticated to Proxmox {ip_address}")
except http_requests.exceptions.RequestException as e:
logger.error(f"Connection error to Proxmox: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Не удалось подключиться к Proxmox: {str(e)}"
)
# 2. Создать API Token
# Извлекаем username из api_user (root@pam -> root)
username_part = api_user.split('@')[0] if '@' in api_user else api_user
realm = api_user.split('@')[1] if '@' in api_user else 'pam'
token_name = f"panel-{uuid.uuid4().hex[:8]}"
headers = {
"Cookie": f"PVEAuthCookie={ticket}",
"CSRFPreventionToken": csrf_token
}
try:
# Создаем токен через API
token_response = http_requests.post(
f"{base_url}/api2/json/access/users/{api_user}/token/{token_name}",
headers=headers,
data={"privsep": "0"}, # Полные права как у пользователя
verify=verify_ssl,
timeout=15
)
if token_response.status_code not in [200, 201]:
logger.warning(f"Failed to create token: {token_response.status_code} - {token_response.text}")
# Если не удалось создать токен, работаем только с паролем
token_value = None
token_name = None
logger.info("Will use password authentication only")
else:
token_data = token_response.json().get("data", {})
token_value = token_data.get("value")
logger.info(f"Created API token: {token_name}")
except Exception as e:
logger.warning(f"Error creating token: {e}, will use password auth")
token_value = None
token_name = None
# 3. Получить список нод кластера и их IP адреса
cluster_nodes = []
node_ips = {} # node_name -> ip_address
try:
# Получаем список нод
nodes_response = http_requests.get(
f"{base_url}/api2/json/nodes",
headers=headers,
cookies={"PVEAuthCookie": ticket},
verify=verify_ssl,
timeout=15
)
if nodes_response.status_code == 200:
nodes_data = nodes_response.json().get("data", [])
cluster_nodes = [n for n in nodes_data if n.get("node")]
logger.info(f"Found {len(cluster_nodes)} nodes in cluster")
# Получаем IP адреса нод из cluster/status
if len(cluster_nodes) > 1:
cluster_status_response = http_requests.get(
f"{base_url}/api2/json/cluster/status",
headers=headers,
cookies={"PVEAuthCookie": ticket},
verify=verify_ssl,
timeout=15
)
if cluster_status_response.status_code == 200:
cluster_status = cluster_status_response.json().get("data", [])
for item in cluster_status:
if item.get("type") == "node" and item.get("name") and item.get("ip"):
node_ips[item.get("name")] = item.get("ip")
logger.info(f"Node {item.get('name')} IP: {item.get('ip')}")
except Exception as e:
logger.warning(f"Could not get cluster nodes: {e}")
# 4. Сохранить серверы в базу
created_servers = []
if len(cluster_nodes) > 1:
# Это кластер - добавляем все ноды
for node_info in cluster_nodes:
node_name = node_info.get("node")
# Используем IP ноды из cluster/status, или IP через который подключились
node_ip = node_ips.get(node_name, ip_address)
# Имя сервера: "BaseName - NodeName" или просто NodeName
if name:
server_name = f"{name} - {node_name}"
else:
server_name = node_name
# Проверяем дубликаты
existing = db.query(ProxmoxServer).filter(ProxmoxServer.name == server_name).first()
if existing:
logger.info(f"Server '{server_name}' already exists, skipping")
continue
# Для каждой ноды создаём токен (используем тот же, так как токен кластерный)
server = ProxmoxServer(
name=server_name,
hostname=node_name,
ip_address=node_ip, # Используем реальный IP ноды
port=port,
api_user=api_user,
api_token_name=token_name,
api_token_value=token_value,
use_password=token_value is None,
password=password,
verify_ssl=verify_ssl,
description=f"{description or ''} (Node: {node_name})".strip(),
is_online=node_info.get("status") == "online"
)
db.add(server)
created_servers.append({
"name": server_name,
"node": node_name,
"ip": node_ip,
"status": node_info.get("status", "unknown")
})
db.commit()
logger.info(f"User {current_user.username} auto-setup Proxmox cluster with {len(created_servers)} nodes")
return {
"cluster": True,
"nodes_count": len(created_servers),
"servers": created_servers,
"message": f"Добавлено {len(created_servers)} нод кластера"
}
else:
# Одиночный сервер
server = ProxmoxServer(
name=name,
hostname=hostname or ip_address,
ip_address=ip_address,
port=port,
api_user=api_user,
api_token_name=token_name,
api_token_value=token_value,
use_password=token_value is None,
password=password,
verify_ssl=verify_ssl,
description=description,
is_online=True
)
db.add(server)
db.commit()
db.refresh(server)
logger.info(f"User {current_user.username} auto-setup Proxmox server: {server.name} ({server.ip_address})")
return {
"cluster": False,
"id": server.id,
"name": server.name,
"hostname": server.hostname,
"ip_address": server.ip_address,
"port": server.port,
"api_user": server.api_user,
"api_token_name": token_name or "(пароль)",
"use_password": server.use_password,
"verify_ssl": server.verify_ssl,
"description": server.description,
"is_online": server.is_online
}
@router.get("/api/servers/{server_id}", response_model=ProxmoxServerResponse)
def get_proxmox_server(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Получить информацию о конкретном Proxmox сервере"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
return server
@router.get("/api/servers/{server_id}/cluster-info")
def get_server_cluster_info(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""
Получить информацию о кластерном режиме сервера.
Возвращает is_cluster=true если сервер в кластере (HA доступна).
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
return JSONResponse(content={
"server_id": server_id,
"server_name": server.name,
"is_cluster": False,
"error": "Cannot connect to server"
})
is_cluster = client.is_cluster()
nodes = client.get_nodes() if is_cluster else []
return JSONResponse(content={
"server_id": server_id,
"server_name": server.name,
"is_cluster": is_cluster,
"node_count": len(nodes),
"nodes": [n.get('node') for n in nodes] if nodes else []
})
except Exception as e:
logger.error(f"Error checking cluster info for server {server_id}: {e}")
return JSONResponse(content={
"server_id": server_id,
"server_name": server.name,
"is_cluster": False,
"error": str(e)
})
@router.put("/api/servers/{server_id}", response_model=ProxmoxServerResponse)
def update_proxmox_server(
server_id: int,
server_data: ProxmoxServerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.servers.edit"))
):
"""Обновить Proxmox сервер"""
from ..proxmox_client import clear_server_cache
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
# Если изменились учётные данные - очищаем кеш
update_data = server_data.model_dump(exclude_unset=True)
if any(key in update_data for key in ['password', 'api_token_name', 'api_token_value', 'api_user']):
clear_server_cache(server.ip_address)
logger.info(f"Cleared cache for server {server.ip_address} due to credential update")
# Update only provided fields
for field, value in update_data.items():
setattr(server, field, value)
db.commit()
db.refresh(server)
logger.info(f"User {current_user.username} updated Proxmox server: {server.name}")
return server
@router.delete("/api/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_proxmox_server(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.servers.delete"))
):
"""Удалить Proxmox сервер и связанные OS Templates"""
from ..proxmox_client import clear_server_cache
from ..models import OSTemplate, OSTemplateGroup, VMInstance
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
server_name = server.name
server_ip = server.ip_address
# Release ALL IPAM allocations for this server
try:
ipam = IPAMService(db)
released_count = ipam.release_all_by_server(
proxmox_server_id=server_id,
released_by=current_user.username
)
if released_count > 0:
logger.info(f"Released {released_count} IPAM allocations for server {server_name}")
except Exception as e:
logger.warning(f"Failed to release IPAM allocations for server: {e}")
# Удаляем связанные OS Templates
templates_deleted = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).delete()
if templates_deleted > 0:
logger.info(f"Deleted {templates_deleted} OS templates for server {server_name}")
# Удаляем кэш VM/контейнеров для этого сервера
vms_deleted = db.query(VMInstance).filter(VMInstance.server_id == server_id).delete()
if vms_deleted > 0:
logger.info(f"Deleted {vms_deleted} cached VM instances for server {server_name}")
# Удаляем пустые группы шаблонов (без шаблонов)
empty_groups = db.query(OSTemplateGroup).filter(
~OSTemplateGroup.id.in_(
db.query(OSTemplate.group_id).filter(OSTemplate.group_id != None).distinct()
)
).all()
for group in empty_groups:
# Проверяем что группа действительно пуста
template_count = db.query(OSTemplate).filter(OSTemplate.group_id == group.id).count()
if template_count == 0:
logger.info(f"Deleting empty template group: {group.name}")
db.delete(group)
logger.info(f"User {current_user.username} deleted Proxmox server: {server_name} ({server_ip})")
# Очищаем кеш подключений для этого сервера
clear_server_cache(server_ip)
db.delete(server)
db.commit()
return None
@router.post("/api/servers/{server_id}/test", status_code=status.HTTP_200_OK)
def test_proxmox_connection(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Проверить подключение к Proxmox серверу"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
# Determine auth method
client = _get_proxmox_client(server)
if client.is_connected():
server.update_status(True)
db.commit()
return {"status": "success", "message": "Connection successful"}
else:
server.update_status(False, "Failed to connect")
db.commit()
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
except Exception as e:
server.update_status(False, str(e))
db.commit()
logger.error(f"Error testing Proxmox connection to {server.name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== Proxmox Resources ====================
@router.get("/api/{server_id}/resources")
def get_server_resources(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""API для получения всех ресурсов (VM + LXC) с Proxmox сервера"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
# Если hostname указан (нода в кластере), фильтруем по ней
# Проверяем что hostname не пустой и не равен IP адресу (автономный сервер)
node_filter = None
if server.hostname and server.hostname != server.ip_address:
node_filter = server.hostname
try:
if server.use_password:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl,
node=node_filter
)
else:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl,
node=node_filter
)
# Фильтруем шаблоны - они не должны считаться как VM
vms = [vm for vm in resources.get('vms', []) if not vm.get('template', 0)]
containers = [ct for ct in resources.get('containers', []) if not ct.get('template', 0)]
server.update_status(True)
db.commit()
return JSONResponse(content={
"server_id": server_id,
"server_name": server.name,
"vms": vms,
"containers": containers
})
except Exception as e:
server.update_status(False, str(e))
db.commit()
raise HTTPException(status_code=500, detail=f"Error getting resources: {str(e)}")
@router.get("/api/resources/all")
def get_all_resources(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""API для получения всех ресурсов со всех Proxmox серверов"""
proxmox_servers = db.query(ProxmoxServer).all()
all_resources = {
"servers": [],
"total_vms": 0,
"total_containers": 0
}
for server in proxmox_servers:
try:
logger.info(f"Getting resources from server {server.name} ({server.ip_address}), use_password={server.use_password}")
# Создаём клиента для каждого сервера независимо
if server.use_password:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
logger.info(f"Using API token for {server.name}: user={server.api_user}, token_name={server.api_token_name}")
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
vms = resources.get('vms', [])
containers = resources.get('containers', [])
logger.info(f"Got {len(vms)} VMs and {len(containers)} containers from {server.name}")
server.update_status(True)
db.commit()
all_resources["servers"].append({
"id": server.id,
"name": server.name,
"ip": server.ip_address,
"vms": vms,
"containers": containers,
"vms_count": len(vms),
"containers_count": len(containers)
})
all_resources["total_vms"] += len(vms)
all_resources["total_containers"] += len(containers)
except Exception as e:
logger.error(f"Error getting resources from server {server.name} ({server.ip_address}): {e}")
server.update_status(False, str(e))
db.commit()
all_resources["servers"].append({
"id": server.id,
"name": server.name,
"ip": server.ip_address,
"error": str(e),
"vms": [],
"containers": [],
"vms_count": 0,
"containers_count": 0
})
# Продолжаем обработку следующего сервера
continue
return JSONResponse(content=all_resources)
# ==================== Node (Host) Status ====================
@router.get("/api/{server_id}/storages")
def get_storages(
server_id: int,
node: str = Query(..., description="Node name"),
content_type: str = Query(None, description="Filter by content type (images, rootdir, vztmpl, etc)"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Получить список хранилищ на ноде Proxmox"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
storages = client.get_storages(node)
# Фильтрация по типу контента если указано
if content_type and storages:
filtered = []
for storage in storages:
content = storage.get('content', '')
if content_type in content.split(','):
filtered.append(storage)
storages = filtered
# Сортировка: сначала активные, потом по имени
storages.sort(key=lambda x: (not x.get('active', 1), x.get('storage', '')))
return JSONResponse(content={"storages": storages})
except Exception as e:
logger.error(f"Error getting storages: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/{server_id}/node/status")
def get_node_status(
server_id: int,
node: str = Query(..., description="Node name"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Получить текущий статус ноды Proxmox (хоста)"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
status = client.get_node_status(node)
if not status:
raise HTTPException(status_code=404, detail="Node status not found")
return JSONResponse(content=status)
except Exception as e:
logger.error(f"Error getting node {node} status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/{server_id}/node/rrddata")
def get_node_rrddata(
server_id: int,
node: str = Query(..., description="Node name"),
timeframe: str = Query("hour", regex="^(hour|day|week|month|year)$"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Получить исторические данные ноды для графиков"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
rrddata = client.get_node_rrddata(node, timeframe)
return JSONResponse(content=rrddata)
except Exception as e:
logger.error(f"Error getting node {node} RRD data: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,462 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
import ssl
import asyncio
import httpx
import websockets
from ..db import get_db
from ..models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ..schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ..proxmox_client import ProxmoxClient, get_proxmox_resources
from ..auth import get_current_user, PermissionChecker, require_permission, check_permission
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
templates)
router = APIRouter()
# ==================== Snapshots ====================
@router.get("/api/{server_id}/vm/{vmid}/snapshots")
def get_vm_snapshots(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Get all snapshots for a VM"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
snapshots = client.get_vm_snapshots(node, vmid)
# Filter out 'current' pseudo-snapshot if present
snapshots = [s for s in snapshots if s.get('name') != 'current']
return JSONResponse(content={"snapshots": snapshots})
except Exception as e:
logger.error(f"Error getting VM {vmid} snapshots: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/snapshots")
async def create_vm_snapshot(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Create a VM snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
snapname = data.get('snapname')
description = data.get('description')
vmstate = data.get('vmstate', False)
if not snapname:
raise HTTPException(status_code=400, detail="Snapshot name is required")
try:
client = _get_proxmox_client(server)
result = client.create_vm_snapshot(node, vmid, snapname, description, vmstate)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_create",
resource_type="vm",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} created snapshot {snapname} for VM {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating VM {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/{server_id}/vm/{vmid}/snapshots/{snapname}")
def delete_vm_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Delete a VM snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_vm_snapshot(node, vmid, snapname)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_delete",
resource_type="vm",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} deleted snapshot {snapname} for VM {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting VM {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/snapshots/{snapname}/rollback")
def rollback_vm_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
start: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Rollback a VM to a snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.rollback_vm_snapshot(node, vmid, snapname, start)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_rollback",
resource_type="vm",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} rolled back VM {vmid} to snapshot {snapname}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to rollback snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error rolling back VM {vmid} to snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Container Snapshots
@router.get("/api/{server_id}/container/{vmid}/snapshots")
def get_container_snapshots(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Get all snapshots for a container"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
snapshots = client.get_container_snapshots(node, vmid)
# Filter out 'current' pseudo-snapshot if present
snapshots = [s for s in snapshots if s.get('name') != 'current']
return JSONResponse(content={"snapshots": snapshots})
except Exception as e:
logger.error(f"Error getting container {vmid} snapshots: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/snapshots")
async def create_container_snapshot(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Create a container snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
snapname = data.get('snapname')
description = data.get('description')
if not snapname:
raise HTTPException(status_code=400, detail="Snapshot name is required")
try:
client = _get_proxmox_client(server)
result = client.create_container_snapshot(node, vmid, snapname, description)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_create",
resource_type="container",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} created snapshot {snapname} for container {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating container {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/{server_id}/container/{vmid}/snapshots/{snapname}")
def delete_container_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Delete a container snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_container_snapshot(node, vmid, snapname)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_delete",
resource_type="container",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} deleted snapshot {snapname} for container {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting container {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/snapshots/{snapname}/rollback")
def rollback_container_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
start: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Rollback a container to a snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.rollback_container_snapshot(node, vmid, snapname, start)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_rollback",
resource_type="container",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} rolled back container {vmid} to snapshot {snapname}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to rollback snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error rolling back container {vmid} to snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== Snapshot Archives ====================
@router.get("/api/snapshot-archives")
def get_snapshot_archives(
server_id: int = None,
vmid: int = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("logs.view"))
):
"""
Get archived snapshots from deleted VMs/containers.
Admin-only endpoint for audit and recovery purposes.
"""
query = db.query(VMSnapshotArchive).order_by(VMSnapshotArchive.archived_at.desc())
if server_id:
query = query.filter(VMSnapshotArchive.server_id == server_id)
if vmid:
query = query.filter(VMSnapshotArchive.vmid == vmid)
total = query.count()
archives = query.offset(offset).limit(limit).all()
return JSONResponse(content={
"total": total,
"offset": offset,
"limit": limit,
"archives": [
{
"id": a.id,
"server_id": a.server_id,
"server_name": a.server_name,
"vmid": a.vmid,
"vm_name": a.vm_name,
"vm_type": a.vm_type,
"node": a.node,
"snapname": a.snapname,
"description": a.description,
"snaptime": a.snaptime,
"parent": a.parent,
"vmstate": a.vmstate,
"deleted_by": a.deleted_by,
"deletion_reason": a.deletion_reason,
"archived_at": a.archived_at.isoformat() if a.archived_at else None
}
for a in archives
]
})
@router.get("/api/snapshot-archives/{archive_id}")
def get_snapshot_archive_detail(
archive_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("logs.view"))
):
"""Get full details of an archived snapshot including config"""
archive = db.query(VMSnapshotArchive).filter(VMSnapshotArchive.id == archive_id).first()
if not archive:
raise HTTPException(status_code=404, detail="Archive not found")
return JSONResponse(content={
"id": archive.id,
"server_id": archive.server_id,
"server_name": archive.server_name,
"vmid": archive.vmid,
"vm_name": archive.vm_name,
"vm_type": archive.vm_type,
"node": archive.node,
"snapname": archive.snapname,
"description": archive.description,
"snaptime": archive.snaptime,
"parent": archive.parent,
"vmstate": archive.vmstate,
"snapshot_config": archive.snapshot_config,
"deleted_by": archive.deleted_by,
"deletion_reason": archive.deletion_reason,
"archived_at": archive.archived_at.isoformat() if archive.archived_at else None
})

View File

@@ -0,0 +1,148 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
import ssl
import asyncio
import httpx
import websockets
from ..db import get_db
from ..models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ..schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ..proxmox_client import ProxmoxClient, get_proxmox_resources
from ..auth import get_current_user, PermissionChecker, require_permission, check_permission
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..ipam_service import IPAMService
from ..models import TaskQueue
from ..services.task_queue_service import TaskQueueService, process_task_queue
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
templates)
router = APIRouter()
@router.get("/api/{server_id}/task/{upid}/status")
def get_task_status(
server_id: int,
upid: str,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Получить статус задачи по UPID"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
status = client.get_task_status(node, upid)
return JSONResponse(content=status)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting task status {upid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/{server_id}/task/{upid}/log")
def get_task_log(
server_id: int,
upid: str,
node: str,
start: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
"""Получить лог задачи по UPID"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
logs = client.get_task_log(node, upid, start, limit)
return JSONResponse(content={"logs": logs})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting task log {upid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/tasks")
def get_user_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
limit: int = Query(20, ge=1, le=100)
):
"""Get recent tasks for current user"""
tasks = TaskQueueService.get_user_tasks(db, current_user.id, limit)
return {
"tasks": [task.to_dict() for task in tasks]
}
@router.get("/api/tasks/active")
def get_active_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all active (pending/running) tasks for current user"""
tasks = db.query(TaskQueue).filter(
TaskQueue.user_id == current_user.id,
TaskQueue.status.in_(['pending', 'running'])
).order_by(TaskQueue.created_at.desc()).all()
return {
"tasks": [task.to_dict() for task in tasks]
}
@router.get("/api/tasks/{task_id}")
def get_task_by_id(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get status of a specific task"""
task = db.query(TaskQueue).filter(
TaskQueue.id == task_id,
TaskQueue.user_id == current_user.id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task.to_dict()
@router.post("/api/tasks/{task_id}/cancel")
def cancel_task(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Cancel a pending task"""
success = TaskQueueService.cancel_task(db, task_id, current_user.id)
if not success:
raise HTTPException(status_code=400, detail="Task cannot be cancelled (not found, not pending, or not yours)")
return {"success": True, "message": "Task cancelled"}

File diff suppressed because it is too large Load Diff