feat: v1.1.0 — backup module, IPAM node binding, HA, task queue, theme CSS

- Backup module: full CRUD for storages, backup files, scheduled jobs (APScheduler)
- Proxmox client: storage/backup/restore/HA methods
- IPAM: proxmox_node field, server+node binding for networks and allocations
- Task queue service: improvements and new task types
- Monitoring worker: extended metrics and alerts
- HA support: add/remove VMs from Proxmox HA groups
- tasks.html: new task tracking page
- proxmox_vms.html: major UI update (bulk ops, HA, snapshots)
- backups.html: full backup management UI (3 tabs)
- base.html: sidebar backup link, websocket manager
- theme.css: full CSS variables theme, 395 lines added
- migrations: migration #15 (BackupJob), #16 (proxmox_node)
- RBAC: backup resource permissions
- websocket_manager.py: new module for WS connections
This commit is contained in:
markmorado
2026-03-08 18:29:29 +01:00
parent 2c8becc6d4
commit b3db346cfb
21 changed files with 3676 additions and 93 deletions

View File

@@ -5,6 +5,7 @@ from .snapshots import router as snapshots_router
from .vms import router as vms_router
from .tasks import router as tasks_router
from .backup import router as backup_router
from .cluster import router as cluster_router
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
router = APIRouter()
@@ -14,3 +15,4 @@ router.include_router(snapshots_router)
router.include_router(vms_router)
router.include_router(tasks_router)
router.include_router(backup_router)
router.include_router(cluster_router)

View File

