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:
@@ -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)
|
||||
|
||||
@@ -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()})
|
||||
|
||||
|
||||
648
backend/app/api/proxmox/cluster.py
Normal file
648
backend/app/api/proxmox/cluster.py
Normal 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}' удалена из кластера. "
|
||||
"Убедитесь, что нода выключена и не подключена к той же кластерной сети."
|
||||
),
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">×</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;">×</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;">×</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 %}
|
||||
|
||||
522
backend/app/templates/tasks.html
Normal file
522
backend/app/templates/tasks.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
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 ? ` · Завершена: ${fmtTime(task.completed_at)}` : '') +
|
||||
` · Время: ${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 %}
|
||||
@@ -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');
|
||||
|
||||
99
backend/app/websocket_manager.py
Normal file
99
backend/app/websocket_manager.py
Normal 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))
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user