Files
pvemanager/backend/app/api/proxmox/servers.py
markmorado 075b8cdaaa feat(servers): distinguish rejected credentials from unreachable nodes
An offline server used to report a single "Failed to connect" state, but
"the node does not answer" and "the node answered 401" are fixed in
completely different ways — the first is power or network, the second is a
stale token, which is what happens when a node joins a cluster and its
user.cfg and priv/token.cfg are replaced by the cluster ones.

- add ProxmoxClient.check_connection() returning (ok, error_kind, error);
  token auth surfaces 401 as ResourceException rather than
  AuthenticationError, since proxmoxer validates the token on first use
- store the reason in proxmox_servers.last_error_kind (migration 40) and
  expose it through the server response
- surface an auth_error state in the UI with a hint explaining the usual
  cause

Storage listings now also report per-node availability: the cluster config
declares a storage for the whole cluster but says nothing about whether it
actually came up on a given node, so a missing volume group or pool stayed
invisible. Availability comes from a single /cluster/resources call.
2026-07-24 04:42:59 +05:00

846 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
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 concurrent.futures import ThreadPoolExecutor, as_completed
RESOURCES_MAX_WORKERS = 8 # parallel Proxmox fetches for /api/resources/all
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from app.schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox import ProxmoxClient, get_proxmox_resources, _run_in_executor
from ...proxmox.token_provisioning import (
TokenProvisionError, provision_api_token, revoke_api_token,
)
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
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,
can_view_all_instances, get_owned_vmids, get_visible_server_ids)
router = APIRouter()
def _attach_workspaces(db: Session, servers: list) -> None:
"""Прикрепить к каждому серверу список рабочих областей, в которых он состоит.
Пишет транзиентный атрибут ``server.workspaces`` (список словарей), который
затем читает Pydantic-схема ``ProxmoxServerResponse`` через ``from_attributes``.
"""
from ...models import WorkspaceServer, Workspace
if not servers:
return
server_ids = [s.id for s in servers]
rows = (
db.query(WorkspaceServer.server_id, Workspace.id, Workspace.name, Workspace.color)
.join(Workspace, Workspace.id == WorkspaceServer.workspace_id)
.filter(WorkspaceServer.server_id.in_(server_ids))
.all()
)
mapping: dict[int, list] = {}
for sid, wid, wname, wcolor in rows:
mapping.setdefault(sid, []).append({"id": wid, "name": wname, "color": wcolor})
for s in servers:
s.workspaces = mapping.get(s.id, [])
# ==================== Proxmox Server CRUD ====================
@router.get("/api/servers", response_model=List[ProxmoxServerResponse])
def list_proxmox_servers(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
):
"""Получить список всех Proxmox серверов (фильтруется по доступу и активному workspace)"""
visible_ids = get_visible_server_ids(request, db, current_user)
if visible_ids is not None and not visible_ids:
return []
query = db.query(ProxmoxServer).order_by(ProxmoxServer.id)
if visible_ids is not None:
query = query.filter(ProxmoxServer.id.in_(visible_ids))
servers = query.all()
_attach_workspaces(db, servers)
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("server:create"))
):
"""Добавить новый 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"
)
payload = server_data.model_dump()
# Not a column — it selects how credentials are obtained, not what is stored.
auto_token = payload.pop("auto_create_token", False)
provisioned_token: str | None = None
if auto_token:
try:
token_name, token_value = provision_api_token(
ip_address=payload["ip_address"],
port=payload.get("port"),
api_user=payload["api_user"],
password=payload.get("password"),
verify_ssl=payload.get("verify_ssl", False),
comment=f"PVEmanager ({payload['name']})",
)
except TokenProvisionError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
payload["api_token_name"] = token_name
payload["api_token_value"] = token_value
# The password stays stored (encrypted) for SSH fallbacks like pvecm
# delnode, but day-to-day API calls now go through the token.
payload["use_password"] = False
provisioned_token = token_name
server = ProxmoxServer(**payload)
db.add(server)
try:
db.commit()
except Exception:
db.rollback()
# The secret was never persisted, so the token on the node is unusable —
# remove it instead of leaving an orphan behind.
if provisioned_token:
revoke_api_token(
ip_address=payload["ip_address"],
port=payload.get("port"),
api_user=payload["api_user"],
password=payload.get("password"),
token_name=provisioned_token,
verify_ssl=payload.get("verify_ssl", False),
)
raise
db.refresh(server)
# Сразу проверяем подключение, чтобы статус is_online отобразился без задержки
# в 30 секунд (период monitoring_worker.run_server_availability_check).
try:
client = _get_proxmox_client(server)
ok, error_kind, check_error = client.check_connection()
if ok:
server.update_status(True)
else:
server.update_status(False, check_error or "Failed to connect", error_kind)
db.commit()
db.refresh(server)
except Exception as e:
logger.warning(f"Initial connectivity check failed for {server.name}: {e}")
try:
server.update_status(False, str(e))
db.commit()
db.refresh(server)
except Exception:
db.rollback()
# Если сервер онлайн — мгновенно подтягиваем VM/LXC в локальный кэш,
# чтобы NodeDetailPage и InstancesPage сразу увидели инстансы (без ожидания
# 10-секундного цикла vm_cache_sync).
if server.is_online:
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as e:
logger.warning(f"Initial VM cache sync failed for server {server.name}: {e}")
# Уведомляем все подключённые клиенты, что сервер добавлен — фронтенд
# сразу инвалидирует список серверов и список VM.
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_added",
server_id=server.id,
name=server.name,
is_online=bool(server.is_online),
)
except Exception as e:
logger.debug(f"Failed to broadcast server_added: {e}")
logger.info(f"User {current_user.username} added Proxmox server: {server.name}")
return server
@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("server: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")
_attach_workspaces(db, [server])
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("server: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()
node_names = [n.get('node') for n in nodes if n.get('node')]
# Fallback: если get_nodes() вернул пустой список (например, нет прав),
# используем hostname сервера как имя ноды
if not node_names:
node_names = [server.hostname]
return JSONResponse(content={
"server_id": server_id,
"server_name": server.name,
"is_cluster": is_cluster,
"node_count": len(node_names),
"nodes": node_names
})
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("server:update"))
):
"""Обновить Proxmox сервер"""
from ...proxmox 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)
# Пустая строка в полях учётных данных означает "не менять" (клиент
# присылает их пустыми, т.к. секреты не возвращаются с бэкенда) —
# иначе она затирает реальный токен/пароль и сервер уходит в offline.
for cred_field in ('api_token_name', 'api_token_value', 'password'):
if update_data.get(cred_field) == '':
update_data.pop(cred_field)
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}")
# Если изменились параметры подключения — сразу проверяем доступность,
# чтобы статус обновился без 30-секундной задержки monitoring_worker.
creds_changed = any(
key in update_data
for key in ('password', 'api_token_name', 'api_token_value', 'api_user',
'ip_address', 'hostname', 'port', 'verify_ssl', 'use_password')
)
if creds_changed:
try:
client = _get_proxmox_client(server)
ok, error_kind, check_error = client.check_connection()
if ok:
server.update_status(True)
else:
server.update_status(False, check_error or "Failed to connect", error_kind)
db.commit()
db.refresh(server)
if server.is_online:
try:
from ...workers.monitoring_worker import monitoring_worker
monitoring_worker.sync_vm_cache()
except Exception as _e:
logger.debug(f"VM sync after update failed: {_e}")
except Exception as e:
logger.warning(f"Connectivity check after update failed for {server.name}: {e}")
try:
server.update_status(False, str(e))
db.commit()
db.refresh(server)
except Exception:
db.rollback()
# Broadcast — фронтенд инвалидирует кэши списков серверов и инстансов
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_updated",
server_id=server.id,
name=server.name,
is_online=bool(server.is_online),
)
except Exception as e:
logger.debug(f"Failed to broadcast server_updated: {e}")
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("server:delete"))
):
"""Удалить Proxmox сервер и связанные OS Templates"""
from ...proxmox import clear_server_cache
from ...models import OSTemplate, OSTemplateGroup, VMInstance, Notification
from ...workers import monitoring_worker
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})")
# Удаляем уведомления о сервере (offline/online) чтобы они не показывались после удаления
notifications_deleted = db.query(Notification).filter(
Notification.source_id == str(server_id),
Notification.source.in_(["monitoring", "server_monitor"])
).delete(synchronize_session=False)
if notifications_deleted > 0:
logger.info(f"Deleted {notifications_deleted} notifications for server {server_name}")
# Очищаем кеш подключений для этого сервера
clear_server_cache(server_ip)
db.delete(server)
db.commit()
# Очищаем in-memory состояние мониторинга для удалённого сервера
try:
monitoring_worker.cleanup_server_state(server_id)
except Exception as e:
logger.warning(f"Failed to cleanup monitoring state for server {server_id}: {e}")
# Broadcast — фронтенд мгновенно убирает удалённый сервер и его инстансы
try:
from ...websocket_manager import broadcast_event
broadcast_event(
"server_deleted",
server_id=server_id,
name=server_name,
)
except Exception as e:
logger.debug(f"Failed to broadcast server_deleted: {e}")
return None
@router.post("/api/servers/test", status_code=status.HTTP_200_OK)
def test_proxmox_credentials(
payload: ProxmoxServerCreate,
current_user: User = Depends(PermissionChecker("server:view"))
):
"""Проверить произвольные учётные данные Proxmox без сохранения в БД."""
try:
# auto_create_token still authenticates by password at this point — the
# token only gets created when the server is actually saved.
if payload.use_password or payload.auto_create_token:
client = ProxmoxClient(
host=payload.ip_address,
user=payload.api_user,
password=payload.password,
verify_ssl=payload.verify_ssl,
)
else:
client = ProxmoxClient(
host=payload.ip_address,
user=payload.api_user,
token_name=payload.api_token_name,
token_value=payload.api_token_value,
verify_ssl=payload.verify_ssl,
)
if client.is_connected():
return {"success": True, "status": "success", "message": "Connection successful"}
return {"success": False, "status": "error", "message": "Failed to connect to Proxmox server"}
except Exception as e:
logger.error(f"Error testing Proxmox credentials for {payload.ip_address}: {e}")
return {"success": False, "status": "error", "message": str(e)}
@router.post("/api/servers/{server_id}/provision-token", status_code=status.HTTP_200_OK)
def provision_server_token(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:update"))
):
"""Перевести уже подключённый сервер с парольной авторизации на API-токен.
Использует сохранённый пароль сервера: подключается им один раз, создаёт
токен и переключает сервер на токен-авторизацию. Пароль остаётся в БД
(зашифрованным) — он нужен для SSH-фолбэков вроде pvecm delnode.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
if not server.password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Server has no stored password — re-add it with credentials to create a token",
)
try:
token_name, token_value = provision_api_token(
ip_address=server.ip_address,
port=server.port,
api_user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl,
comment=f"PVEmanager ({server.name})",
)
except TokenProvisionError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
previous_token = server.api_token_name
server.api_token_name = token_name
server.api_token_value = token_value
server.use_password = False
try:
db.commit()
except Exception:
db.rollback()
revoke_api_token(
ip_address=server.ip_address, port=server.port, api_user=server.api_user,
password=server.password, token_name=token_name, verify_ssl=server.verify_ssl,
)
raise
db.refresh(server)
# Cached connections still carry the old credentials.
try:
from ...proxmox.client import clear_server_cache
clear_server_cache(server.ip_address)
except Exception as e:
logger.debug(f"Failed to clear connection cache for {server.name}: {e}")
logger.info(
f"User {current_user.username} switched server '{server.name}' to API token "
f"'{server.api_user}!{token_name}'"
+ (f" (previous token: {previous_token})" if previous_token else "")
)
return {"success": True, "token_name": token_name}
@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("server: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 {"success": True, "status": "success", "message": "Connection successful"}
else:
server.update_status(False, "Failed to connect")
db.commit()
return {"success": False, "status": "error", "message": "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}")
return {"success": False, "status": "error", "message": 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("vm: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)]
# Изоляция по владельцу: обычный пользователь видит на ноде только свои
# инстансы. Живые данные Proxmox не содержат owner_id, поэтому сверяемся
# с локальным кэшем vm_instances по vmid.
if not can_view_all_instances(current_user):
owned = get_owned_vmids(db, current_user, server_id)
vms = [vm for vm in vms if vm.get('vmid') in owned]
containers = [ct for ct in containers if ct.get('vmid') in owned]
# Получаем uptime ноды
node_uptime = None
try:
client = _get_proxmox_client(server)
nodes = client.get_nodes()
if nodes:
target_node = node_filter or (nodes[0].get('node') if nodes else None)
for n in nodes:
if target_node and n.get('node') == target_node:
node_uptime = n.get('uptime')
break
if node_uptime is None and nodes:
node_uptime = nodes[0].get('uptime')
except Exception:
pass
server.update_status(True)
db.commit()
return JSONResponse(content={
"server_id": server_id,
"server_name": server.name,
"vms": vms,
"containers": containers,
"node_uptime": node_uptime
})
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(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""API для получения ресурсов со всех видимых пользователю Proxmox серверов.
Набор серверов фильтруется так же, как в GET /api/servers: по доступу
пользователя и по активной рабочей области. Иначе агрегаты (total_vms,
total_containers) считались бы по всей панели, а не по текущей области.
"""
visible_ids = get_visible_server_ids(request, db, current_user)
if visible_ids is not None and not visible_ids:
return JSONResponse(content={"servers": [], "total_vms": 0, "total_containers": 0})
# order_by: without it Postgres returns heap order, which shifts every time
# update_status rewrites a row — the server list would reshuffle per request.
servers_q = db.query(ProxmoxServer).order_by(ProxmoxServer.id)
if visible_ids is not None:
servers_q = servers_q.filter(ProxmoxServer.id.in_(visible_ids))
proxmox_servers = servers_q.all()
# Обычный пользователь видит только свои инстансы во всех агрегатах.
is_privileged = can_view_all_instances(current_user)
all_resources = {
"servers": [],
"total_vms": 0,
"total_containers": 0
}
# Credentials are read here, on the request thread — the worker threads get
# plain kwargs so they never touch the ORM (Session is not thread-safe and a
# lazy load from a worker would blow up mid-request).
fetch_args = {}
for server in proxmox_servers:
logger.info(f"Getting resources from server {server.name}, use_password={server.use_password}")
common = dict(host=server.ip_address, user=server.api_user, verify_ssl=server.verify_ssl)
if server.use_password:
fetch_args[server.id] = {**common, 'password': server.password}
else:
logger.info(f"Using API token for {server.name}: user={server.api_user}")
fetch_args[server.id] = {
**common,
'token_name': server.api_token_name,
'token_value': server.api_token_value,
}
# One slow or unreachable server must not hold up the rest: total wait is the
# slowest server, not the sum of all of them.
fetched: dict[int, dict] = {}
errors: dict[int, str] = {}
if proxmox_servers:
with ThreadPoolExecutor(max_workers=min(RESOURCES_MAX_WORKERS, len(proxmox_servers))) as pool:
futures = {
pool.submit(get_proxmox_resources, **fetch_args[s.id]): s
for s in proxmox_servers
}
for future in as_completed(futures):
server = futures[future]
try:
fetched[server.id] = future.result()
except Exception as e:
logger.error(f"Error getting resources from server {server.name} ({server.ip_address}): {e}")
errors[server.id] = str(e)
# Assemble in the original server order — the response must not reshuffle
# depending on which cluster happened to answer first.
for server in proxmox_servers:
if server.id in errors:
server.update_status(False, errors[server.id])
all_resources["servers"].append({
"id": server.id,
"name": server.name,
"ip": server.ip_address,
"error": errors[server.id],
"vms": [],
"containers": [],
"vms_count": 0,
"containers_count": 0
})
continue
resources = fetched.get(server.id) or {}
vms = resources.get('vms', [])
containers = resources.get('containers', [])
# Изоляция по владельцу (живые данные Proxmox не содержат owner_id).
if not is_privileged:
owned = get_owned_vmids(db, current_user, server.id)
vms = [vm for vm in vms if vm.get('vmid') in owned]
containers = [ct for ct in containers if ct.get('vmid') in owned]
logger.info(f"Got {len(vms)} VMs and {len(containers)} containers from {server.name}")
server.update_status(True)
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)
db.commit() # single commit for every status update, instead of one per server
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("server: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_node_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("vm: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("vm: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))