@@ -349,6 +349,25 @@ async def restore_backup(
# ==================== Scheduled Backup Jobs ====================
@router.get("/api/backups/proxmox-jobs/{server_id}")
def list_proxmox_native_jobs(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""Fetch native vzdump backup schedules configured directly in Proxmox"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
jobs = client.get_cluster_backup_jobs()
return JSONResponse(content={"jobs": jobs, "server_name": server.name})
except Exception as e:
logger.error(f"Error fetching proxmox native jobs for server {server_id}: {e}")
return JSONResponse(content={"jobs": [], "error": str(e)})
@router.get("/api/backups/jobs")
def list_backup_jobs(
db: Session = Depends(get_db),
@@ -408,9 +427,10 @@ async def create_backup_job(
except Exception as e:
logger.warning(f"Could not register job {job.id} with scheduler: {e}")
LoggingService.log_action(
db=db, action="backup_job_create", resource_type="backup_job",
LoggingService.log_proxmox_action(
db=db, action="create", resource_type="backup_job",
resource_id=str(job.id), username=current_user.username, success=True,
server_id=job.server_id, node_name=job.node,
)
return JSONResponse(content={"success": True, "job": job.to_dict()})

View File

@@ -0,0 +1,648 @@
"""
Cluster Topology Management API
=================================
Endpoints for creating Proxmox clusters, joining nodes, ejecting nodes,
and preparing nodes for join (backup + cleanup).
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from loguru import logger
import asyncio
from typing import Optional
from ...db import get_db
from ...models import ProxmoxServer, User
from ...auth import PermissionChecker
from ...proxmox_client import ProxmoxClient
from ._helpers import _get_proxmox_client
router = APIRouter()
# ==================== Helpers ====================
def _get_server_or_404(db: Session, server_id: int) -> ProxmoxServer:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail=f"Server {server_id} not found")
return server
def _get_password_client(server: ProxmoxServer, override_password: str = None) -> ProxmoxClient:
"""
Build a ProxmoxClient that uses password auth (required for cluster ops).
Falls back to stored password, or raises 400 if none available.
"""
pw = override_password or server.password
if not pw:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Для операций с кластером требуется пароль root@pam. "
"Убедитесь, что сервер добавлен через Auto-Setup с паролем, "
"или передайте пароль явно в параметре rootpw."
)
)
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=pw,
verify_ssl=server.verify_ssl,
)
# ==================== GET /api/cluster/topology ====================
@router.get("/api/cluster/topology")
def get_cluster_topology(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view")),
):
"""
Получить все серверы, сгруппированные по кластеру.
Standalone-серверы (cluster_name=NULL) — в отдельном списке.
"""
servers = db.query(ProxmoxServer).all()
clusters: dict = {}
standalone = []
for s in servers:
entry = {
"id": s.id,
"name": s.name,
"hostname": s.hostname,
"ip_address": s.ip_address,
"port": s.port,
"is_online": s.is_online,
"cluster_name": s.cluster_name,
}
if s.cluster_name:
clusters.setdefault(s.cluster_name, []).append(entry)
else:
standalone.append(entry)
return {"clusters": clusters, "standalone": standalone}
# ==================== POST /api/cluster/create ====================
@router.post("/api/cluster/create", status_code=status.HTTP_201_CREATED)
async def create_cluster(
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.cluster.manage")),
):
"""
Создать новый Proxmox corosync кластер на указанной ноде.
Body:
server_id (int): ID ноды в панели
cluster_name (str): Уникальное имя кластера
link0_ip (str, optional): IP для corosync link0
rootpw (str, optional): root-пароль (если не сохранён)
"""
server_id = body.get("server_id")
cluster_name = body.get("cluster_name", "").strip()
link0_ip = body.get("link0_ip")
rootpw = body.get("rootpw")
if not server_id or not cluster_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать server_id и cluster_name"
)
# Validate cluster_name matches Proxmox node name rules:
# lowercase, alphanumeric + hyphens, starts with letter, max 15 chars
import re
if not re.match(r'^[a-zA-Z][a-zA-Z0-9\-]{0,14}$', cluster_name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="cluster_name должен начинаться с буквы, содержать только буквы/цифры/дефис, макс 15 символов"
)
server = _get_server_or_404(db, server_id)
# Check if server is already part of a cluster
if server.cluster_name:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Сервер '{server.name}' уже состоит в кластере '{server.cluster_name}'"
)
client = _get_password_client(server, rootpw)
# Verify server is not already in a cluster on Proxmox side
if client.is_cluster():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Нода уже является частью кластера Proxmox. Используйте join для добавления в существующий кластер."
)
result = client.create_cluster(cluster_name=cluster_name, link0=link0_ip)
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Ошибка создания кластера: {result.get('error')}"
)
# Update panel DB
server.cluster_name = cluster_name
db.commit()
logger.info(
f"User {current_user.username} created cluster '{cluster_name}' on server '{server.name}'"
)
return {
"success": True,
"cluster_name": cluster_name,
"server_id": server_id,
"server_name": server.name,
"upid": result.get("upid"),
"message": f"Кластер '{cluster_name}' создаётся. Отслеживайте задачу через upid.",
}
# ==================== GET /api/cluster/{server_id}/join-info ====================
@router.get("/api/cluster/{server_id}/join-info")
def get_cluster_join_info(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.cluster.manage")),
):
"""
Получить fingerprint и join-данные с кластерной ноды.
Используется для последующего вызова /api/cluster/join.
"""
server = _get_server_or_404(db, server_id)
client = _get_proxmox_client(server)
result = client.get_cluster_join_info()
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Не удалось получить join-информацию: {result.get('error')}"
)
data = result.get("data", {})
# Extract the most useful fields for the frontend
totem = data.get("totem", {})
return {
"server_id": server_id,
"server_name": server.name,
"cluster_name": totem.get("cluster_name") or server.cluster_name,
"fingerprint": data.get("fingerprint"),
"nodelist": data.get("nodelist", []),
"config_digest": data.get("config_digest"),
"preferred_node_ip": server.ip_address,
}
# ==================== GET /api/cluster/{server_id}/pre-join-check ====================
@router.get("/api/cluster/{server_id}/pre-join-check")
def pre_join_check(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.cluster.manage")),
):
"""
Проверить ноду перед присоединением к кластеру:
- Список существующих VM и LXC (их нужно бэкапировать и удалить)
- Список хранилищ, поддерживающих content=backup
"""
server = _get_server_or_404(db, server_id)
client = _get_proxmox_client(server)
nodes = client.get_nodes()
node_name = nodes[0].get("node") if nodes else server.hostname
vms = client.get_vms(node=node_name)
containers = client.get_containers(node=node_name)
backup_storages = client.get_backup_storages(node=node_name)
vm_list = [
{
"vmid": v.get("vmid"),
"name": v.get("name", f"vm-{v.get('vmid')}"),
"status": v.get("status"),
"type": "qemu",
"maxdisk": v.get("maxdisk", 0),
"mem": v.get("maxmem", 0),
}
for v in vms
]
ct_list = [
{
"vmid": c.get("vmid"),
"name": c.get("name", f"ct-{c.get('vmid')}"),
"status": c.get("status"),
"type": "lxc",
"maxdisk": c.get("maxdisk", 0),
"mem": c.get("maxmem", 0),
}
for c in containers
]
guests = vm_list + ct_list
storages = [
{
"storage": s.get("storage"),
"type": s.get("type"),
"avail": s.get("avail"),
"total": s.get("total"),
}
for s in backup_storages
]
return {
"server_id": server_id,
"server_name": server.name,
"node_name": node_name,
"guests_count": len(guests),
"guests": guests,
"backup_storages": storages,
"ready_for_join": len(guests) == 0,
"warning": (
"После присоединения к кластеру /etc/pve будет перезаписан. "
"Рекомендуется использовать shared storage (NFS, Ceph) — "
"локальные хранилища ноды могут стать недоступными через кластерный интерфейс."
if guests else None
),
}
# ==================== POST /api/cluster/prepare-join ====================
@router.post("/api/cluster/prepare-join")
async def prepare_join(
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.cluster.manage")),
):
"""
Создать резервные копии всех VM/LXC на ноде через vzdump и удалить их,
чтобы нода стала готова для присоединения к кластеру.
Body:
node_server_id (int): ID ноды в панели
backup_storage (str): Хранилище для бэкапов (content=backup)
compress (str, optional): zstd | lzo | gzip | 0 (default: zstd)
Returns:
backed_up: список успешно забэкапированных vmid
deleted: список удалённых vmid
errors: список ошибок (vmid + сообщение)
ready_for_join: True если все VM удалены
"""
node_server_id = body.get("node_server_id")
backup_storage = body.get("backup_storage", "").strip()
compress = body.get("compress", "zstd")
if not node_server_id or not backup_storage:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать node_server_id и backup_storage"
)
server = _get_server_or_404(db, node_server_id)
client = _get_proxmox_client(server)
nodes = client.get_nodes()
node_name = nodes[0].get("node") if nodes else server.hostname
# Collect all guests
vms = client.get_vms(node=node_name)
containers = client.get_containers(node=node_name)
all_guests = [
{"vmid": v.get("vmid"), "type": "qemu"} for v in vms
] + [
{"vmid": c.get("vmid"), "type": "lxc"} for c in containers
]
if not all_guests:
return {
"backed_up": [],
"deleted": [],
"errors": [],
"ready_for_join": True,
"message": "Нода не содержит VM/LXC — готова к join",
}
backed_up = []
errors = []
# Step 1: Create backups for all guests
upid_map: dict = {} # vmid -> upid
for guest in all_guests:
vmid = guest["vmid"]
result = client.vzdump_guest(
node=node_name,
vmid=vmid,
storage=backup_storage,
compress=compress,
)
if result.get("success"):
upid_map[vmid] = result["upid"]
else:
errors.append({"vmid": vmid, "phase": "backup", "error": result.get("error")})
# Step 2: Poll backup tasks until done (max 30 min, check every 5 sec)
MAX_WAIT = 1800 # seconds
POLL_INTERVAL = 5
elapsed = 0
while upid_map and elapsed < MAX_WAIT:
await asyncio.sleep(POLL_INTERVAL)
elapsed += POLL_INTERVAL
finished_vmids = []
for vmid, upid in list(upid_map.items()):
try:
task_status = client.proxmox.nodes(node_name).tasks(upid).status.get()
if task_status.get("status") == "stopped":
finished_vmids.append(vmid)
if task_status.get("exitstatus") == "OK":
backed_up.append(vmid)
else:
errors.append({
"vmid": vmid,
"phase": "backup",
"error": f"vzdump завершился с ошибкой: {task_status.get('exitstatus')}",
})
except Exception as e:
logger.warning(f"Could not check task status for vmid {vmid}: {e}")
for vmid in finished_vmids:
del upid_map[vmid]
# Timeout: treat remaining as errors
for vmid in upid_map:
errors.append({"vmid": vmid, "phase": "backup", "error": "Таймаут ожидания бэкапа (30 мин)"})
# Step 3: Delete guests that were successfully backed up
deleted = []
vmids_to_delete = set(backed_up)
for guest in all_guests:
vmid = guest["vmid"]
if vmid not in vmids_to_delete:
continue
try:
if guest["type"] == "qemu":
# Stop first if running
qemu_info = client.proxmox.nodes(node_name).qemu(vmid).status.current.get()
if qemu_info.get("status") == "running":
client.proxmox.nodes(node_name).qemu(vmid).status.stop.post()
await asyncio.sleep(3)
client.proxmox.nodes(node_name).qemu(vmid).delete()
else:
ct_info = client.proxmox.nodes(node_name).lxc(vmid).status.current.get()
if ct_info.get("status") == "running":
client.proxmox.nodes(node_name).lxc(vmid).status.stop.post()
await asyncio.sleep(3)
client.proxmox.nodes(node_name).lxc(vmid).delete()
deleted.append(vmid)
logger.info(f"Deleted guest vmid {vmid} on {server.name} during prepare-join")
except Exception as e:
errors.append({"vmid": vmid, "phase": "delete", "error": str(e)})
ready_for_join = len(errors) == 0 and len(deleted) == len(all_guests)
logger.info(
f"User {current_user.username} prepare-join on '{server.name}': "
f"backed_up={backed_up}, deleted={deleted}, errors={[e['vmid'] for e in errors]}"
)
return {
"backed_up": backed_up,
"deleted": deleted,
"errors": errors,
"ready_for_join": ready_for_join,
"message": (
"Нода очищена и готова к присоединению к кластеру"
if ready_for_join
else f"Завершено с {len(errors)} ошибками. Исправьте их вручную перед join."
),
}
# ==================== POST /api/cluster/join ====================
@router.post("/api/cluster/join")
async def join_cluster(
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.cluster.manage")),
):
"""
Присоединить standalone-ноду к существующему кластеру.
Body:
node_server_id (int): ID присоединяемой ноды в панели
cluster_server_id (int): ID любой ноды существующего кластера
rootpw (str): root-пароль присоединяемой ноды
link0 (str, optional): Локальный IP присоединяемой ноды для corosync
Returns 409 если нода содержит VM/LXC (сначала вызовите prepare-join).
"""
node_server_id = body.get("node_server_id")
cluster_server_id = body.get("cluster_server_id")
rootpw = body.get("rootpw", "").strip()
link0 = body.get("link0")
if not node_server_id or not cluster_server_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать node_server_id и cluster_server_id"
)
if not rootpw:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать rootpw (root-пароль присоединяемой ноды)"
)
if node_server_id == cluster_server_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="node_server_id и cluster_server_id не могут совпадать"
)
node_server = _get_server_or_404(db, node_server_id)
cluster_server = _get_server_or_404(db, cluster_server_id)
if not cluster_server.cluster_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Сервер '{cluster_server.name}' не является кластерной нодой в панели. "
"Сначала создайте кластер или выберите ноду с cluster_name."
)
# --- Pre-check: ноды не должно содержать гестов ---
node_client = _get_proxmox_client(node_server)
nodes = node_client.get_nodes()
node_name = nodes[0].get("node") if nodes else node_server.hostname
vms = node_client.get_vms(node=node_name)
containers = node_client.get_containers(node=node_name)
if vms or containers:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "GUESTS_FOUND",
"message": (
f"Нода '{node_server.name}' содержит {len(vms)} VM и {len(containers)} LXC. "
"При присоединении к кластеру /etc/pve будет перезаписан и все конфиги потеряются. "
"Используйте 'prepare-join' для создания бэкапов и очистки ноды."
),
"vms": [{"vmid": v.get("vmid"), "name": v.get("name")} for v in vms],
"lxc": [{"vmid": c.get("vmid"), "name": c.get("name")} for c in containers],
}
)
# --- Получить join-info с кластерной ноды ---
cluster_client = _get_proxmox_client(cluster_server)
join_info_result = cluster_client.get_cluster_join_info()
if not join_info_result.get("success"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Не удалось получить join-информацию с '{cluster_server.name}': "
f"{join_info_result.get('error')}"
)
join_data = join_info_result.get("data", {})
fingerprint = join_data.get("fingerprint")
cluster_name = join_data.get("totem", {}).get("cluster_name") or cluster_server.cluster_name
if not fingerprint:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Не удалось получить fingerprint кластера"
)
# --- Выполнить join на присоединяемой ноде (используем пароль из запроса) ---
joining_client = _get_password_client(node_server, rootpw)
result = joining_client.join_cluster(
cluster_host=cluster_server.ip_address,
rootpw=rootpw,
fingerprint=fingerprint,
link0=link0,
)
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Ошибка присоединения к кластеру: {result.get('error')}"
)
# --- Обновить панель: выставить cluster_name ---
node_server.cluster_name = cluster_name
db.commit()
logger.info(
f"User {current_user.username} joined '{node_server.name}' "
f"to cluster '{cluster_name}' (via '{cluster_server.name}')"
)
return {
"success": True,
"node_server_id": node_server_id,
"node_name": node_server.name,
"cluster_name": cluster_name,
"upid": result.get("upid"),
"message": (
f"Нода '{node_server.name}' присоединяется к кластеру '{cluster_name}'. "
"После завершения задачи интерфейс ноды перезапустится. "
"Отслеживайте прогресс через upid."
),
}
# ==================== POST /api/cluster/{server_id}/eject-node ====================
@router.post("/api/cluster/{server_id}/eject-node")
def eject_cluster_node(
server_id: int,
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.cluster.manage")),
):
"""
Удалить ноду из кластера Proxmox.
Вызывается на ЛЮБОЙ другой кластерной ноде (не на удаляемой).
Удаляемая нода должна быть ВЫКЛЮЧЕНА.
body:
node_name (str): hostname ноды для удаления
rootpw (str, optional): root-пароль для SSH fallback
clear_panel_entry (bool, optional): Очистить cluster_name у панельной записи ноды (default: true)
"""
node_name = body.get("node_name", "").strip()
rootpw = body.get("rootpw")
clear_panel_entry = body.get("clear_panel_entry", True)
if not node_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать node_name"
)
# server_id — это нода, с которой выполняем команду (остающаяся в кластере)
server = _get_server_or_404(db, server_id)
# Build client — prefer password for SSH fallback availability
if rootpw or server.password:
client = _get_password_client(server, rootpw)
client._password = rootpw or server.password # expose for SSH fallback
else:
client = _get_proxmox_client(server)
result = client.delete_cluster_node(node_name)
if not result.get("success"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Не удалось удалить ноду '{node_name}': {result.get('error')}"
)
# Clear cluster_name on matching panel entry
if clear_panel_entry:
ejected_server = db.query(ProxmoxServer).filter(
ProxmoxServer.hostname == node_name,
ProxmoxServer.cluster_name == server.cluster_name,
).first()
if ejected_server:
ejected_server.cluster_name = None
db.commit()
logger.info(
f"Cleared cluster_name for panel server '{ejected_server.name}' (ejected node)"
)
logger.info(
f"User {current_user.username} ejected node '{node_name}' "
f"from cluster '{server.cluster_name}' via server '{server.name}' "
f"(method: {result.get('method', 'rest')})"
)
return {
"success": True,
"ejected_node": node_name,
"cluster_name": server.cluster_name,
"method": result.get("method", "rest"),
"message": (
f"Нода '{node_name}' удалена из кластера. "
"Убедитесь, что нода выключена и не подключена к той же кластерной сети."
),
}

View File

@@ -403,14 +403,19 @@ def get_server_cluster_info(
})
is_cluster = client.is_cluster()
nodes = client.get_nodes() if is_cluster else []
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(nodes),
"nodes": [n.get('node') for n in nodes] if nodes else []
"node_count": len(node_names),
"nodes": node_names
})
except Exception as e:
logger.error(f"Error checking cluster info for server {server_id}: {e}")

View File

@@ -11,7 +11,7 @@ import httpx
import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask
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
@@ -28,6 +28,60 @@ from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
router = APIRouter()
# ==================== ProxmoxTaskService ====================
class ProxmoxTaskService:
"""Service for managing Proxmox UPID tasks stored in DB."""
@staticmethod
def register(
db: Session,
upid: str,
server_id: int,
user_id: int,
action: str,
node: str = None,
vmid: int = None,
vm_type: str = None,
description: str = None,
) -> ProxmoxTask:
task = ProxmoxTask(
upid=upid,
server_id=server_id,
user_id=user_id,
node=node,
vmid=vmid,
vm_type=vm_type,
action=action,
description=description,
status="running",
)
db.add(task)
db.commit()
db.refresh(task)
logger.info(f"[PROXMOX TASK] Registered UPID task #{task.id}: {action} on {node}/{vmid}")
return task
@staticmethod
def get_user_tasks(db: Session, user_id: int, limit: int = 50) -> list:
return (
db.query(ProxmoxTask)
.filter(ProxmoxTask.user_id == user_id)
.order_by(ProxmoxTask.created_at.desc())
.limit(limit)
.all()
)
@staticmethod
def get_active(db: Session, user_id: int) -> list:
return (
db.query(ProxmoxTask)
.filter(ProxmoxTask.user_id == user_id, ProxmoxTask.status == "running")
.order_by(ProxmoxTask.created_at.desc())
.all()
)
@router.get("/api/{server_id}/task/{upid}/status")
def get_task_status(
server_id: int,
@@ -86,33 +140,30 @@ def get_task_log(
raise HTTPException(status_code=500, detail=str(e))
# ─── Bulk (TaskQueue) endpoints ─────────────────────────────────────────────
@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"""
"""Get recent bulk-operation tasks for current user"""
tasks = TaskQueueService.get_user_tasks(db, current_user.id, limit)
return {
"tasks": [task.to_dict() for task in tasks]
}
return {"tasks": [task.to_dict() for task in tasks]}
@router.get("/api/tasks/active")
def get_active_tasks(
def get_active_bulk_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all active (pending/running) tasks for current user"""
"""Get all active (pending/running) bulk 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]
}
return {"tasks": [task.to_dict() for task in tasks]}
@router.get("/api/tasks/{task_id}")
@@ -121,7 +172,7 @@ def get_task_by_id(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get status of a specific task"""
"""Get status of a specific bulk task"""
task = db.query(TaskQueue).filter(
TaskQueue.id == task_id,
TaskQueue.user_id == current_user.id
@@ -139,10 +190,92 @@ def cancel_task(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Cancel a pending task"""
"""Cancel a pending bulk 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"}
# ─── All tasks (bulk + proxmox UPID) combined ──────────────────────────────
@router.get("/api/all-tasks")
def get_all_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
limit: int = Query(30, ge=1, le=200),
status_filter: str = Query(None, alias="status"),
):
"""
Return both TaskQueue (bulk ops) and ProxmoxTask (UPID) records for the
current user, merged and sorted by created_at descending.
"""
bulk_q = db.query(TaskQueue).filter(TaskQueue.user_id == current_user.id)
proxmox_q = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == current_user.id)
if status_filter:
# Normalise statuses: proxmox uses running/completed/failed; bulk uses the same + pending/cancelled
bulk_q = bulk_q.filter(TaskQueue.status == status_filter)
proxmox_q = proxmox_q.filter(ProxmoxTask.status == status_filter)
bulk_tasks = bulk_q.order_by(TaskQueue.created_at.desc()).limit(limit).all()
proxmox_tasks = proxmox_q.order_by(ProxmoxTask.created_at.desc()).limit(limit).all()
all_tasks = sorted(
[t.to_dict() for t in bulk_tasks] + [t.to_dict() for t in proxmox_tasks],
key=lambda t: t.get("created_at") or "",
reverse=True,
)[:limit]
return {"tasks": all_tasks}
@router.get("/api/all-tasks/active-count")
def get_active_count(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return count of currently active tasks (pending/running) for navbar badge."""
bulk_count = db.query(func.count(TaskQueue.id)).filter(
TaskQueue.user_id == current_user.id,
TaskQueue.status.in_(["pending", "running"]),
).scalar() or 0
proxmox_count = db.query(func.count(ProxmoxTask.id)).filter(
ProxmoxTask.user_id == current_user.id,
ProxmoxTask.status == "running",
).scalar() or 0
return {"count": bulk_count + proxmox_count}
# ─── Proxmox UPID task log from DB ──────────────────────────────────────────
@router.get("/api/proxmox-tasks")
def list_proxmox_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
limit: int = Query(20, ge=1, le=100),
):
"""List ProxmoxTask records for current user."""
tasks = ProxmoxTaskService.get_user_tasks(db, current_user.id, limit)
return {"tasks": [t.to_dict() for t in tasks]}
@router.get("/api/proxmox-tasks/{task_id}/log")
def get_proxmox_task_log_db(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get the cached log text for a ProxmoxTask stored in DB."""
task = db.query(ProxmoxTask).filter(
ProxmoxTask.id == task_id,
ProxmoxTask.user_id == current_user.id,
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {"log_text": task.log_text or "", "status": task.status, "exit_status": task.exit_status}

View File

@@ -27,6 +27,7 @@ router = APIRouter()
from ...models import TaskQueue
from ...services.task_queue_service import TaskQueueService, process_task_queue
from ...api.proxmox.tasks import ProxmoxTaskService
from pydantic import BaseModel
from typing import List as TypingList, Optional
@@ -1869,6 +1870,20 @@ async def download_lxc_template(
if not upid:
raise HTTPException(status_code=500, detail="Failed to start template download")
# Register UPID task for real-time tracking
try:
ProxmoxTaskService.register(
db=db,
upid=upid,
server_id=server_id,
user_id=current_user.id,
action="download_template",
node=node,
description=f"Download template: {template}",
)
except Exception as _e:
logger.warning(f"Failed to register UPID task for template download: {_e}")
LoggingService.log_proxmox_action(
db=db,
action="download_template",
@@ -1974,6 +1989,22 @@ async def create_lxc_container(
if not upid:
raise HTTPException(status_code=500, detail="Failed to create LXC container")
# Register UPID task for real-time tracking
try:
ProxmoxTaskService.register(
db=db,
upid=upid,
server_id=server_id,
user_id=current_user.id,
action="create_lxc",
node=node,
vmid=vmid,
vm_type="lxc",
description=f"Create LXC container: {hostname} (ID: {vmid})",
)
except Exception as _e:
logger.warning(f"Failed to register UPID task for LXC creation: {_e}")
LoggingService.log_proxmox_action(
db=db,
action="create",

View File

@@ -3,7 +3,7 @@ import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exception_handlers import http_exception_handler
@@ -71,6 +71,21 @@ def setup_logging():
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
# Suppress noisy third-party loggers
for noisy_logger in (
"apscheduler",
"apscheduler.scheduler",
"apscheduler.executors.default",
"httpx",
"httpcore",
"httpcore.http11",
"httpcore.connection",
"urllib3",
"urllib3.connectionpool",
"multipart",
):
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -277,6 +292,87 @@ def create_app() -> FastAPI:
"""Proxy to proxmox virtual-machines API"""
return RedirectResponse(url="/proxmox/api/virtual-machines", status_code=307)
# Task Manager page
@app.get("/tasks", include_in_schema=False)
async def tasks_page(request: Request):
"""Task Manager page"""
from .i18n import t
lang = request.cookies.get("language", "ru")
context = {
"request": request,
"page_title": t("nav_tasks", lang) if callable(t) else "Менеджер задач",
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("tasks.html", context)
# WebSocket endpoint for real-time task updates
@app.websocket("/ws/tasks")
async def websocket_tasks(websocket: WebSocket, token: str = Query(None)):
"""
WebSocket endpoint for live task manager updates.
Auth via ?token=<jwt> query parameter (browsers cannot set WS headers).
"""
from .auth import decode_access_token
from .db import SessionLocal
from .models import TaskQueue, ProxmoxTask, User
from .websocket_manager import ws_manager
import json
# --- Authenticate --------------------------------------------------
if not token:
await websocket.close(code=4001, reason="Missing token")
return
try:
payload = decode_access_token(token)
username = payload.get("sub")
if not username:
raise ValueError("No subject in token")
except Exception:
await websocket.close(code=4001, reason="Invalid token")
return
db = SessionLocal()
try:
user = db.query(User).filter(User.username == username, User.is_active == True).first()
if not user:
await websocket.close(code=4001, reason="User not found")
return
user_id = user.id
finally:
db.close()
# --- Connect -------------------------------------------------------
await ws_manager.connect(websocket, user_id)
try:
# Send initial snapshot
db = SessionLocal()
try:
bulk = db.query(TaskQueue).filter(TaskQueue.user_id == user_id).order_by(TaskQueue.created_at.desc()).limit(30).all()
prox = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == user_id).order_by(ProxmoxTask.created_at.desc()).limit(30).all()
finally:
db.close()
all_tasks = sorted(
[t.to_dict() for t in bulk] + [t.to_dict() for t in prox],
key=lambda x: x.get("created_at") or "",
reverse=True,
)[:30]
await websocket.send_text(json.dumps({"type": "task_list", "tasks": all_tasks}, default=str))
# Keep connection alive – wait for client messages / ping-pong
while True:
try:
msg = await websocket.receive_text()
if msg == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
except WebSocketDisconnect:
break
except Exception:
break
finally:
ws_manager.disconnect(websocket, user_id)
# Include routers
app.include_router(auth_router.router, tags=["auth"])
app.include_router(dashboard_router.router, tags=["dashboard"])

View File

@@ -385,6 +385,9 @@ class ProxmoxServer(Base):
# SSL/TLS verification
verify_ssl = Column(Boolean, nullable=False, default=True)
# Cluster membership
cluster_name = Column(String(100), nullable=True, index=True)
# Metadata
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@@ -787,11 +790,15 @@ class TaskQueue(Base):
def to_dict(self) -> dict:
return {
'id': self.id,
'kind': 'bulk',
'user_id': self.user_id,
'task_type': self.task_type,
'description': self.task_type,
'status': self.status,
'total_items': self.total_items,
'completed_items': self.completed_items,
'failed_items': self.failed_items,
'processed_items': self.completed_items + self.failed_items,
'progress_percent': self.progress_percent,
'results': self.results,
'error_message': self.error_message,
@@ -801,6 +808,53 @@ class TaskQueue(Base):
}
# ==================== Proxmox UPID Task ====================
class ProxmoxTask(Base):
"""
Tracks long-running Proxmox tasks identified by UPID.
Created when operations like create VM/container or download template are started.
"""
__tablename__ = "proxmox_tasks"
id = Column(Integer, primary_key=True, index=True)
upid = Column(String(200), nullable=False, index=True)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="SET NULL"), nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
node = Column(String(100), nullable=True)
vmid = Column(Integer, nullable=True)
vm_type = Column(String(10), nullable=True) # qemu / lxc / storage
action = Column(String(100), nullable=False) # create, clone, download_template, etc.
description = Column(String(500), nullable=True)
status = Column(String(20), nullable=False, default="running", index=True) # running/completed/failed
exit_status = Column(String(100), nullable=True) # "OK" or error text from Proxmox
log_text = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
'id': self.id,
'kind': 'proxmox',
'upid': self.upid,
'server_id': self.server_id,
'user_id': self.user_id,
'node': self.node,
'vmid': self.vmid,
'vm_type': self.vm_type,
'action': self.action,
'description': self.description,
'status': self.status,
'exit_status': self.exit_status,
'log_text': self.log_text,
'created_at': self.created_at.isoformat() if self.created_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
def __repr__(self):
return f"<ProxmoxTask(id={self.id}, upid='{self.upid[:30]}...', status='{self.status}')>"
# ==================== Snapshot Archive ====================
class VMSnapshotArchive(Base):

View File

@@ -577,6 +577,187 @@ class ProxmoxClient:
logger.error(f"Ошибка получения HA статуса {vmid}: {e}")
return None
# ==================== Cluster Topology Management ====================
def create_cluster(self, cluster_name: str, link0: str = None) -> Dict:
"""
Создать новый Proxmox кластер на этой ноде.
Требует аутентификации через пароль (root@pam).
Args:
cluster_name: Уникальное имя кластера (не изменяемо после создания)
link0: IP-адрес для corosync link0 (по умолчанию — IP ноды)
Returns:
Dict с UPID задачи или ошибкой
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
params = {'clustername': cluster_name}
if link0:
params['link0'] = link0
result = self.proxmox.cluster.config.post(**params)
logger.info(f"Cluster '{cluster_name}' creation started on {self.host}, UPID: {result}")
return {'success': True, 'upid': result, 'cluster_name': cluster_name}
except Exception as e:
logger.error(f"Ошибка создания кластера '{cluster_name}' на {self.host}: {e}")
return {'success': False, 'error': str(e)}
def get_cluster_join_info(self) -> Dict:
"""
Получить информацию для присоединения к кластеру (fingerprint, join token).
Вызывается на существующей кластерной ноде.
Returns:
Dict с полями: totem, nodelist, fingerprint, config_digest
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
info = self.proxmox.cluster.config.join.get()
logger.info(f"Got cluster join info from {self.host}")
return {'success': True, 'data': info}
except Exception as e:
logger.error(f"Ошибка получения join info с {self.host}: {e}")
return {'success': False, 'error': str(e)}
def join_cluster(self, cluster_host: str, rootpw: str, fingerprint: str,
link0: str = None) -> Dict:
"""
Присоединить эту ноду к существующему кластеру.
ВАЖНО: Нода должна быть пустой — /etc/pve будет перезаписан.
Args:
cluster_host: IP/hostname существующей кластерной ноды
rootpw: root-пароль кластерной ноды (нужен для join)
fingerprint: SHA-256 fingerprint кластерного сертификата
link0: Локальный IP для corosync link0 (по умолчанию — IP ноды)
Returns:
Dict с UPID задачи или ошибкой
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
params = {
'hostname': cluster_host,
'password': rootpw,
'fingerprint': fingerprint,
}
if link0:
params['link0'] = link0
result = self.proxmox.cluster.config.join.post(**params)
logger.info(f"Node {self.host} joining cluster at {cluster_host}, UPID: {result}")
return {'success': True, 'upid': result}
except Exception as e:
logger.error(f"Ошибка присоединения {self.host} к кластеру {cluster_host}: {e}")
return {'success': False, 'error': str(e)}
def delete_cluster_node(self, node_name: str) -> Dict:
"""
Удалить ноду из кластера (pvecm delnode).
Вызывается на ДРУГОЙ ноде кластера, не на удаляемой.
Удаляемая нода должна быть ВЫКЛЮЧЕНА.
Args:
node_name: Имя ноды для удаления (hostname)
Returns:
Dict с результатом
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
# Proxmox PVE 8 API: DELETE /nodes/{node}
self.proxmox.nodes(node_name).delete()
logger.info(f"Node '{node_name}' deleted from cluster via {self.host}")
return {'success': True, 'node': node_name}
except Exception as e:
logger.warning(f"REST API delete node failed, trying SSH fallback: {e}")
# SSH fallback через pvecm delnode
return self._delete_cluster_node_via_ssh(node_name)
def _delete_cluster_node_via_ssh(self, node_name: str) -> Dict:
"""SSH fallback для удаления ноды через pvecm delnode"""
from app.ssh_client import SSHClient
ssh = SSHClient(
hostname=self.host,
username='root',
password=getattr(self, '_password', None),
)
try:
if not ssh.connect():
return {'success': False, 'error': 'SSH connection failed'}
output, exit_code = ssh.execute(f"pvecm delnode {node_name}", return_exit_code=True)
ssh.close()
# "Could not kill node (error = CS_ERR_NOT_EXIST)" — ignorable per Proxmox docs
if exit_code == 0 or (output and 'CS_ERR_NOT_EXIST' in output):
logger.info(f"Node '{node_name}' removed via pvecm delnode (SSH)")
return {'success': True, 'node': node_name, 'method': 'ssh'}
else:
return {'success': False, 'error': output or 'pvecm delnode failed'}
except Exception as e:
logger.error(f"SSH pvecm delnode failed for {node_name}: {e}")
return {'success': False, 'error': str(e)}
def vzdump_guest(self, node: str, vmid: int, storage: str,
compress: str = 'zstd', mode: str = 'snapshot') -> Dict:
"""
Создать резервную копию VM/LXC через vzdump.
Args:
node: Имя ноды
vmid: ID ВМ или контейнера
storage: Хранилище для бэкапа (должно поддерживать content=backup)
compress: Алгоритм сжатия (zstd, lzo, gzip, 0)
mode: Режим снятия бэкапа (snapshot, suspend, stop)
Returns:
Dict с UPID задачи
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
upid = self.proxmox.nodes(node).vzdump.post(
vmid=vmid,
storage=storage,
compress=compress,
mode=mode,
remove=0, # не удалять старые бэкапы автоматически
)
logger.info(f"vzdump started for vmid {vmid} on {node}, storage {storage}, UPID: {upid}")
return {'success': True, 'upid': upid, 'vmid': vmid}
except Exception as e:
logger.error(f"Ошибка vzdump vmid {vmid} на {node}: {e}")
return {'success': False, 'error': str(e), 'vmid': vmid}
def get_backup_storages(self, node: str) -> List[Dict]:
"""
Получить список хранилищ, поддерживающих content=backup на ноде.
Returns:
Список Dict с полями: storage, type, content, avail, total
"""
if not self.proxmox:
return []
try:
storages = self.proxmox.nodes(node).storage.get(content='backup', enabled=1)
return storages
except Exception as e:
logger.error(f"Ошибка получения backup-хранилищ для {node}: {e}")
return []
def get_vm_rrddata(self, node: str, vmid: int, timeframe: str = "hour") -> Dict:
"""
Получить исторические данные VM для графиков (CPU, Memory, Network, Disk IO)
@@ -2973,7 +3154,7 @@ class ProxmoxClient:
def create_backup(self, node: str, vmid: int, storage: str,
mode: str = "snapshot", compress: str = "zstd",
remove: int = 1, notes: str = None) -> Dict:
remove: int = 1, keep_last: int = None, notes: str = None) -> Dict:
"""
Trigger vzdump backup for a VM/container.
Returns {"success": True, "upid": "..."} or {"success": False, "error": "..."}
@@ -2986,8 +3167,10 @@ class ProxmoxClient:
"storage": storage,
"mode": mode,
"compress": compress,
"remove": remove,
"remove": int(bool(remove)),
}
if keep_last and int(keep_last) > 0:
params["prune-backups"] = f"keep-last={int(keep_last)}"
if notes:
params["notes"] = notes
upid = self.proxmox.nodes(node).vzdump.post(**params)
@@ -3070,6 +3253,17 @@ class ProxmoxClient:
logger.error(f"Error deleting backup {volid}: {e}")
return {"success": False, "error": str(e)}
def get_cluster_backup_jobs(self) -> List[Dict]:
"""Fetch native vzdump backup jobs configured in Proxmox (cluster/backup)"""
if not self.proxmox:
return []
try:
jobs = self.proxmox.cluster.backup.get()
return jobs if isinstance(jobs, list) else []
except Exception as e:
logger.error(f"Error fetching cluster backup jobs: {e}")
return []
def get_proxmox_resources(host: str, user: str = "root@pam",
password: str = None, token_name: str = None,

View File

@@ -240,6 +240,13 @@ def _create_permissions() -> PermissionRegistry:
category="Proxmox Servers",
requires=["server:view"]
))
registry.register(Permission(
resource="cluster", action="manage",
display_name="Manage Cluster Topology",
description="Create clusters, join nodes, eject nodes from cluster",
category="Proxmox Servers",
requires=["server:view"]
))
# VM permissions
registry.register(Permission(
@@ -619,6 +626,7 @@ LEGACY_PERMISSION_MAP = {
"proxmox.servers.add": "server:create",
"proxmox.servers.edit": "server:update",
"proxmox.servers.delete": "server:delete",
"proxmox.cluster.manage": "cluster:manage",
"proxmox.vm.manage": "vm:manage",
"vms.view": "vm:view",
"vms.create": "vm:create",

View File

@@ -156,7 +156,8 @@ async def run_backup_job(job_id: int) -> None:
storage=job.storage,
mode=job.mode,
compress=job.compress,
remove=job.keep_last,
remove=1 if job.keep_last else 0,
keep_last=job.keep_last,
notes=job.notes,
)
if result.get("success"):

View File

@@ -18,10 +18,22 @@ try:
from backend.app.db import SessionLocal
from backend.app.models import TaskQueue, ProxmoxServer, User
from backend.app.proxmox_client import ProxmoxClient
from backend.app.websocket_manager import broadcast_task_update
except ImportError:
from app.db import SessionLocal
from app.models import TaskQueue, ProxmoxServer, User
from app.proxmox_client import ProxmoxClient
from app.websocket_manager import broadcast_task_update
def _ws_broadcast(task: TaskQueue, event: str = "task_update") -> None:
"""Fire-and-forget WS broadcast for a TaskQueue update."""
try:
data = task.to_dict()
data["kind"] = "bulk"
broadcast_task_update(task.user_id, event, data)
except Exception as e:
logger.debug(f"[TASK QUEUE] WS broadcast skipped: {e}")
class TaskQueueService:
@@ -114,6 +126,7 @@ class TaskQueueService:
task.completed_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
logger.info(f"[TASK QUEUE] Task #{task_id} cancelled by user {user_id}")
return True
@@ -220,6 +233,7 @@ class TaskQueueProcessor:
task.started_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
logger.info(f"[TASK QUEUE] Starting task #{task.id}: {task.task_type}")
# Determine action from task type
@@ -276,12 +290,14 @@ class TaskQueueProcessor:
# Update progress
task.results = results
db.commit()
_ws_broadcast(task, "task_update")
# Mark as completed
task.status = 'completed'
task.completed_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
logger.info(f"[TASK QUEUE] Completed task #{task.id}: {task.completed_items} success, {task.failed_items} failed")
except Exception as e:
@@ -291,6 +307,7 @@ class TaskQueueProcessor:
task.error_message = str(e)
task.completed_at = utcnow()
db.commit()
_ws_broadcast(task, "task_update")
except Exception:
pass
finally:

View File

@@ -2303,3 +2303,398 @@ body {
/* Rocky Linux использует тот же зелёный цвет */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 192 192'%3E%3Cpath fill='%2310B981' fill-rule='evenodd' clip-rule='evenodd' d='M186.7 127.7C190.1 117.7 192 107.1 192 96c0-53-43-96-96-96S0 43 0 96c0 26.2 10.5 50 27.6 67.3l97.4-97.4L149 90l37.7 37.7zM169.1 158.2l-44.2-44.2-69.1 69.2C67.9 188.8 81.6 192 96 192c29.3 0 55.5-13.1 73.1-33.8z'/%3E%3C/svg%3E");
}
/* ================================================================
TASK MANAGER — Animations & Component Styles
================================================================ */
/* ── Keyframes ────────────────────────────────────────────────── */
@keyframes task-shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
@keyframes task-pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .5; transform: scale(1.35); }
}
@keyframes task-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
@keyframes task-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes task-drawer-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes task-badge-pop {
0% { transform: scale(0); }
70% { transform: scale(1.3); }
100% { transform: scale(1); }
}
/* ── Status colours ───────────────────────────────────────────── */
.task-status-running { color: #667eea; }
.task-status-pending { color: #f97316; }
.task-status-completed { color: #10b981; }
.task-status-failed { color: #ef4444; }
.task-status-cancelled { color: #6b7280; }
/* ── Status badges ────────────────────────────────────────────── */
.task-badge {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: 3px 10px;
border-radius: var(--radius-full, 9999px);
font-size: .72rem;
font-weight: 600;
letter-spacing: .03em;
white-space: nowrap;
}
.task-badge-running {
background: rgba(102,126,234,.15);
color: #667eea;
border: 1px solid rgba(102,126,234,.3);
}
.task-badge-pending {
background: rgba(249,115,22,.13);
color: #f97316;
border: 1px solid rgba(249,115,22,.3);
}
.task-badge-completed {
background: rgba(16,185,129,.13);
color: #10b981;
border: 1px solid rgba(16,185,129,.3);
}
.task-badge-failed {
background: rgba(239,68,68,.13);
color: #ef4444;
border: 1px solid rgba(239,68,68,.3);
}
.task-badge-cancelled {
background: rgba(107,114,128,.1);
color: #9ca3af;
border: 1px solid rgba(107,114,128,.25);
}
/* ── Progress bar ─────────────────────────────────────────────── */
.task-progress-track {
width: 100%;
height: 6px;
background: var(--bg-surface-hover, rgba(255,255,255,.08));
border-radius: 9999px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
border-radius: 9999px;
transition: width .5s ease;
background: var(--gradient-primary, linear-gradient(90deg, #667eea, #764ba2));
}
.task-progress-fill.running {
background: linear-gradient(90deg, #667eea 0%, #a78bfa 50%, #667eea 100%);
background-size: 200% 100%;
animation: task-shimmer 2s linear infinite;
}
.task-progress-fill.pending {
background: linear-gradient(90deg, #f97316, #fb923c);
animation: task-shimmer 2.5s linear infinite;
background-size: 200% 100%;
}
.task-progress-fill.completed { background: linear-gradient(90deg, #10b981, #34d399); }
.task-progress-fill.failed { background: linear-gradient(90deg, #ef4444, #f87171); }
.task-progress-fill.cancelled { background: #6b7280; }
/* ── Spinning icon for running tasks ──────────────────────────── */
.task-icon-spin {
display: inline-block;
animation: task-spin 1.2s linear infinite;
}
/* ── Error shake ──────────────────────────────────────────────── */
.task-shake {
animation: task-shake .5s ease;
}
/* ── Navbar tasks button & badge ──────────────────────────────── */
.tasks-btn {
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 50%;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), background var(--transition-fast);
}
.tasks-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.tasks-count-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: #ef4444;
color: #fff;
font-size: .62rem;
font-weight: 700;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
animation: task-badge-pop .25s ease;
line-height: 1;
}
.tasks-count-badge.has-active {
background: #667eea;
animation: task-badge-pop .25s ease, task-pulse-dot 2s ease-in-out infinite;
}
/* ── Status pulsing dot ───────────────────────────────────────── */
.task-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.task-dot.running { background: #667eea; animation: task-pulse-dot 1.2s ease-in-out infinite; }
.task-dot.pending { background: #f97316; animation: task-pulse-dot 1.6s ease-in-out infinite; }
.task-dot.completed { background: #10b981; }
.task-dot.failed { background: #ef4444; }
.task-dot.cancelled { background: #6b7280; }
/* ── Task Drawer (slide-in from right) ────────────────────────── */
.task-drawer {
position: fixed;
top: 0;
right: 0;
width: 380px;
max-width: 96vw;
height: 100vh;
background: var(--glass-bg, rgba(20,20,35,.95));
backdrop-filter: blur(20px);
border-left: 1px solid var(--glass-border, rgba(255,255,255,.08));
z-index: 1200;
display: flex;
flex-direction: column;
box-shadow: -8px 0 40px rgba(0,0,0,.45);
animation: task-drawer-in .25s ease;
}
.task-drawer-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.task-drawer-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.task-drawer-close {
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
color: var(--text-muted);
font-size: 1.25rem;
line-height: 1;
transition: color .15s, background .15s;
}
.task-drawer-close:hover { color: var(--text-primary); background: var(--bg-hover); }
.task-drawer-body {
overflow-y: auto;
flex: 1;
padding: .75rem 1rem;
}
.task-drawer-empty {
text-align: center;
padding: 3rem 1.5rem;
color: var(--text-muted);
font-size: .9rem;
}
.task-drawer-footer {
padding: .75rem 1rem;
border-top: 1px solid var(--border-default);
flex-shrink: 0;
}
.task-drawer-footer a {
display: block;
text-align: center;
font-size: .85rem;
color: #667eea;
text-decoration: none;
padding: .5rem;
border-radius: 8px;
transition: background .15s;
}
.task-drawer-footer a:hover { background: rgba(102,126,234,.1); }
/* ── Mini task card (drawer) ──────────────────────────────────── */
.task-mini-card {
background: var(--bg-surface, rgba(255,255,255,.04));
border: 1px solid var(--border-muted, rgba(255,255,255,.06));
border-radius: 10px;
padding: .75rem 1rem;
margin-bottom: .5rem;
transition: border-color .15s;
}
.task-mini-card:hover { border-color: var(--border-default); }
.task-mini-card.running { border-left: 3px solid #667eea; }
.task-mini-card.pending { border-left: 3px solid #f97316; }
.task-mini-card.completed { border-left: 3px solid #10b981; }
.task-mini-card.failed { border-left: 3px solid #ef4444; }
.task-mini-card.cancelled { border-left: 3px solid #6b7280; }
.task-mini-top {
display: flex;
align-items: center;
gap: .5rem;
margin-bottom: .45rem;
}
.task-mini-name {
font-size: .85rem;
font-weight: 500;
color: var(--text-primary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-mini-time {
font-size: .72rem;
color: var(--text-muted);
white-space: nowrap;
}
.task-mini-progress { margin-top: .3rem; }
/* ── Full task card (page) ────────────────────────────────────── */
.task-card {
background: var(--glass-bg, rgba(255,255,255,.04));
border: 1px solid var(--glass-border, rgba(255,255,255,.08));
border-radius: 14px;
padding: 1.25rem 1.5rem;
margin-bottom: .75rem;
transition: border-color .2s, box-shadow .2s;
cursor: pointer;
}
.task-card:hover {
border-color: rgba(102,126,234,.4);
box-shadow: 0 4px 20px rgba(0,0,0,.2);
}
.task-card.running { border-left: 4px solid #667eea; }
.task-card.pending { border-left: 4px solid #f97316; }
.task-card.completed { border-left: 4px solid #10b981; }
.task-card.failed { border-left: 4px solid #ef4444; }
.task-card.cancelled { border-left: 4px solid #6b7280; }
.task-card-header {
display: flex;
align-items: center;
gap: .75rem;
margin-bottom: .75rem;
}
.task-card-title {
flex: 1;
font-weight: 600;
font-size: .95rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-card-meta {
display: flex;
gap: 1.5rem;
font-size: .8rem;
color: var(--text-muted);
margin-top: .6rem;
flex-wrap: wrap;
}
.task-card-meta span { display: flex; align-items: center; gap: .3rem; }
/* ── Trace modal ──────────────────────────────────────────────── */
.task-trace-log {
font-family: 'JetBrains Mono', monospace;
font-size: .78rem;
line-height: 1.6;
color: var(--text-secondary);
background: var(--bg-input, rgba(0,0,0,.25));
border: 1px solid var(--border-muted);
border-radius: 8px;
padding: 1rem;
max-height: 320px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.task-results-table th { font-weight: 600; font-size: .8rem; color: var(--text-muted); }
.task-results-table td { font-size: .82rem; padding: .5rem .75rem; vertical-align: middle; }
.task-result-ok { color: #10b981; }
.task-result-fail { color: #ef4444; }
/* ── Tab bar ──────────────────────────────────────────────────── */
.tasks-tab-bar {
display: flex;
gap: .25rem;
border-bottom: 2px solid var(--border-muted);
margin-bottom: 1.5rem;
overflow-x: auto;
scrollbar-width: none;
}
.tasks-tab-bar::-webkit-scrollbar { display: none; }
.tasks-tab {
background: none;
border: none;
cursor: pointer;
padding: .6rem 1.1rem;
font-size: .85rem;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color .15s, border-color .15s;
white-space: nowrap;
}
.tasks-tab:hover { color: var(--text-primary); }
.tasks-tab.active { color: #667eea; border-bottom-color: #667eea; }
.tasks-tab .tasks-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--bg-surface-hover, rgba(255,255,255,.08));
border-radius: 9999px;
font-size: .7rem;
font-weight: 700;
margin-left: .35rem;
color: var(--text-secondary);
}
.tasks-tab.active .tasks-tab-count {
background: rgba(102,126,234,.2);
color: #667eea;
}

View File

@@ -113,13 +113,45 @@
<!-- =================== TAB: SCHEDULE =================== -->
<div id="pane-jobs" class="d-none">
<!-- Нативные расписания Proxmox -->
<div class="card mb-3" id="proxmoxJobsCard" style="display:none!important">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-muted"><i class="fa-solid fa-server me-1"></i>Расписания из Proxmox <span class="badge bg-secondary ms-1">нативные</span></h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadProxmoxNativeJobs()">
<i class="fa-solid fa-rotate me-1"></i>Обновить
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-secondary"><tr>
<th>ID</th>
<th>VMIDs</th>
<th>{{ t('backup_storage_id') }}</th>
<th>Cron</th>
<th>{{ t('backup_mode') }}</th>
<th>{{ t('status') }}</th>
</tr></thead>
<tbody id="proxmoxJobsTbody">
<tr><td colspan="6" class="text-center py-3 text-muted">{{ t('select_server') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ t('backup_jobs_title') }}</h5>
<button class="btn btn-primary btn-sm" onclick="openAddJobModal()">
<i class="fa-solid fa-plus me-1"></i>
{{ t('backup_add_job') }}
</button>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="loadProxmoxNativeJobs()" id="btnLoadProxmoxJobs" title="Показать расписания, созданные напрямую в Proxmox">
<i class="fa-solid fa-server me-1"></i>Расписания Proxmox
</button>
<button class="btn btn-primary btn-sm" onclick="openAddJobModal()">
<i class="fa-solid fa-plus me-1"></i>
{{ t('backup_add_job') }}
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
@@ -370,31 +402,120 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">{{ t('server') }}</label>
<!-- Сервер и нода -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label fw-semibold">{{ t('server') }}</label>
<select id="ajServer" class="form-select" onchange="onJobServerChange(this.value)">
{% for s in servers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 mb-3">
<label class="form-label">{{ t('node') }}</label>
<div class="col-6">
<label class="form-label fw-semibold">{{ t('node') }}</label>
<select id="ajNode" class="form-select"></select>
</div>
</div>
<hr class="my-3">
<!-- Построитель расписания -->
<div class="mb-3">
<label class="form-label">VMIDs</label>
<input type="text" id="ajVmids" class="form-control" placeholder='100,101,102 или "all"'>
<div class="form-text">{{ t('backup_vmids_hint') }}</div>
<label class="form-label fw-semibold"><i class="fa-solid fa-clock me-1"></i>Расписание</label>
<div class="btn-group w-100 mb-3" role="group">
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedHourly" value="hourly" onchange="onSchedTypeChange('hourly')">
<label class="btn btn-outline-secondary" for="ajSchedHourly"><i class="fa-solid fa-stopwatch me-1"></i>Ежечасно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedDaily" value="daily" onchange="onSchedTypeChange('daily')" checked>
<label class="btn btn-outline-secondary" for="ajSchedDaily"><i class="fa-solid fa-sun me-1"></i>Ежедневно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedWeekly" value="weekly" onchange="onSchedTypeChange('weekly')">
<label class="btn btn-outline-secondary" for="ajSchedWeekly"><i class="fa-solid fa-calendar-week me-1"></i>Еженедельно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedMonthly" value="monthly" onchange="onSchedTypeChange('monthly')">
<label class="btn btn-outline-secondary" for="ajSchedMonthly"><i class="fa-solid fa-calendar-days me-1"></i>Ежемесячно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedCustom" value="custom" onchange="onSchedTypeChange('custom')">
<label class="btn btn-outline-secondary" for="ajSchedCustom"><i class="fa-solid fa-code me-1"></i>Cron</label>
</div>
<!-- Интервал (ежечасно) -->
<div id="schedHourlyOpts" class="d-none mb-3">
<div class="d-flex align-items-center gap-2">
<span class="text-muted">Каждые</span>
<select id="ajHourlyInterval" class="form-select" style="width:180px" onchange="buildCron()">
<option value="15">15 минут</option>
<option value="30">30 минут</option>
<option value="60">1 час</option>
<option value="120">2 часа</option>
<option value="360">6 часов</option>
<option value="720">12 часов</option>
</select>
</div>
</div>
<!-- Время (ежедневно / еженедельно / ежемесячно) -->
<div id="schedTimeRow" class="d-flex align-items-center gap-2 mb-3">
<span class="text-muted">в</span>
<input type="time" id="ajSchedTime" class="form-control" style="width:140px" value="02:00" oninput="buildCron()">
<span class="text-muted">по UTC</span>
</div>
<!-- Дни недели (еженедельно) -->
<div id="schedWeeklyOpts" class="d-none mb-3">
<label class="form-label small text-muted">Дни недели:</label>
<div class="d-flex gap-2 flex-wrap">
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd1" value="1" onchange="buildCron()" checked><label class="form-check-label small fw-semibold ms-1" for="wd1">Пн</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd2" value="2" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd2">Вт</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd3" value="3" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd3">Ср</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd4" value="4" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd4">Чт</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd5" value="5" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd5">Пт</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd6" value="6" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd6">Сб</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd0" value="0" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd0">Вс</label></div>
</div>
</div>
<!-- Число месяца (ежемесячно) -->
<div id="schedMonthlyOpts" class="d-none mb-3">
<div class="d-flex align-items-center gap-2">
<span class="text-muted">Число:</span>
<input type="number" id="ajMonthDay" class="form-control" style="width:90px" value="1" min="1" max="28" oninput="buildCron()">
<span class="text-muted">каждого месяца</span>
</div>
</div>
<!-- Произвольный cron -->
<div id="schedCustomOpts" class="d-none mb-3">
<input type="text" id="ajCronCustom" class="form-control font-monospace" placeholder="0 2 * * *" oninput="buildCron()">
<div class="form-text">{{ t('backup_cron_hint') }}</div>
</div>
<!-- Превью cron -->
<div class="d-flex align-items-center gap-2 p-2 rounded" style="background:var(--bg-surface,rgba(0,0,0,.05))">
<i class="fa-regular fa-calendar-check text-primary"></i>
<span id="ajCronPreview" class="small">Ежедневно в 02:00</span>
<code id="ajCronDisplay" class="ms-auto small text-muted font-monospace">0 2 * * *</code>
</div>
<input type="hidden" id="ajCron" value="0 2 * * *">
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_id') }}</label>
<select id="ajStorage" class="form-select"></select>
<hr class="my-3">
<!-- Хранилище и VMIDs -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label fw-semibold"><i class="fa-solid fa-database me-1"></i>{{ t('backup_storage_id') }}</label>
<select id="ajStorage" class="form-select"></select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">VMIDs</label>
<input type="text" id="ajVmids" class="form-control" placeholder='100,101 или "all"'>
<div class="form-text">{{ t('backup_vmids_hint') }}</div>
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<!-- Режим и сжатие -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label">{{ t('backup_mode') }}</label>
<select id="ajMode" class="form-select">
<option value="snapshot">snapshot</option>
@@ -402,47 +523,57 @@
<option value="stop">stop</option>
</select>
</div>
<div class="col-6 mb-3">
<div class="col-6">
<label class="form-label">{{ t('backup_compress') }}</label>
<select id="ajCompress" class="form-select">
<option value="zstd">zstd</option>
<option value="zstd">zstd (быстро + хорошо)</option>
<option value="gzip">gzip</option>
<option value="lzo">lzo</option>
<option value="0">{{ t('none') }}</option>
</select>
</div>
</div>
<hr class="my-3">
<!-- Хранение копий -->
<div class="mb-3">
<label class="form-label">Cron</label>
<input type="text" id="ajCron" class="form-control font-monospace" placeholder="0 2 * * *">
<div class="form-text">{{ t('backup_cron_hint') }}</div>
<label class="form-label fw-semibold"><i class="fa-solid fa-rotate me-1"></i>Хранение копий</label>
<div class="row g-2">
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-last</label>
<input type="number" id="ajKeepLast" class="form-control" value="3" min="0">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-daily</label>
<input type="number" id="ajKeepDaily" class="form-control" value="7" min="0">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-weekly</label>
<input type="number" id="ajKeepWeekly" class="form-control" value="4" min="0">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-monthly</label>
<input type="number" id="ajKeepMonthly" class="form-control" value="6" min="0">
</div>
</div>
<div class="form-text mt-1">{{ t('backup_keep_hint') }}</div>
</div>
<!-- Заметки + Включено -->
<div class="row">
<div class="col-3 mb-2">
<label class="form-label small">keep-last</label>
<input type="number" id="ajKeepLast" class="form-control form-control-sm" value="3" min="0">
<div class="col-9">
<label class="form-label">{{ t('backup_notes') }}</label>
<input type="text" id="ajNotes" class="form-control" placeholder="{{ t('backup_notes_placeholder') }}">
</div>
<div class="col-3 mb-2">
<label class="form-label small">keep-daily</label>
<input type="number" id="ajKeepDaily" class="form-control form-control-sm" value="7" min="0">
</div>
<div class="col-3 mb-2">
<label class="form-label small">keep-weekly</label>
<input type="number" id="ajKeepWeekly" class="form-control form-control-sm" value="4" min="0">
</div>
<div class="col-3 mb-2">
<label class="form-label small">keep-monthly</label>
<input type="number" id="ajKeepMonthly" class="form-control form-control-sm" value="6" min="0">
<div class="col-3 d-flex align-items-end pb-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ajEnabled" checked>
<label class="form-check-label">{{ t('backup_job_enabled') }}</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_notes') }}</label>
<input type="text" id="ajNotes" class="form-control" placeholder="{{ t('backup_notes_placeholder') }}">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ajEnabled" checked>
<label class="form-check-label">{{ t('backup_job_enabled') }}</label>
</div>
<input type="hidden" id="ajJobId">
</div>
<div class="modal-footer">
@@ -777,6 +908,45 @@ async function loadJobs() {
} catch(e) { console.error(e); }
}
async function loadProxmoxNativeJobs() {
const card = document.getElementById('proxmoxJobsCard');
card.style.removeProperty('display');
const tbody = document.getElementById('proxmoxJobsTbody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-2"><span class="spinner-border spinner-border-sm me-2"></span>Загрузка...</td></tr>';
const servers = serversData || [];
if (!servers.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-3 text-muted">Нет подключённых серверов</td></tr>';
return;
}
const allRows = [];
await Promise.all(servers.map(async srv => {
try {
const r = await (await fetchWithAuth(`/proxmox/api/backups/proxmox-jobs/${srv.id}`)).json();
(r.jobs || []).forEach(j => allRows.push({ srv, job: j }));
} catch (e) { console.warn(`Failed to load Proxmox jobs for server ${srv.name}:`, e); }
}));
if (!allRows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-3 text-muted">Нативных расписаний Proxmox не найдено</td></tr>';
return;
}
tbody.innerHTML = allRows.map(({ srv, job }) => {
const vmids = job.vmid ? String(job.vmid).replace(/,/g, ', ') : '<em class="text-muted">all</em>';
const enabled = job.enabled !== 0 && job.enabled !== '0' && job.enabled !== false;
const statusBadge = enabled
? '<span class="badge bg-success">активно</span>'
: '<span class="badge bg-secondary">отключено</span>';
const mode = job.mode || '-';
return `<tr class="table-light">
<td><span class="badge bg-dark me-1">${srv.name}</span>${job.id || ''}</td>
<td class="font-monospace small">${vmids}</td>
<td>${job.storage || '-'}</td>
<td class="font-monospace small">${job.schedule || '-'}</td>
<td>${mode}</td>
<td>${statusBadge}</td>
</tr>`;
}).join('');
}
async function onJobServerChange(serverId) {
const nodes = await fetchNodes(serverId);
fillNodeSelect('ajNode', nodes);
@@ -793,13 +963,112 @@ async function populateJobStorages(serverId) {
} catch(e) {}
}
// ===== Schedule Builder =====
function onSchedTypeChange(type) {
document.getElementById('schedHourlyOpts').classList.add('d-none');
document.getElementById('schedTimeRow').classList.add('d-none');
document.getElementById('schedWeeklyOpts').classList.add('d-none');
document.getElementById('schedMonthlyOpts').classList.add('d-none');
document.getElementById('schedCustomOpts').classList.add('d-none');
if (type === 'hourly') document.getElementById('schedHourlyOpts').classList.remove('d-none');
else if (type === 'daily') document.getElementById('schedTimeRow').classList.remove('d-none');
else if (type === 'weekly') {
document.getElementById('schedTimeRow').classList.remove('d-none');
document.getElementById('schedWeeklyOpts').classList.remove('d-none');
} else if (type === 'monthly') {
document.getElementById('schedTimeRow').classList.remove('d-none');
document.getElementById('schedMonthlyOpts').classList.remove('d-none');
} else if (type === 'custom') document.getElementById('schedCustomOpts').classList.remove('d-none');
buildCron();
}
function buildCron() {
const type = document.querySelector('input[name="ajSchedType"]:checked')?.value || 'daily';
let cron = '0 2 * * *', preview = '';
if (type === 'hourly') {
const mins = parseInt(document.getElementById('ajHourlyInterval').value);
if (mins < 60) { cron = `*/${mins} * * * *`; preview = `Каждые ${mins} минут`; }
else { const hrs = mins / 60; cron = `0 */${hrs} * * *`; preview = `Каждые ${hrs} ${hrs === 1 ? 'час' : hrs < 5 ? 'часа' : 'часов'}`; }
} else if (type === 'daily') {
const time = document.getElementById('ajSchedTime').value || '02:00';
const [h, m] = time.split(':');
cron = `${parseInt(m)} ${parseInt(h)} * * *`;
preview = `Ежедневно в ${time}`;
} else if (type === 'weekly') {
const time = document.getElementById('ajSchedTime').value || '02:00';
const [h, m] = time.split(':');
const dayNames = { '0': 'Вс', '1': 'Пн', '2': 'Вт', '3': 'Ср', '4': 'Чт', '5': 'Пт', '6': 'Сб' };
const days = ['1','2','3','4','5','6','0'].filter(d => document.getElementById('wd' + d)?.checked);
if (!days.length) { document.getElementById('wd1').checked = true; days.push('1'); }
cron = `${parseInt(m)} ${parseInt(h)} * * ${days.join(',')}`;
preview = `Еженедельно (${days.map(d => dayNames[d]).join(', ')}) в ${time}`;
} else if (type === 'monthly') {
const time = document.getElementById('ajSchedTime').value || '02:00';
const [h, m] = time.split(':');
const day = document.getElementById('ajMonthDay').value || '1';
cron = `${parseInt(m)} ${parseInt(h)} ${day} * *`;
preview = `Ежемесячно ${day}-го в ${time}`;
} else if (type === 'custom') {
cron = (document.getElementById('ajCronCustom').value || '').trim() || '0 2 * * *';
preview = cron;
}
document.getElementById('ajCron').value = cron;
document.getElementById('ajCronDisplay').textContent = cron;
document.getElementById('ajCronPreview').textContent = preview;
}
function parseCronToUI(cron) {
const p = (cron || '').trim().split(/\s+/);
if (p.length !== 5) return { type: 'custom' };
const [min, hour, dom, month, dow] = p;
if (month === '*' && dom === '*' && min.startsWith('*/') && hour === '*' && dow === '*')
return { type: 'hourly', interval: parseInt(min.slice(2)) };
if (month === '*' && dom === '*' && min === '0' && hour.startsWith('*/') && dow === '*')
return { type: 'hourly', interval: parseInt(hour.slice(2)) * 60 };
if (month === '*' && dow === '*' && !/[*\/,]/.test(dom) && !/[*\/]/.test(hour) && !/[*\/]/.test(min))
return { type: 'monthly', time: `${hour.padStart(2,'0')}:${min.padStart(2,'0')}`, day: dom };
if (dom === '*' && month === '*' && dow !== '*' && !/[*\/]/.test(hour) && !/[*\/]/.test(min))
return { type: 'weekly', time: `${hour.padStart(2,'0')}:${min.padStart(2,'0')}`, days: dow.split(',') };
if (dom === '*' && month === '*' && dow === '*' && !/[*\/]/.test(hour) && !/[*\/]/.test(min))
return { type: 'daily', time: `${hour.padStart(2,'0')}:${min.padStart(2,'0')}` };
return { type: 'custom' };
}
function applySchedToUI(info) {
const map = { hourly: 'ajSchedHourly', daily: 'ajSchedDaily', weekly: 'ajSchedWeekly', monthly: 'ajSchedMonthly', custom: 'ajSchedCustom' };
const r = document.getElementById(map[info.type] || 'ajSchedDaily');
if (r) r.checked = true;
if (info.time) document.getElementById('ajSchedTime').value = info.time;
if (info.type === 'hourly' && info.interval) {
const opts = [15, 30, 60, 120, 360, 720];
document.getElementById('ajHourlyInterval').value = opts.reduce((a, b) => Math.abs(b - info.interval) < Math.abs(a - info.interval) ? b : a);
}
if (info.type === 'weekly') {
['0','1','2','3','4','5','6'].forEach(d => {
const cb = document.getElementById('wd' + d);
if (cb) cb.checked = (info.days || []).includes(d);
});
}
if (info.type === 'monthly' && info.day) document.getElementById('ajMonthDay').value = info.day;
if (info.type === 'custom') document.getElementById('ajCronCustom').value = document.getElementById('ajCron').value;
onSchedTypeChange(info.type);
}
async function openAddJobModal() {
document.getElementById('addJobModalTitle').textContent = '{{ t("backup_add_job") }}';
document.getElementById('ajJobId').value = '';
document.getElementById('ajCron').value = '0 2 * * *';
document.getElementById('ajVmids').value = '';
document.getElementById('ajNotes').value = '';
document.getElementById('ajEnabled').checked = true;
document.getElementById('ajKeepLast').value = 3;
document.getElementById('ajKeepDaily').value = 7;
document.getElementById('ajKeepWeekly').value = 4;
document.getElementById('ajKeepMonthly').value = 6;
document.getElementById('ajMode').value = 'snapshot';
document.getElementById('ajCompress').value = 'zstd';
document.getElementById('ajSchedTime').value = '02:00';
document.getElementById('ajSchedDaily').checked = true;
onSchedTypeChange('daily');
const firstServer = serversData[0];
if (firstServer) {
document.getElementById('ajServer').value = firstServer.id;
@@ -825,6 +1094,7 @@ async function openEditJobModal(job) {
document.getElementById('ajKeepMonthly').value = job.keep_monthly;
document.getElementById('ajNotes').value = job.notes || '';
document.getElementById('ajEnabled').checked = job.enabled;
applySchedToUI(parseCronToUI(job.cron_expression));
new bootstrap.Modal(document.getElementById('addJobModal')).show();
}

View File

@@ -578,6 +578,11 @@ body.update-in-progress .layout {
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>
<span>{{ t('nav_logs') }}</span>
</a></li>
<li><a href="/tasks" class="{{ 'active' if request.path.startswith('/tasks') else '' }}" title="Менеджер задач">
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>
<span>Задачи</span>
<span id="sidebarTasksBadge" style="display:none; background:#667eea; color:#fff; font-size:.62rem; font-weight:700; border-radius:9999px; padding:1px 6px; margin-left:auto; line-height:1.5;"></span>
</a></li>
<li><a href="/settings" class="{{ 'active' if request.path.startswith('/settings') else '' }}" title="{{ t('nav_settings') }}">
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>{{ t('nav_settings') }}</span>
@@ -643,6 +648,14 @@ body.update-in-progress .layout {
</svg>
</button>
<!-- Task Manager Button -->
<button id="tasksBtn" class="tasks-btn" onclick="toggleTaskDrawer()" title="Менеджер задач">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
<span id="tasksCountBadge" class="tasks-count-badge" style="display:none;">0</span>
</button>
<!-- Notification Bell -->
<button id="notificationBell" class="notification-bell" onclick="toggleNotificationCenter()">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -676,6 +689,29 @@ body.update-in-progress .layout {
<button onclick="clearAllRead()" class="notification-action-btn">{{ t('clear_all') }}</button>
</div>
</div>
<!-- Task Drawer -->
<div id="taskDrawer" class="task-drawer" style="display:none;">
<div class="task-drawer-header">
<h3><i class="fa-solid fa-list-check" style="color:#667eea; margin-right:.5rem;"></i> Задачи</h3>
<button class="task-drawer-close" onclick="toggleTaskDrawer()">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="task-drawer-body" id="taskDrawerBody">
<div class="task-drawer-empty" id="taskDrawerEmpty">
<i class="fa-solid fa-inbox" style="font-size:1.8rem; display:block; margin-bottom:.5rem; opacity:.4;"></i>
Нет активных задач
</div>
<div id="taskDrawerCards"></div>
</div>
<div class="task-drawer-footer">
<a href="/tasks">
<i class="fa-solid fa-arrow-right" style="margin-right:.4rem;"></i>
Открыть менеджер задач
</a>
</div>
</div>
<!-- Custom Dialog Container -->
<div id="customDialogOverlay" class="custom-dialog-overlay">
@@ -1316,6 +1352,9 @@ body.update-in-progress .layout {
// Load GitHub stars count
loadGitHubStars();
// Init task drawer WS
initTaskDrawerWS();
}
// Add click handlers for sidebar menu items to update page title
@@ -1335,6 +1374,128 @@ body.update-in-progress .layout {
});
});
});
// ================================================================
// Task Drawer — global WebSocket-based live task indicator
// ================================================================
let taskDrawerOpen = false;
let taskDrawerWS = null;
let taskDrawerTasks = {};
let taskDrawerReconnectTimer = null;
let taskDrawerReconnectDelay = 2000;
function toggleTaskDrawer() {
const drawer = document.getElementById('taskDrawer');
taskDrawerOpen = !taskDrawerOpen;
drawer.style.display = taskDrawerOpen ? 'flex' : 'none';
if (taskDrawerOpen) renderTaskDrawer();
}
// Close drawer when clicking outside
document.addEventListener('click', function(e) {
if (!taskDrawerOpen) return;
const drawer = document.getElementById('taskDrawer');
const btn = document.getElementById('tasksBtn');
if (drawer && btn && !drawer.contains(e.target) && !btn.contains(e.target)) {
taskDrawerOpen = false;
drawer.style.display = 'none';
}
});
function normalizeTaskStatus(s) {
const m = { queued:'pending', error:'failed', canceled:'cancelled' };
return m[s] || s || 'pending';
}
function renderTaskDrawer() {
const cards = document.getElementById('taskDrawerCards');
const empty = document.getElementById('taskDrawerEmpty');
const list = Object.values(taskDrawerTasks)
.sort((a,b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 10);
if (list.length === 0) {
cards.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
const icons = { running:'<i class="fa-solid fa-circle-notch" style="animation:task-spin 1.2s linear infinite;"></i>', pending:'<i class="fa-solid fa-clock"></i>', completed:'<i class="fa-solid fa-circle-check"></i>', failed:'<i class="fa-solid fa-circle-xmark"></i>', cancelled:'<i class="fa-solid fa-ban"></i>' };
const labels = { running:'Выполняется', pending:'В очереди', completed:'Завершено', failed:'Ошибка', cancelled:'Отменено' };
cards.innerHTML = list.map(t => {
const st = normalizeTaskStatus(t.status);
const pct = st === 'completed' ? 100 : st === 'failed' || st === 'cancelled' ? 100 :
(t.kind === 'bulk' && t.total_items > 0 ? Math.round(t.processed_items/t.total_items*100) : (st==='running'?60:0));
const desc = t.description || t.action || t.task_type || 'Задача';
const ts = t.created_at ? new Date(t.created_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '';
return `<div class="task-mini-card ${st}">
<div class="task-mini-top">
<span class="task-dot ${st}"></span>
<span class="task-mini-name" title="${desc}">${desc.length>35?desc.slice(0,33)+'…':desc}</span>
<span class="task-mini-time">${ts}</span>
</div>
<div class="task-mini-progress">
<div class="task-progress-track" style="height:4px;">
<div class="task-progress-fill ${st}" style="width:${pct}%;"></div>
</div>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-top:.35rem;">
<span class="task-badge task-badge-${st}" style="font-size:.65rem; padding:2px 7px;">${icons[st]||''} ${labels[st]||st}</span>
${t.kind==='bulk'&&t.total_items>0?`<span style="font-size:.7rem;color:var(--text-muted);">${t.processed_items||0}/${t.total_items}</span>`:''}
</div>
</div>`;
}).join('');
}
function updateTaskBadge() {
const active = Object.values(taskDrawerTasks).filter(t => {
const st = normalizeTaskStatus(t.status);
return st === 'running' || st === 'pending';
}).length;
const badge = document.getElementById('tasksCountBadge');
const sidebar = document.getElementById('sidebarTasksBadge');
if (!badge) return;
if (active > 0) {
badge.textContent = active;
badge.style.display = 'flex';
badge.className = 'tasks-count-badge has-active';
if (sidebar) { sidebar.textContent = active; sidebar.style.display = 'inline-block'; }
} else {
badge.style.display = 'none';
if (sidebar) sidebar.style.display = 'none';
}
}
function initTaskDrawerWS() {
const token = localStorage.getItem('access_token');
if (!token) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
taskDrawerWS = new WebSocket(`${proto}://${location.host}/ws/tasks?token=${encodeURIComponent(token)}`);
taskDrawerWS.onmessage = ({data}) => {
try {
const msg = JSON.parse(data);
if (msg.type === 'task_list') {
taskDrawerTasks = {};
(msg.tasks||[]).forEach(t => { taskDrawerTasks[`${t.kind}_${t.id}`] = t; });
} else if (msg.type === 'task_update' || msg.type === 'task_created') {
if (msg.task) taskDrawerTasks[`${msg.task.kind}_${msg.task.id}`] = msg.task;
} else if (msg.type === 'task_removed') {
if (msg.task) delete taskDrawerTasks[`${msg.task.kind}_${msg.task.id}`];
}
updateTaskBadge();
if (taskDrawerOpen) renderTaskDrawer();
} catch {}
};
taskDrawerWS.onclose = () => {
taskDrawerReconnectTimer = setTimeout(initTaskDrawerWS, taskDrawerReconnectDelay);
taskDrawerReconnectDelay = Math.min(taskDrawerReconnectDelay * 2, 30000);
};
taskDrawerWS.onopen = () => {
taskDrawerReconnectDelay = 2000;
setInterval(() => {
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) taskDrawerWS.send(JSON.stringify({type:'ping'}));
}, 20000);
};
}
</script>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>

View File

@@ -5,8 +5,26 @@
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3><i class="fa-solid fa-computer"></i> {{ t('proxmox_servers') }}</h3>
<div style="display: flex; gap: 0.5rem;" data-permission="proxmox.servers.add">
<button onclick="showAddServerModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_server') }}</button>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div data-permission="proxmox.cluster.manage" style="position: relative;">
<button onclick="toggleClusterDropdown(event)" id="clusterMenuBtn" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-network-wired"></i> Кластер <i class="fa-solid fa-caret-down" style="font-size: 0.75rem; margin-left: 2px;"></i>
</button>
<div id="clusterDropdown" class="cluster-dropdown" style="display: none;">
<button onclick="showCreateClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-circle-plus" style="color: var(--success);"></i> Создать кластер
</button>
<button onclick="showJoinClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру
</button>
<button onclick="showEjectNodeModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду
</button>
</div>
</div>
<div data-permission="proxmox.servers.add">
<button onclick="showAddServerModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_server') }}</button>
</div>
</div>
</div>
@@ -41,6 +59,9 @@
<tr id="server-row-{{ server.id }}" data-server-id="{{ server.id }}" data-name="{{ server.name|lower }}" data-ip="{{ server.ip_address }}" data-node="{{ server.hostname or '' }}" data-vms="0" data-lxc="0" data-status="loading">
<td>
<div style="font-weight: 600; color: var(--text-primary);">{{ server.name }}</div>
{% if server.cluster_name %}
<span class="cluster-badge"><i class="fa-solid fa-network-wired"></i> {{ server.cluster_name }}</span>
{% endif %}
{% if server.description %}
<div style="font-size: 0.75rem; color: var(--text-muted);">{{ server.description }}</div>
{% endif %}
@@ -173,6 +194,52 @@
background: var(--bg-surface-hover);
color: var(--text-muted);
}
.cluster-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: rgba(56, 189, 248, 0.1);
color: var(--info);
border: 1px solid rgba(56, 189, 248, 0.4);
border-radius: 0.375rem;
font-size: 0.7rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
margin-top: 0.25rem;
}
.cluster-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
min-width: 215px;
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
z-index: 200;
overflow: hidden;
}
.cluster-dropdown-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 1rem;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 0.875rem;
transition: background 0.15s;
}
.cluster-dropdown-item:hover {
background: var(--bg-hover);
}
</style>
<!-- Modal для добавления сервера -->
@@ -343,6 +410,245 @@
</div>
</div>
<!-- ==================== Cluster: Create Cluster Modal ==================== -->
<div id="createClusterModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-network-wired" style="color: var(--success);"></i> Создать кластер Proxmox</h3>
<button onclick="closeCreateClusterModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="createClusterForm" onsubmit="submitCreateCluster(event)">
<div style="margin-bottom: 1rem;">
<label>Сервер (нода для создания кластера) *</label>
<select id="createClusterServerId" name="server_id" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите сервер --</option>
{% for s in proxmox_servers %}{% if not s.cluster_name %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.ip_address }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1rem;">
<label>Имя кластера *</label>
<input type="text" id="createClusterName" name="cluster_name" required
placeholder="mycluster" pattern="[a-zA-Z][a-zA-Z0-9\-]{0,14}"
title="Начинается с буквы, буквы/цифры/дефис, макс 15 символов"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
<small style="color: var(--text-muted);">Начинается с буквы, только буквы/цифры/дефис, макс. 15 символов</small>
</div>
<div style="margin-bottom: 1rem;">
<label>Corosync link0 IP <span style="color: var(--text-muted);">(опционально)</span></label>
<input type="text" id="createClusterLink0" name="link0_ip"
placeholder="192.168.1.101 (по умолчанию — IP сервера)"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div style="margin-bottom: 1.5rem;">
<label>Пароль root@pam <span style="color: var(--text-muted);">(если не сохранён в панели)</span></label>
<input type="password" id="createClusterRootpw" name="rootpw"
placeholder="Пароль для кластерных операций"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
{% if proxmox_servers | length <= 2 %}
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: start; gap: 0.75rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning); margin-top: 2px;"></i>
<div style="font-size: 0.875rem; color: var(--text-primary);">
<strong>Кластер из 2 нод</strong><br>
В 2-нодовых кластерах нет чёткого кворума — при отказе одной ноды оставшаяся не сможет запускать VM.
Рекомендуется добавить 3-ю ноду или настроить <strong>QDevice</strong> (<code>pvecm qdevice setup</code>).
Временное восстановление: <code>pvecm expected 1</code>.
</div>
</div>
</div>
{% endif %}
<div id="createClusterStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeCreateClusterModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="createClusterBtn" class="btn" style="background: var(--success); color: var(--text-on-success);">
<i class="fa-solid fa-circle-plus"></i> Создать кластер
</button>
</div>
</form>
</div>
</div>
<!-- ==================== Cluster: Join Cluster Modal (4-step) ==================== -->
<div id="joinClusterModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h3 style="margin: 0;"><i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру</h3>
<small id="joinStepTitle" style="color: var(--text-muted);">Шаг 1 из 4: Выбор нод</small>
</div>
<button onclick="closeJoinClusterModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<!-- Warning: no cluster servers -->
<div id="joinNoClusterWarning" style="display: none; background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
Нет серверов с активным кластером. Сначала <strong>создайте кластер</strong> на одном из серверов.
</div>
<!-- Step 1: Select nodes -->
<div id="joinStep1">
<div style="background: rgba(56, 189, 248, 0.1); border: 1px solid var(--info); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-circle-info" style="color: var(--info);"></i>
Выберите ноду для добавления в кластер и любую существующую ноду кластера.
</div>
<form id="joinStep1Form" onsubmit="submitJoinStep1(event)">
<div style="margin-bottom: 1rem;">
<label>Добавляемая нода (standalone) *</label>
<select id="joinNodeServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите ноду для добавления --</option>
{% for s in proxmox_servers %}{% if not s.cluster_name %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.ip_address }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1.5rem;">
<label>Кластерная нода (источник join-информации) *</label>
<select id="joinClusterServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите ноду кластера --</option>
{% for s in proxmox_servers %}{% if s.cluster_name %}
<option value="{{ s.id }}" data-cluster="{{ s.cluster_name }}">{{ s.name }} (кластер: {{ s.cluster_name }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeJoinClusterModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="joinStep1Btn" class="btn btn-primary">Далее <i class="fa-solid fa-arrow-right"></i></button>
</div>
</form>
</div>
<!-- Step 2: Pre-check results -->
<div id="joinStep2" style="display: none;">
<div id="joinPreCheckResult"></div>
<div id="joinPrepareSection" style="display: none; margin-top: 0.5rem;">
<div style="margin-bottom: 1rem;">
<label>Хранилище для резервных копий *</label>
<select id="joinBackupStorage" style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Загрузка хранилищ... --</option>
</select>
<small style="color: var(--text-muted);">Рекомендуется общее хранилище (NFS, Ceph) — доступное в будущем из кластера.</small>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem;">
<button onclick="showJoinStep(1)" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-arrow-left"></i> Назад
</button>
<button id="joinSkipToStep4Btn" onclick="joinSkipToFinalStep()" class="btn btn-primary" style="display: none;">
Продолжить <i class="fa-solid fa-arrow-right"></i>
</button>
<button id="joinStep2PrepBtn" onclick="submitJoinPrepare()" class="btn" style="display: none; background: var(--warning); color: var(--bg-primary);">
<i class="fa-solid fa-hard-drive"></i> Бэкап и очистка ноды
</button>
</div>
</div>
<!-- Step 3: Backup progress -->
<div id="joinStep3" style="display: none;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<i class="fa-solid fa-hard-drive"></i>
Резервное копирование и очистка гестов перед присоединением к кластеру
</div>
<div id="joinPrepResult"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem;">
<button id="joinPrepContinueBtn" onclick="showJoinStep(4)" class="btn btn-primary" style="display: none;">
Продолжить <i class="fa-solid fa-arrow-right"></i>
</button>
</div>
</div>
<!-- Step 4: Final join -->
<div id="joinStep4" style="display: none;">
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
После присоединения нода синхронизирует конфигурацию с кластером. Операция необратима.
</div>
<form onsubmit="submitJoinFinal(event)">
<div style="margin-bottom: 1rem;">
<label>Пароль root@pam присоединяемой ноды *</label>
<input type="password" id="joinRootpw" required placeholder="root-пароль"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div style="margin-bottom: 1.5rem;">
<label>Corosync link0 IP <span style="color: var(--text-muted);">(опционально)</span></label>
<input type="text" id="joinLink0" placeholder="IP присоединяемой ноды для corosync"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div id="joinFinalStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button"
onclick="showJoinStep(joinState.preCheck && joinState.preCheck.guests_count > 0 ? 3 : 2)"
class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-arrow-left"></i> Назад
</button>
<button type="submit" id="joinFinalBtn" class="btn" style="background: var(--success); color: var(--text-on-success);">
<i class="fa-solid fa-link"></i> Присоединить к кластеру
</button>
</div>
</form>
</div>
</div>
</div>
<!-- ==================== Cluster: Eject Node Modal ==================== -->
<div id="ejectNodeModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду из кластера</h3>
<button onclick="closeEjectNodeModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Внимание!</strong> Исключаемая нода должна быть <strong>полностью выключена</strong> до запуска операции.
После исключения на ней необходимо вручную сбросить конфигурацию кластера.
</div>
<form id="ejectNodeForm" onsubmit="submitEjectNode(event)">
<div style="margin-bottom: 1rem;">
<label>Управляющий сервер (остаётся в кластере) *</label>
<select id="ejectFromServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите кластерный сервер --</option>
{% for s in proxmox_servers %}{% if s.cluster_name %}
<option value="{{ s.id }}" data-cluster="{{ s.cluster_name }}">{{ s.name }} (кластер: {{ s.cluster_name }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1rem;">
<label>Hostname ноды для исключения *</label>
<input type="text" id="ejectNodeName" required placeholder="pve2 (hostname как в pvecm status)"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
<small style="color: var(--text-muted);">Hostname как в <code>pvecm status</code>, не IP-адрес</small>
</div>
<div style="margin-bottom: 1.5rem;">
<label>Пароль root@pam <span style="color: var(--text-muted);">(для SSH fallback)</span></label>
<input type="password" id="ejectRootpw" placeholder="Используется при ошибке REST API"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div id="ejectNodeStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeEjectNodeModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="ejectNodeBtn" class="btn" style="background: var(--danger); color: white;">
<i class="fa-solid fa-right-from-bracket"></i> Исключить ноду
</button>
</div>
</form>
</div>
</div>
<div id="stats-section" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
@@ -2136,6 +2442,464 @@ function closeCreateVMModal() {
const status = document.getElementById('createVMStatus');
if (status) status.style.display = 'none';
}
// ==================== Cluster Dropdown ====================
function toggleClusterDropdown(event) {
event.stopPropagation();
const dropdown = document.getElementById('clusterDropdown');
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
}
function closeClusterDropdown() {
const dropdown = document.getElementById('clusterDropdown');
if (dropdown) dropdown.style.display = 'none';
}
document.addEventListener('click', function(e) {
const btn = document.getElementById('clusterMenuBtn');
const dropdown = document.getElementById('clusterDropdown');
if (dropdown && btn && !btn.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.style.display = 'none';
}
});
// ==================== Create Cluster ====================
function showCreateClusterModal() {
document.getElementById('createClusterModal').style.display = 'flex';
document.getElementById('createClusterStatus').style.display = 'none';
}
function closeCreateClusterModal() {
document.getElementById('createClusterModal').style.display = 'none';
document.getElementById('createClusterForm').reset();
document.getElementById('createClusterStatus').style.display = 'none';
}
async function submitCreateCluster(event) {
event.preventDefault();
const btn = document.getElementById('createClusterBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание...';
btn.disabled = true;
const data = {
server_id: parseInt(document.getElementById('createClusterServerId').value),
cluster_name: document.getElementById('createClusterName').value.trim(),
link0_ip: document.getElementById('createClusterLink0').value.trim() || null,
rootpw: document.getElementById('createClusterRootpw').value || null,
};
const statusEl = document.getElementById('createClusterStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание кластера...';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong>Кластер "${result.cluster_name}" создаётся!</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">Сервер: ${result.server_name}</span>
${result.upid ? `<br><small style="color: var(--text-muted);">UPID: <code>${result.upid}</code></small>` : ''}`;
setTimeout(() => { closeCreateClusterModal(); window.location.reload(); }, 2500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// ==================== Join Cluster (4-step wizard) ====================
let joinState = {};
function showJoinClusterModal() {
joinState = {};
document.getElementById('joinClusterModal').style.display = 'flex';
document.getElementById('joinPreCheckResult').innerHTML = '';
document.getElementById('joinPrepResult').innerHTML = '';
document.getElementById('joinFinalStatus').style.display = 'none';
document.getElementById('joinStep1Form').reset();
showJoinStep(1);
// Warn if no cluster servers available
const clusterSelect = document.getElementById('joinClusterServerId');
const hasCluster = clusterSelect.querySelectorAll('option[value]:not([value=""])').length > 0;
document.getElementById('joinNoClusterWarning').style.display = hasCluster ? 'none' : 'block';
}
function closeJoinClusterModal() {
document.getElementById('joinClusterModal').style.display = 'none';
joinState = {};
}
function showJoinStep(step) {
[1, 2, 3, 4].forEach(s => {
const el = document.getElementById(`joinStep${s}`);
if (el) el.style.display = s === step ? 'block' : 'none';
});
const titles = ['', 'Шаг 1 из 4: Выбор нод', 'Шаг 2 из 4: Проверка ноды', 'Шаг 3 из 4: Подготовка', 'Шаг 4 из 4: Присоединение'];
document.getElementById('joinStepTitle').textContent = titles[step] || `Шаг ${step} из 4`;
}
async function submitJoinStep1(event) {
event.preventDefault();
const nodeServerId = parseInt(document.getElementById('joinNodeServerId').value);
const clusterServerId = parseInt(document.getElementById('joinClusterServerId').value);
if (!nodeServerId || !clusterServerId) {
showToast('error', 'Ошибка', 'Выберите оба сервера'); return;
}
if (nodeServerId === clusterServerId) {
showToast('error', 'Ошибка', 'Нда для добавления и кластерная нода должны быть разными'); return;
}
joinState.nodeServerId = nodeServerId;
joinState.clusterServerId = clusterServerId;
const btn = document.getElementById('joinStep1Btn');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Проверка...';
btn.disabled = true;
try {
const response = await fetchWithAuth(`/proxmox/api/cluster/${nodeServerId}/pre-join-check`);
const data = await response.json();
if (!response.ok) {
showToast('error', 'Ошибка', typeof data.detail === 'string' ? data.detail : 'Ошибка проверки ноды');
return;
}
joinState.preCheck = data;
renderPreCheckResult(data);
showJoinStep(2);
} catch (error) {
showToast('error', 'Ошибка', error.message);
} finally {
btn.innerHTML = 'Далее <i class="fa-solid fa-arrow-right"></i>';
btn.disabled = false;
}
}
function renderPreCheckResult(data) {
const container = document.getElementById('joinPreCheckResult');
const storageOptions = (data.backup_storages || []).map(s =>
`<option value="${s.storage}">${s.storage} (${s.type}${s.avail ? ', свободно: ' + formatBytes(s.avail) : ''})</option>`
).join('');
if (data.guests_count === 0) {
container.innerHTML = `
<div style="background: rgba(34, 197, 94, 0.1); border: 1px solid var(--success); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> Нода чистая!</strong> VM и LXC не обнаружены. Можно сразу присоединяться к кластеру.
</div>
`;
document.getElementById('joinPrepareSection').style.display = 'none';
document.getElementById('joinSkipToStep4Btn').style.display = 'inline-flex';
document.getElementById('joinStep2PrepBtn').style.display = 'none';
} else {
const guestRows = data.guests.map(g => `
<tr>
<td style="padding: 0.4rem 0.75rem;">${g.vmid}</td>
<td style="padding: 0.4rem 0.75rem;">${g.name}</td>
<td style="padding: 0.4rem 0.75rem;">
<span style="background: ${g.type === 'qemu' ? 'var(--purple)' : 'var(--info)'}; color: white;
padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem;">
${g.type === 'qemu' ? 'VM' : 'LXC'}
</span>
</td>
<td style="padding: 0.4rem 0.75rem;">
<span class="status-badge ${g.status === 'running' ? 'status-online' : 'status-offline'}"
style="font-size: 0.7rem; padding: 0.1rem 0.5rem;">${g.status}</span>
</td>
</tr>
`).join('');
container.innerHTML = `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Обнаружено гестов: ${data.guests_count}</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">
При присоединении к кластеру /etc/pve перезапишется и конфиги гестов потеряются.
Необходимо создать бэкапы и удалить всех гестов перед join.
</span>
</div>
<div style="overflow-x: auto; margin-bottom: 1rem;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<thead>
<tr style="background: var(--bg-secondary);">
<th style="padding: 0.4rem 0.75rem; text-align: left;">VMID</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Имя</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Тип</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Статус</th>
</tr>
</thead>
<tbody>${guestRows}</tbody>
</table>
</div>
`;
document.getElementById('joinPrepareSection').style.display = 'block';
if (storageOptions) {
document.getElementById('joinBackupStorage').innerHTML = storageOptions;
} else {
document.getElementById('joinBackupStorage').innerHTML = '<option value="">Нет доступных хранилищ backup</option>';
container.innerHTML += `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-exclamation-circle" style="color: var(--danger);"></i>
<strong> Нет хранилищ для бэкапов.</strong> Добавьте NFS/CIFS/Ceph хранилище с типом backup.
</div>
`;
}
document.getElementById('joinSkipToStep4Btn').style.display = 'none';
document.getElementById('joinStep2PrepBtn').style.display = 'inline-flex';
}
}
function joinSkipToFinalStep() {
showJoinStep(4);
}
async function submitJoinPrepare() {
const storage = document.getElementById('joinBackupStorage').value;
if (!storage) {
showToast('error', 'Ошибка', 'Выберите хранилище для бэкапов');
return;
}
joinState.backupStorage = storage;
const btn = document.getElementById('joinStep2PrepBtn');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание бэкапов...';
btn.disabled = true;
showJoinStep(3);
document.getElementById('joinPrepResult').innerHTML = `
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin" style="font-size: 2rem; margin-bottom: 1rem; display: block;"></i>
<div>Создание резервных копий и очистка ноды...</div>
<div style="font-size: 0.875rem; margin-top: 0.5rem;">Процесс может занять несколько минут</div>
</div>
`;
document.getElementById('joinPrepContinueBtn').style.display = 'none';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/prepare-join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_server_id: joinState.nodeServerId,
backup_storage: storage,
compress: 'zstd',
}),
});
const result = await response.json();
const container = document.getElementById('joinPrepResult');
if (!response.ok) {
container.innerHTML = `
<div style="background: var(--danger-dark); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem;">
<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
<strong> Ошибка:</strong> ${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}
</div>
`;
return;
}
const statusColor = result.ready_for_join ? 'var(--success)' : 'var(--warning)';
let errorsHtml = '';
if (result.errors && result.errors.length > 0) {
const errList = result.errors.map(e => `<li>VMID ${e.vmid} (${e.phase}): ${e.error}</li>`).join('');
errorsHtml = `<ul style="margin-top: 0.5rem; color: var(--danger); font-size: 0.875rem;">${errList}</ul>`;
}
container.innerHTML = `
<div style="background: ${result.ready_for_join ? 'rgba(34, 197, 94, 0.1)' : 'rgba(234, 179, 8, 0.1)'};
border: 1px solid ${statusColor}; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-${result.ready_for_join ? 'circle-check' : 'triangle-exclamation'}" style="color: ${statusColor};"></i>
<strong> ${result.message}</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">
Забэкаплено: ${result.backed_up.length} | Удалено: ${result.deleted.length} | Ошибки: ${result.errors.length}
</span>
${errorsHtml}
</div>
`;
joinState.prepareResult = result;
if (result.ready_for_join) {
document.getElementById('joinPrepContinueBtn').style.display = 'inline-flex';
}
} catch (error) {
document.getElementById('joinPrepResult').innerHTML = `
<div style="background: var(--danger-dark); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem;">
<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}
</div>
`;
} finally {
btn.innerHTML = '<i class="fa-solid fa-hard-drive"></i> Бэкап и очистка';
btn.disabled = false;
}
}
async function submitJoinFinal(event) {
event.preventDefault();
const rootpw = document.getElementById('joinRootpw').value.trim();
if (!rootpw) {
showToast('error', 'Ошибка', 'Введите пароль root@pam для присоединяемой ноды');
return;
}
const link0 = document.getElementById('joinLink0').value.trim() || null;
const btn = document.getElementById('joinFinalBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Присоединение...';
btn.disabled = true;
const statusEl = document.getElementById('joinFinalStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Выполняется join...';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_server_id: joinState.nodeServerId,
cluster_server_id: joinState.clusterServerId,
rootpw: rootpw,
link0: link0,
}),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> ${result.message}</strong>${result.upid ? `<br><small style="color: var(--text-muted);">UPID: <code>${result.upid}</code></small>` : ''}`;
setTimeout(() => { closeJoinClusterModal(); window.location.reload(); }, 3000);
} else if (response.status === 409 && result.detail && result.detail.code === 'GUESTS_FOUND') {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> ${result.detail.message}`;
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// ==================== Eject Node ====================
function showEjectNodeModal() {
document.getElementById('ejectNodeModal').style.display = 'flex';
document.getElementById('ejectNodeStatus').style.display = 'none';
document.getElementById('ejectNodeForm').reset();
}
function closeEjectNodeModal() {
document.getElementById('ejectNodeModal').style.display = 'none';
}
async function submitEjectNode(event) {
event.preventDefault();
const serverId = document.getElementById('ejectFromServerId').value;
const nodeName = document.getElementById('ejectNodeName').value.trim();
const rootpw = document.getElementById('ejectRootpw').value.trim() || null;
if (!serverId || !nodeName) {
showToast('error', 'Ошибка', 'Выберите сервер и введите имя ноды');
return;
}
const confirmed = await showDeleteConfirm(
`Вы уверены, что хотите исключить ноду "${nodeName}" из кластера?\n\nНода должна быть ВЫКЛЮЧЕНА перед выполнением этой операции.`,
'Исключить ноду'
);
if (!confirmed) return;
const btn = document.getElementById('ejectNodeBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Исключение...';
btn.disabled = true;
const statusEl = document.getElementById('ejectNodeStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Удаление ноды из кластера...';
try {
const response = await fetchWithAuth(`/proxmox/api/cluster/${serverId}/eject-node`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_name: nodeName, rootpw: rootpw, clear_panel_entry: true }),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> ${result.message}</strong>
<br><span style="font-size: 0.875rem; color: var(--text-muted);">Метод: ${result.method}</span>`;
setTimeout(() => { closeEjectNodeModal(); window.location.reload(); }, 2500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,522 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header" style="margin-bottom: 1.5rem;">
<div style="display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
<div>
<h1 style="font-size:1.5rem; font-weight:700; margin:0; color:var(--text-primary);">
<i class="fa-solid fa-list-check" style="color:#667eea; margin-right:.5rem;"></i>
Менеджер задач
</h1>
<p style="margin:.25rem 0 0; color:var(--text-muted); font-size:.87rem;">
Все активные и завершённые задачи в реальном времени
</p>
</div>
<div style="margin-left:auto; display:flex; gap:.75rem; align-items:center;">
<div id="wsStatus" style="display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--text-muted);">
<span class="task-dot" id="wsDot" style="background:#6b7280;"></span>
<span id="wsStatusText">Подключение...</span>
</div>
<button class="btn btn-sm" style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary); border-radius:8px; padding:.35rem .85rem;"
onclick="TaskManager.refresh()" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
</div>
<!-- Tab bar -->
<div class="tasks-tab-bar">
<button class="tasks-tab active" data-filter="all" onclick="TaskManager.setFilter('all', this)">
Все <span class="tasks-tab-count" id="cnt-all">0</span>
</button>
<button class="tasks-tab" data-filter="running" onclick="TaskManager.setFilter('running', this)">
<span class="task-dot running" style="margin-right:.3rem;"></span>
Выполняется <span class="tasks-tab-count" id="cnt-running">0</span>
</button>
<button class="tasks-tab" data-filter="pending" onclick="TaskManager.setFilter('pending', this)">
<span class="task-dot pending" style="margin-right:.3rem;"></span>
В очереди <span class="tasks-tab-count" id="cnt-pending">0</span>
</button>
<button class="tasks-tab" data-filter="completed" onclick="TaskManager.setFilter('completed', this)">
<span class="task-dot completed" style="margin-right:.3rem;"></span>
Завершено <span class="tasks-tab-count" id="cnt-completed">0</span>
</button>
<button class="tasks-tab" data-filter="failed" onclick="TaskManager.setFilter('failed', this)">
<span class="task-dot failed" style="margin-right:.3rem;"></span>
Ошибка <span class="tasks-tab-count" id="cnt-failed">0</span>
</button>
<button class="tasks-tab" data-filter="cancelled" onclick="TaskManager.setFilter('cancelled', this)">
<span class="task-dot cancelled" style="margin-right:.3rem;"></span>
Отменено <span class="tasks-tab-count" id="cnt-cancelled">0</span>
</button>
</div>
<!-- Task list -->
<div id="taskList">
<div class="task-empty-state" id="taskEmpty" style="text-align:center; padding:4rem 2rem; color:var(--text-muted); display:none;">
<i class="fa-solid fa-inbox" style="font-size:2.5rem; margin-bottom:1rem; display:block; opacity:.35;"></i>
<p style="font-size:.95rem;">Задачи не найдены</p>
</div>
<div id="taskCards"></div>
</div>
<!-- Trace Modal -->
<div class="modal fade" id="traceModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content" style="background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:16px;">
<div class="modal-header" style="border-bottom:1px solid var(--border-default); padding:1.25rem 1.5rem;">
<div>
<h5 class="modal-title" id="traceModalTitle" style="font-weight:700; color:var(--text-primary); margin:0;"></h5>
<div id="traceModalMeta" style="font-size:.78rem; color:var(--text-muted); margin-top:.25rem;"></div>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body" style="padding:0;">
<!-- Status bar -->
<div id="traceStatusBar" style="padding:.75rem 1.5rem; border-bottom:1px solid var(--border-muted); display:flex; align-items:center; gap:.75rem;">
<span id="traceStatusBadge"></span>
<div class="task-progress-track" style="flex:1;">
<div class="task-progress-fill" id="traceProgressFill" style="width:0%;"></div>
</div>
<span id="traceProgressPct" style="font-size:.78rem; color:var(--text-muted); min-width:35px; text-align:right;">0%</span>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs" style="padding:0 1.5rem; border-bottom:1px solid var(--border-muted); margin:0;">
<li class="nav-item">
<button class="nav-link active" id="tabResultsBtn" onclick="TaskManager.switchTraceTab('results')"
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
<i class="fa-solid fa-table-list" style="margin-right:.4rem;"></i> Результаты
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="tabLogBtn" onclick="TaskManager.switchTraceTab('log')"
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
<i class="fa-solid fa-terminal" style="margin-right:.4rem;"></i> Лог
</button>
</li>
</ul>
<!-- Results tab -->
<div id="tabResults" style="padding:1.25rem 1.5rem;">
<div id="traceResultsContent">
<div style="text-align:center; padding:2rem; color:var(--text-muted);">Нет данных</div>
</div>
</div>
<!-- Log tab -->
<div id="tabLog" style="padding:1.25rem 1.5rem; display:none;">
<div class="task-trace-log" id="traceLogContent">Загрузка...</div>
</div>
</div>
<div class="modal-footer" style="border-top:1px solid var(--border-default); padding:1rem 1.5rem;">
<button id="traceCancelBtn" class="btn btn-sm btn-danger" onclick="TaskManager.cancelTask()" style="display:none;">
<i class="fa-solid fa-ban"></i> Отменить задачу
</button>
<button type="button" class="btn btn-sm" data-bs-dismiss="modal"
style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary);">
Закрыть
</button>
</div>
</div>
</div>
</div>
<script>
const TaskManager = (() => {
let ws = null;
let tasks = {}; // id -> task object
let currentFilter = 'all';
let openTaskId = null;
let openTaskKind = null; // 'bulk' | 'proxmox'
let traceTab = 'results';
let reconnectTimer = null;
let heartbeatTimer = null;
let reconnectDelay = 2000;
// ── Status helpers ─────────────────────────────────────────────
const STATUS_LABEL = {
running: 'Выполняется', pending: 'В очереди', queued: 'В очереди',
completed: 'Завершено', failed: 'Ошибка', error: 'Ошибка',
cancelled: 'Отменено', canceled: 'Отменено'
};
const STATUS_ICON = {
running: '<i class="fa-solid fa-circle-notch task-icon-spin"></i>',
pending: '<i class="fa-solid fa-clock"></i>',
queued: '<i class="fa-solid fa-clock"></i>',
completed: '<i class="fa-solid fa-circle-check"></i>',
failed: '<i class="fa-solid fa-circle-xmark"></i>',
error: '<i class="fa-solid fa-circle-xmark"></i>',
cancelled: '<i class="fa-solid fa-ban"></i>',
canceled: '<i class="fa-solid fa-ban"></i>',
};
function normalizeStatus(s) {
const map = { queued: 'pending', error: 'failed', canceled: 'cancelled' };
return map[s] || s || 'pending';
}
function progressPct(task) {
const s = normalizeStatus(task.status);
if (s === 'completed') return 100;
if (s === 'failed' || s === 'cancelled') return 100;
if (task.kind === 'bulk' && task.total_items > 0) {
return Math.round((task.processed_items / task.total_items) * 100);
}
if (s === 'running') return null; // indeterminate
return 0;
}
function escH(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function fmtTime(iso) {
if (!iso) return '—';
try { return new Date(iso).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' }); }
catch { return iso; }
}
function elapsed(start, end) {
if (!start) return '';
const a = new Date(start), b = end ? new Date(end) : new Date();
const s = Math.floor((b - a) / 1000);
if (s < 60) return `${s}с`;
if (s < 3600) return `${Math.floor(s/60)}м ${s%60}с`;
return `${Math.floor(s/3600)}ч ${Math.floor((s%3600)/60)}м`;
}
// ── Build card HTML ────────────────────────────────────────────
function buildCard(task) {
const st = normalizeStatus(task.status);
const pct = progressPct(task);
const indeterminate = (pct === null);
const fillPct = indeterminate ? 60 : pct;
const desc = task.description || task.action || task.task_type || 'Задача';
const meta = [];
if (task.kind === 'bulk') {
if (task.total_items > 0) meta.push(`<span><i class="fa-solid fa-layer-group"></i> ${task.processed_items||0} / ${task.total_items} объектов</span>`);
} else {
if (task.vmid) meta.push(`<span><i class="fa-solid fa-server"></i> VM ${task.vmid}</span>`);
if (task.node) meta.push(`<span><i class="fa-solid fa-network-wired"></i> ${escH(task.node)}</span>`);
}
meta.push(`<span><i class="fa-regular fa-clock"></i> ${fmtTime(task.created_at)}</span>`);
if (task.completed_at || task.updated_at) meta.push(`<span><i class="fa-solid fa-hourglass-end"></i> ${elapsed(task.created_at, task.completed_at || task.updated_at)}</span>`);
const cancelBtn = (st === 'pending' && task.kind === 'bulk') ?
`<button class="btn btn-sm" onclick="event.stopPropagation(); TaskManager.cancelTaskById('${task.kind}','${task.id}')"
style="background:rgba(239,68,68,.12); color:#ef4444; border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:3px 10px; font-size:.75rem;">
<i class="fa-solid fa-ban"></i> Отменить
</button>` : '';
return `<div class="task-card ${st}" data-id="${task.id}" data-kind="${task.kind}" onclick="TaskManager.openTrace('${task.kind}','${task.id}')">
<div class="task-card-header">
<span class="task-dot ${st}"></span>
<span class="task-card-title">${escH(desc)}</span>
<span class="task-badge task-badge-${st}">
${STATUS_ICON[st] || ''} ${STATUS_LABEL[st] || st}
</span>
${cancelBtn}
</div>
<div class="task-progress-track">
<div class="task-progress-fill ${st}" style="width:${fillPct}%;"></div>
</div>
<div class="task-card-meta">
${meta.join('')}
</div>
</div>`;
}
// ── Render all cards ───────────────────────────────────────────
function render() {
const counts = { all:0, running:0, pending:0, completed:0, failed:0, cancelled:0 };
const filtered = [];
for (const [, task] of Object.entries(tasks)) {
const st = normalizeStatus(task.status);
counts.all++;
counts[st] = (counts[st] || 0) + 1;
if (currentFilter === 'all' || st === currentFilter) filtered.push(task);
}
// Sort: running first, then pending, then by date desc
const order = ['running','pending','failed','cancelled','completed'];
filtered.sort((a,b) => {
const oa = order.indexOf(normalizeStatus(a.status));
const ob = order.indexOf(normalizeStatus(b.status));
if (oa !== ob) return oa - ob;
return new Date(b.created_at) - new Date(a.created_at);
});
document.getElementById('cnt-all').textContent = counts.all;
document.getElementById('cnt-running').textContent = counts.running || 0;
document.getElementById('cnt-pending').textContent = counts.pending || 0;
document.getElementById('cnt-completed').textContent = counts.completed || 0;
document.getElementById('cnt-failed').textContent = counts.failed || 0;
document.getElementById('cnt-cancelled').textContent = counts.cancelled || 0;
const cardsEl = document.getElementById('taskCards');
const emptyEl = document.getElementById('taskEmpty');
if (filtered.length === 0) {
cardsEl.innerHTML = '';
emptyEl.style.display = 'flex';
emptyEl.style.flexDirection = 'column';
emptyEl.style.alignItems = 'center';
} else {
emptyEl.style.display = 'none';
cardsEl.innerHTML = filtered.map(buildCard).join('');
}
// Update open trace if modal is visible
const modal = document.getElementById('traceModal');
if (modal.classList.contains('show') && openTaskId) {
const t = findTask(openTaskKind, openTaskId);
if (t) refreshTraceModal(t);
}
}
function findTask(kind, id) {
const key = `${kind}_${id}`;
return tasks[key] || null;
}
// ── WebSocket ──────────────────────────────────────────────────
function connect() {
const token = localStorage.getItem('access_token');
if (!token) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws/tasks?token=${encodeURIComponent(token)}`;
ws = new WebSocket(url);
setWsStatus('connecting');
ws.onopen = () => {
setWsStatus('connected');
reconnectDelay = 2000;
// heartbeat
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type:'ping'}));
}, 20000);
};
ws.onmessage = ({data}) => {
try {
const msg = JSON.parse(data);
handleMessage(msg);
} catch {}
};
ws.onerror = () => {};
ws.onclose = () => {
clearInterval(heartbeatTimer);
setWsStatus('disconnected');
reconnectTimer = setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
}
function handleMessage(msg) {
if (msg.type === 'task_list') {
tasks = {};
(msg.tasks || []).forEach(t => { tasks[`${t.kind}_${t.id}`] = t; });
render();
} else if (msg.type === 'task_update' || msg.type === 'task_created') {
const t = msg.task;
if (t) { tasks[`${t.kind}_${t.id}`] = t; render(); }
} else if (msg.type === 'task_removed') {
const t = msg.task;
if (t) { delete tasks[`${t.kind}_${t.id}`]; render(); }
} else if (msg.type === 'pong') { /* ok */ }
}
function setWsStatus(status) {
const dot = document.getElementById('wsDot');
const txt = document.getElementById('wsStatusText');
const colors = { connected:'#10b981', connecting:'#f97316', disconnected:'#ef4444' };
const labels = { connected:'Подключено', connecting:'Подключение...', disconnected:'Отключено' };
if (dot) dot.style.background = colors[status];
if (txt) txt.textContent = labels[status];
}
// ── Trace Modal ────────────────────────────────────────────────
function openTrace(kind, id) {
openTaskId = id;
openTaskKind = kind;
const task = findTask(kind, id);
if (!task) return;
refreshTraceModal(task);
const modal = new bootstrap.Modal(document.getElementById('traceModal'));
modal.show();
// If proxmox task and running, fetch fresh log
if (kind === 'proxmox') loadProxmoxLog(id);
}
function refreshTraceModal(task) {
const st = normalizeStatus(task.status);
const desc = task.description || task.action || task.task_type || 'Задача';
document.getElementById('traceModalTitle').textContent = desc;
document.getElementById('traceModalMeta').innerHTML =
`Создана: ${fmtTime(task.created_at)}` +
(task.completed_at ? ` &nbsp;·&nbsp; Завершена: ${fmtTime(task.completed_at)}` : '') +
` &nbsp;·&nbsp; Время: ${elapsed(task.created_at, task.completed_at)}`;
// Status badge
document.getElementById('traceStatusBadge').innerHTML =
`<span class="task-badge task-badge-${st}">${STATUS_ICON[st]||''} ${STATUS_LABEL[st]||st}</span>`;
// Progress
const pct = progressPct(task);
const fill = document.getElementById('traceProgressFill');
fill.className = `task-progress-fill ${st}`;
fill.style.width = `${pct === null ? 60 : pct}%`;
document.getElementById('traceProgressPct').textContent = pct === null ? '…' : `${pct}%`;
// Cancel button
const cancelBtn = document.getElementById('traceCancelBtn');
cancelBtn.style.display = (st === 'pending' && task.kind === 'bulk') ? 'inline-flex' : 'none';
// Results tab
renderTraceResults(task);
// Log tab
if (task.kind === 'proxmox') {
document.getElementById('traceLogContent').textContent = task.log_text || '(лог пуст)';
} else {
renderBulkLog(task);
}
}
function renderTraceResults(task) {
const el = document.getElementById('traceResultsContent');
const st = normalizeStatus(task.status);
if (task.kind === 'proxmox') {
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
<thead><tr>
<th>Параметр</th><th>Значение</th>
</tr></thead>
<tbody>
<tr><td>UPID</td><td style="font-family:monospace; font-size:.78rem;">${escH(task.upid)}</td></tr>
<tr><td>Узел</td><td>${escH(task.node)||'—'}</td></tr>
<tr><td>VM ID</td><td>${task.vmid||'—'}</td></tr>
<tr><td>Тип</td><td>${escH(task.vm_type)||'—'}</td></tr>
<tr><td>Действие</td><td>${escH(task.action)}</td></tr>
<tr><td>Статус выхода</td><td>${task.exit_status ? `<span class="${task.exit_status==='OK'?'task-result-ok':'task-result-fail'}">${escH(task.exit_status)}</span>` : '—'}</td></tr>
</tbody>
</table>`;
} else if (task.kind === 'bulk') {
const items = task.processed_items || 0;
const total = task.total_items || 0;
const results = task.results || [];
if (results.length === 0) {
el.innerHTML = `<div style="padding:1.5rem; text-align:center; color:var(--text-muted);">
${total > 0 ? `Обработано ${items} из ${total} объектов` : 'Нет результатов'}
</div>`;
return;
}
const rows = results.map(r => {
const ok = r.success || r.status === 'success' || r.status === 'completed';
const icon = ok ? '<i class="fa-solid fa-circle-check task-result-ok"></i>' : '<i class="fa-solid fa-circle-xmark task-result-fail"></i>';
return `<tr>
<td>${icon}</td>
<td>${escH(r.vm_name || r.name || r.id || '—')}</td>
<td>${escH(r.message || r.error || (ok ? 'OK' : 'Ошибка'))}</td>
</tr>`;
}).join('');
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
<thead><tr><th width="30"></th><th>Объект</th><th>Сообщение</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
}
function renderBulkLog(task) {
const items = task.results || [];
const lines = items.map(r => {
const ok = r.success || r.status === 'success' || r.status === 'completed';
const ts = r.timestamp ? new Date(r.timestamp).toLocaleTimeString('ru-RU') : '';
return `${ts ? '['+ts+'] ' : ''}${ok?'✓':'✗'} ${r.vm_name||r.name||''}: ${r.message||r.error||''}`;
}).join('\n');
document.getElementById('traceLogContent').textContent = lines || '(пусто)';
}
function switchTraceTab(tab) {
traceTab = tab;
document.getElementById('tabResults').style.display = tab === 'results' ? '' : 'none';
document.getElementById('tabLog').style.display = tab === 'log' ? '' : 'none';
document.getElementById('tabResultsBtn').classList.toggle('active', tab === 'results');
document.getElementById('tabLogBtn').classList.toggle('active', tab === 'log');
// Style active tab
['tabResultsBtn','tabLogBtn'].forEach(id => {
const btn = document.getElementById(id);
const isActive = btn.classList.contains('active');
btn.style.color = isActive ? '#667eea' : 'var(--text-muted)';
btn.style.borderBottom = isActive ? '2px solid #667eea' : '2px solid transparent';
});
}
async function loadProxmoxLog(id) {
try {
const resp = await fetchWithAuth(`/proxmox/api/proxmox-tasks/${id}/log`);
if (resp && resp.ok) {
const data = await resp.json();
document.getElementById('traceLogContent').textContent = data.log_text || '(лог пуст)';
}
} catch {}
}
async function cancelTaskById(kind, id) {
if (kind !== 'bulk') return;
const ok = await showConfirm('Отменить эту задачу?', 'Подтверждение');
if (!ok) return;
try {
const resp = await fetchWithAuth(`/proxmox/api/tasks/${id}/cancel`, { method:'POST' });
if (resp && resp.ok) showToast('success', 'Готово', 'Задача отменена');
else showToast('error', 'Ошибка', 'Не удалось отменить');
} catch { showToast('error', 'Ошибка', 'Ошибка сети'); }
}
async function cancelTask() {
if (openTaskKind && openTaskId) {
await cancelTaskById(openTaskKind, openTaskId);
bootstrap.Modal.getInstance(document.getElementById('traceModal'))?.hide();
}
}
async function refresh() {
try {
const resp = await fetchWithAuth('/proxmox/api/all-tasks');
if (resp && resp.ok) {
const data = await resp.json();
const list = Array.isArray(data) ? data : (data.tasks || []);
tasks = {};
list.forEach(t => { tasks[`${t.kind}_${t.id}`] = t; });
render();
}
} catch {}
}
function setFilter(f, btn) {
currentFilter = f;
document.querySelectorAll('.tasks-tab').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
render();
}
// ── Init ───────────────────────────────────────────────────────
function init() {
connect();
refresh();
}
return { init, refresh, setFilter, openTrace, switchTraceTab, cancelTask, cancelTaskById };
})();
document.addEventListener('DOMContentLoaded', () => {
// Wait for token to be available
const check = () => {
if (localStorage.getItem('access_token')) TaskManager.init();
else setTimeout(check, 300);
};
check();
});
// Style active tab on load
document.addEventListener('DOMContentLoaded', () => {
TaskManager.switchTraceTab('results');
});
</script>
{% endblock %}

View File

@@ -966,6 +966,7 @@ function restoreFiltersFromUrl() {
// Bulk selection state
let selectedVMs = new Set(); // Set of "server_id-vmid" keys
let currentPageVMsCache = []; // sorted page items cache used by getCurrentPageVMs
let currentTaskId = null;
let taskPollInterval = null;
@@ -1002,7 +1003,7 @@ function toggleSelectAll(event) {
// checked уже изменён браузером к моменту вызова onclick
const isChecked = checkbox.checked;
console.log('toggleSelectAll:', { isChecked, selectedVMsSize: selectedVMs.size });
debugLog('toggleSelectAll:', { isChecked, selectedVMsSize: selectedVMs.size });
const checkboxes = document.querySelectorAll('.vm-row-checkbox');
@@ -1025,7 +1026,7 @@ function toggleSelectAll(event) {
});
}
console.log('After toggleSelectAll:', { selectedVMsSize: selectedVMs.size, isChecked });
debugLog('After toggleSelectAll:', { selectedVMsSize: selectedVMs.size, isChecked });
updateBulkActionsBar();
}
@@ -1091,9 +1092,7 @@ function updateSelectAllCheckbox() {
}
function getCurrentPageVMs() {
const start = (currentPageNum - 1) * pageSizeNum;
const end = start + pageSizeNum;
return filteredVMs.slice(start, end);
return currentPageVMsCache;
}
function getSelectedVMsData() {
@@ -1162,9 +1161,9 @@ async function bulkAction(action) {
bulkOperationAction = action;
// Show pending status with spinner for all selected items
console.log('[bulkAction] Setting pending status for items:', items);
debugLog('[bulkAction] Setting pending status for items:', items);
items.forEach(item => {
console.log(`[bulkAction] Calling updateVMStatusUI for server_id=${item.server_id}, vmid=${item.vmid}, action=${action}`);
debugLog(`[bulkAction] Calling updateVMStatusUI for server_id=${item.server_id}, vmid=${item.vmid}, action=${action}`);
updateVMStatusUI(item.server_id, item.vmid, 'pending', action);
});
@@ -1439,6 +1438,7 @@ function renderTable() {
const start = (currentPageNum - 1) * pageSizeNum;
const end = start + pageSizeNum;
const pageItems = sorted.slice(start, end);
currentPageVMsCache = pageItems;
if (pageItems.length === 0) {
tbody.innerHTML = `
@@ -1816,7 +1816,7 @@ async function vmAction(action, serverId, vmid, type, node, vmName) {
function updateVMStatusUI(serverId, vmid, status, action = null) {
const elementId = `vm-status-${serverId}-${vmid}`;
const statusEl = document.getElementById(elementId);
console.log(`[updateVMStatusUI] Looking for element: ${elementId}, found: ${!!statusEl}, status: ${status}, action: ${action}`);
debugLog(`[updateVMStatusUI] Looking for element: ${elementId}, found: ${!!statusEl}, status: ${status}, action: ${action}`);
if (!statusEl) {
console.warn(`[updateVMStatusUI] Element not found: ${elementId}`);
return;
@@ -1837,8 +1837,7 @@ function updateVMStatusUI(serverId, vmid, status, action = null) {
action === 'restart' ? '{{ t("restarting") }}' :
action === 'shutdown' ? '{{ t("shutting_down") }}' : '...';
statusEl.innerHTML = `<span class="status-spinner"></span> ${actionLabel}`;
console.log(`[updateVMStatusUI] Set pending with spinner: ${statusEl.innerHTML}`);
} else if (status === 'running') {
debugLog(`[updateVMStatusUI] Set pending with spinner: ${statusEl.innerHTML}`); } else if (status === 'running') {
statusEl.textContent = t('active');
} else {
statusEl.textContent = t('inactive');

View File

@@ -0,0 +1,99 @@
"""
WebSocket Connection Manager for real-time task updates.
Maintains per-user WebSocket connections and broadcasts task events.
"""
import asyncio
import json
from typing import Dict, Set
from fastapi import WebSocket
from loguru import logger
class ConnectionManager:
"""Manages WebSocket connections grouped by user_id."""
def __init__(self):
# user_id -> set of active WebSocket connections
self._connections: Dict[int, Set[WebSocket]] = {}
async def connect(self, websocket: WebSocket, user_id: int) -> None:
await websocket.accept()
if user_id not in self._connections:
self._connections[user_id] = set()
self._connections[user_id].add(websocket)
logger.debug(f"[WS TASKS] User {user_id} connected ({len(self._connections[user_id])} socket(s))")
def disconnect(self, websocket: WebSocket, user_id: int) -> None:
if user_id in self._connections:
self._connections[user_id].discard(websocket)
if not self._connections[user_id]:
del self._connections[user_id]
logger.debug(f"[WS TASKS] User {user_id} disconnected")
@property
def active_user_count(self) -> int:
return len(self._connections)
async def send_to_user(self, user_id: int, data: dict) -> None:
"""Send a JSON message to all WebSocket connections of a specific user."""
connections = self._connections.get(user_id)
if not connections:
return
message = json.dumps(data, default=str)
dead: Set[WebSocket] = set()
for ws in list(connections):
try:
await ws.send_text(message)
except Exception:
dead.add(ws)
for ws in dead:
self._connections[user_id].discard(ws)
if not self._connections.get(user_id):
self._connections.pop(user_id, None)
async def broadcast(self, data: dict) -> None:
"""Send a JSON message to all connected users."""
message = json.dumps(data, default=str)
dead_pairs = []
for user_id, connections in list(self._connections.items()):
for ws in list(connections):
try:
await ws.send_text(message)
except Exception:
dead_pairs.append((user_id, ws))
for user_id, ws in dead_pairs:
if user_id in self._connections:
self._connections[user_id].discard(ws)
# ──────────────────────────────────────────────
# Module-level singleton used across the app
# ──────────────────────────────────────────────
ws_manager = ConnectionManager()
def run_async_safe(coro) -> None:
"""
Schedule an async coroutine from a synchronous background thread.
Used by APScheduler jobs to push WS updates without blocking.
"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.run_coroutine_threadsafe(coro, loop)
else:
loop.run_until_complete(coro)
except RuntimeError:
# No event loop yet (e.g. during startup); silently skip
pass
def broadcast_task_update(user_id: int, event_type: str, task_data: dict) -> None:
"""
Fire-and-forget WebSocket broadcast from a sync context (e.g. APScheduler).
Wraps `ws_manager.send_to_user` and schedules it on the running asyncio loop.
"""
payload = {"type": event_type, "task": task_data}
run_async_safe(ws_manager.send_to_user(user_id, payload))

View File

@@ -16,20 +16,22 @@ def utcnow() -> datetime:
try:
from backend.app.db import SessionLocal
from backend.app.models import User, ProxmoxServer, PanelSettings, VMInstance, IPAMAllocation, Notification
from backend.app.models import User, ProxmoxServer, PanelSettings, VMInstance, IPAMAllocation, Notification, ProxmoxTask
from backend.app.services.notification_service import NotificationService
from backend.app.logging_service import LoggingService
from backend.app.proxmox_client import ProxmoxClient
from backend.app.schemas import NotificationCreate
from backend.app.i18n import t
from backend.app.websocket_manager import broadcast_task_update
except ImportError:
from app.db import SessionLocal
from app.models import User, ProxmoxServer, PanelSettings, VMInstance, IPAMAllocation, Notification
from app.models import User, ProxmoxServer, PanelSettings, VMInstance, IPAMAllocation, Notification, ProxmoxTask
from app.services.notification_service import NotificationService
from app.logging_service import LoggingService
from app.proxmox_client import ProxmoxClient
from app.schemas import NotificationCreate
from app.i18n import t
from app.websocket_manager import broadcast_task_update
def get_panel_language(db) -> str:
@@ -570,7 +572,7 @@ class MonitoringWorker:
logger.debug("[VM SYNC] No online servers, skipping sync")
return
logger.info(f"[VM SYNC] Starting VM cache sync for {len(servers)} servers")
logger.debug(f"[VM SYNC] Starting VM cache sync for {len(servers)} servers")
# Load IPAM allocations for IP lookup
ipam_allocations = db.query(IPAMAllocation).filter(
@@ -618,7 +620,7 @@ class MonitoringWorker:
cluster_id = min(server_ids) # Use smallest server_id as cluster identifier
for sid in server_ids:
cluster_map[sid] = cluster_id
logger.info(f"[VM SYNC] Cluster detected: servers {server_ids} share nodes {list(nodes)})")
logger.debug(f"[VM SYNC] Cluster detected: servers {server_ids} share nodes {list(nodes)})")
# Track seen VMs per cluster/server for dedup
seen_in_cluster = {} # cluster_id -> set of vmid
@@ -677,12 +679,12 @@ class MonitoringWorker:
all_vms_data.append((use_server_id, ct, 'lxc'))
server_ct_count += 1
logger.info(f"[VM SYNC] Server {server.name}: collected {server_vm_count} VMs, {server_ct_count} containers")
logger.debug(f"[VM SYNC] Server {server.name}: collected {server_vm_count} VMs, {server_ct_count} containers")
except Exception as e:
logger.error(f"[VM SYNC] Error fetching from server {server.name}: {e}")
logger.info(f"[VM SYNC] Collected {len(all_vms_data)} unique VMs/containers")
logger.debug(f"[VM SYNC] Collected {len(all_vms_data)} unique VMs/containers")
# Track all seen (server_id, vmid) pairs for cleanup
all_seen = set()
@@ -780,7 +782,7 @@ class MonitoringWorker:
if (vm.server_id, vm.vmid) not in all_seen:
vm.deleted_at = sync_time
deleted_count += 1
logger.info(f"[VM SYNC] Marked VM {vm.name} (server={vm.server_id}, vmid={vm.vmid}) as deleted")
logger.debug(f"[VM SYNC] Marked VM {vm.name} (server={vm.server_id}, vmid={vm.vmid}) as deleted")
# Release IPAM allocation for deleted VM
try:
@@ -794,19 +796,88 @@ class MonitoringWorker:
)
if released:
released_ips += 1
logger.info(f"[VM SYNC] Released IPAM IP {released_ip} for deleted VM {vm.name}")
logger.debug(f"[VM SYNC] Released IPAM IP {released_ip} for deleted VM {vm.name}")
except Exception as e:
logger.warning(f"[VM SYNC] Failed to release IPAM for VM {vm.vmid}: {e}")
db.commit()
logger.info(f"[VM SYNC] Cache sync completed. Processed {len(all_vms_data)} VMs/containers, marked {deleted_count} as deleted, released {released_ips} IPs")
logger.debug(f"[VM SYNC] Cache sync completed. Processed {len(all_vms_data)} VMs/containers, marked {deleted_count} as deleted, released {released_ips} IPs")
except Exception as e:
logger.error(f"[VM SYNC] Critical error: {e}", exc_info=True)
db.rollback()
finally:
db.close()
def run_upid_task_sync(self) -> None:
"""
Poll Proxmox API for status of all 'running' ProxmoxTask records.
Updates DB status + log, then broadcasts WS update.
Runs every 5 seconds via APScheduler.
"""
db = SessionLocal()
try:
running_tasks = (
db.query(ProxmoxTask)
.filter(ProxmoxTask.status == "running")
.all()
)
if not running_tasks:
return
logger.debug(f"[UPID SYNC] Checking {len(running_tasks)} running UPID task(s)")
for task in running_tasks:
if not task.server_id or not task.node:
continue
try:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == task.server_id).first()
if not server or not server.is_online:
continue
client = self._create_proxmox_client(server)
if not client.is_connected():
continue
# --- status -----------------------------------------------
proxmox_status = client.get_task_status(task.node, task.upid)
pve_status = proxmox_status.get("status", "running") if proxmox_status else "running"
# --- log --------------------------------------------------
try:
log_entries = client.get_task_log(task.node, task.upid, 0, 500) or []
if isinstance(log_entries, list):
task.log_text = "\n".join(
entry.get("t", "") if isinstance(entry, dict) else str(entry)
for entry in log_entries
)
except Exception:
pass
# --- finalise if stopped ----------------------------------
if pve_status == "stopped":
exit_status = proxmox_status.get("exitstatus", "") if proxmox_status else ""
task.exit_status = exit_status
task.status = "completed" if exit_status == "OK" else "failed"
task.completed_at = utcnow()
logger.info(f"[UPID SYNC] Task #{task.id} finished: {task.status} ({exit_status})")
db.commit()
# --- WS broadcast -----------------------------------------
try:
broadcast_task_update(task.user_id, "task_update", task.to_dict())
except Exception as _wb:
logger.debug(f"[UPID SYNC] WS broadcast skipped: {_wb}")
except Exception as e:
logger.error(f"[UPID SYNC] Error syncing task #{task.id} ({task.upid[:30]}): {e}")
except Exception as e:
logger.error(f"[UPID SYNC] Critical error: {e}", exc_info=True)
finally:
db.close()
def _should_send_alert(self, alert_key: str, cooldown_minutes: int = 30) -> bool:
"""
Check if alert should be sent (rate limiting)
@@ -837,7 +908,7 @@ class MonitoringWorker:
db = SessionLocal()
try:
logger.info("[UPDATE CHECK] Checking for panel updates...")
logger.debug("[UPDATE CHECK] Checking for panel updates...")
# Run async check_for_updates
result = run_async(check_for_updates())
@@ -888,12 +959,12 @@ class MonitoringWorker:
new_version=new_version,
changelog=changelog
)
logger.info(f"[UPDATE CHECK] Notified user {user.username} about update to {new_version}")
logger.debug(f"[UPDATE CHECK] Notified user {user.username} about update to {new_version}")
except Exception as e:
logger.error(f"[UPDATE CHECK] Failed to notify user {user.id}: {e}")
db.commit()
logger.info(f"[UPDATE CHECK] Completed. Notified {len(users)} admin users about version {new_version}")
logger.debug(f"[UPDATE CHECK] Completed. Notified {len(users)} admin users about version {new_version}")
except Exception as e:
logger.error(f"[UPDATE CHECK] Error checking for updates: {e}", exc_info=True)
@@ -972,6 +1043,15 @@ def start_monitoring_worker():
logger.info("Task queue processor registered")
except ImportError as e:
logger.warning(f"Task queue service not available: {e}")
# Proxmox UPID task sync - every 5 seconds
scheduler.add_job(
monitoring_worker.run_upid_task_sync,
trigger=IntervalTrigger(seconds=5),
id='upid_task_sync',
name='Sync Proxmox UPID task statuses',
replace_existing=True
)
# Cleanup expired notifications - every 6 hours
scheduler.add_job(

View File

@@ -1049,6 +1049,74 @@ def migrate_ipam_network_node(conn):
logger.info("✓ ipam_networks.proxmox_node already exists")
def migrate_cluster_support(conn):
"""Migration 17: Add cluster_name column to proxmox_servers table"""
logger.info("Migration 17: Adding cluster support to proxmox_servers...")
if add_column_if_not_exists(conn, 'proxmox_servers', 'cluster_name', 'VARCHAR(100)'):
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_proxmox_cluster_name
ON proxmox_servers(cluster_name)
"""))
logger.info("✓ Added cluster_name column and index to proxmox_servers")
else:
logger.info("✓ proxmox_servers.cluster_name already exists")
# Add cluster:manage permission to admin role if not already present
try:
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'admin'")).fetchone()
if result:
import json as _json
perms = _json.loads(result.permissions) if isinstance(result.permissions, str) else result.permissions
if not perms.get('proxmox.cluster.manage'):
perms['proxmox.cluster.manage'] = True
conn.execute(
text("UPDATE roles SET permissions = :p WHERE name = 'admin'"),
{"p": _json.dumps(perms)}
)
logger.info("✓ Added proxmox.cluster.manage to admin role")
except Exception as e:
logger.warning(f"Could not update admin role permissions: {e}")
# ==================== Migration 18: Proxmox Tasks (UPID) ====================
def migrate_proxmox_tasks(conn):
"""Create proxmox_tasks table for tracking long-running Proxmox UPID tasks."""
logger.info("Migration 18: Creating proxmox_tasks table...")
if table_exists(conn, 'proxmox_tasks'):
logger.info("Table proxmox_tasks already exists, skipping")
return
conn.execute(text("""
CREATE TABLE proxmox_tasks (
id SERIAL PRIMARY KEY,
upid VARCHAR(200) NOT NULL,
server_id INTEGER REFERENCES proxmox_servers(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node VARCHAR(100),
vmid INTEGER,
vm_type VARCHAR(10),
action VARCHAR(100) NOT NULL,
description VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'running',
exit_status VARCHAR(100),
log_text TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE
)
"""))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_upid ON proxmox_tasks(upid)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_user_id ON proxmox_tasks(user_id)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_status ON proxmox_tasks(status)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_user_status ON proxmox_tasks(user_id, status)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_created ON proxmox_tasks(created_at DESC)"))
logger.info("✓ proxmox_tasks table created")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1191,6 +1259,22 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"IPAM network node migration: {e}")
conn.rollback()
# Migration 17: Cluster Support (cluster_name column)
try:
migrate_cluster_support(conn)
conn.commit()
except Exception as e:
logger.warning(f"Cluster support migration: {e}")
conn.rollback()
# Migration 18: Proxmox Tasks (UPID tracking)
try:
migrate_proxmox_tasks(conn)
conn.commit()
except Exception as e:
logger.warning(f"Proxmox tasks migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)