Add favicon, backup scheduler, backup API and templates, RBAC and migration updates

This commit is contained in:
markmorado
2026-03-08 11:40:41 +01:00
parent 7a77ffbd2b
commit 85aa59e7b6
12 changed files with 2500 additions and 5 deletions

View File

@@ -4,6 +4,7 @@ from .sdn import router as sdn_router
from .snapshots import router as snapshots_router
from .vms import router as vms_router
from .tasks import router as tasks_router
from .backup import router as backup_router
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
router = APIRouter()
@@ -12,3 +13,4 @@ router.include_router(sdn_router)
router.include_router(snapshots_router)
router.include_router(vms_router)
router.include_router(tasks_router)
router.include_router(backup_router)

View File

@@ -0,0 +1,532 @@
"""
Backup & Storage API
====================
Endpoints for managing Proxmox storages, backup files,
creating backups (vzdump), restoring, and scheduled backup jobs.
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from loguru import logger
from ...db import get_db
from ...models import BackupJob, ProxmoxServer, User
from ...auth import PermissionChecker
from ...logging_service import LoggingService
from ...template_helpers import add_i18n_context
from ._helpers import _get_proxmox_client, templates
router = APIRouter()
# ==================== UI Page ====================
@router.get("/api/backups/page", response_class=HTMLResponse, include_in_schema=False)
async def backups_page(
request: Request,
db: Session = Depends(get_db),
):
from ...i18n import t
lang = request.cookies.get("language", "ru")
servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online.is_(True)).all()
context = {
"request": request,
"page_title": t("nav_backups", lang),
"servers": [{"id": s.id, "name": s.name} for s in servers],
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("backups.html", context)
# ==================== Storage Management ====================
@router.get("/api/backups/storages/{server_id}")
def get_storages(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""List all storages configured on a Proxmox server"""
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)
storages = client.get_storages()
return JSONResponse(content={"storages": storages})
except Exception as e:
logger.error(f"Error listing storages for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/backups/storages/{server_id}")
async def create_storage(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Add a new storage to a Proxmox server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
storage_id = data.pop("storage", None)
storage_type = data.pop("type", None)
if not storage_id or not storage_type:
raise HTTPException(status_code=400, detail="storage and type are required")
try:
client = _get_proxmox_client(server)
result = client.create_storage(storage_id, storage_type, **data)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="storage_create", resource_type="storage",
resource_id=storage_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/backups/storages/{server_id}/{storage_id}")
async def update_storage(
server_id: int,
storage_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Update storage configuration"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_storage(storage_id, **data)
if result.get("success"):
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/backups/storages/{server_id}/{storage_id}")
def delete_storage(
server_id: int,
storage_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Delete storage from 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)
result = client.delete_storage(storage_id)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="storage_delete", resource_type="storage",
resource_id=storage_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Backup Listing ====================
@router.get("/api/backups/list/{server_id}")
def list_backups(
server_id: int,
node: str,
storage: str,
vmid: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""List backup files stored in a given storage on a node"""
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)
backups = client.list_backups(node, storage, vmid)
return JSONResponse(content={"backups": backups})
except Exception as e:
logger.error(f"Error listing backups: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/backups/{server_id}/backup")
async def delete_backup(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:delete")),
):
"""Delete a specific backup file"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
node = data.get("node")
storage = data.get("storage")
volid = data.get("volid")
if not all([node, storage, volid]):
raise HTTPException(status_code=400, detail="node, storage, volid are required")
try:
client = _get_proxmox_client(server)
result = client.delete_backup(node, storage, volid)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="backup_delete", resource_type="backup",
resource_id=volid, username=current_user.username,
server_id=server_id, server_name=server.name,
node_name=node, success=True,
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Create Backup ====================
@router.post("/api/backups/create")
async def create_backup(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:create")),
):
"""Trigger vzdump backup for a VM/container. Returns UPID for tracking."""
data = await request.json()
server_id = data.get("server_id")
node = data.get("node")
vmid = data.get("vmid")
storage = data.get("storage")
if not all([server_id, node, vmid, storage]):
raise HTTPException(status_code=400, detail="server_id, node, vmid, storage are required")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
mode = data.get("mode", "snapshot")
compress = data.get("compress", "zstd")
remove = int(data.get("remove", 1))
notes = data.get("notes")
try:
client = _get_proxmox_client(server)
result = client.create_backup(
node=node, vmid=int(vmid), storage=storage,
mode=mode, compress=compress, remove=remove, notes=notes,
)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="backup_create", resource_type="backup",
resource_id=str(vmid), username=current_user.username,
server_id=server_id, server_name=server.name,
node_name=node, success=True,
details={"storage": storage, "mode": mode, "compress": compress},
)
return JSONResponse(content={"success": True, "upid": result.get("upid")})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Task Status ====================
@router.get("/api/backups/task/{server_id}/{node}/{upid:path}")
def get_task_status(
server_id: int,
node: str,
upid: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""Get Proxmox task status and log by UPID"""
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)
status = client.get_task_status(node, upid)
log = client.get_task_log(node, upid)
return JSONResponse(content={"status": status, "log": log})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Restore ====================
@router.post("/api/backups/restore")
async def restore_backup(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:restore")),
):
"""Restore a VM or LXC from a backup file. Returns UPID."""
data = await request.json()
server_id = data.get("server_id")
node = data.get("node")
vmid = data.get("vmid")
archive = data.get("archive") # volume id
storage = data.get("storage")
vm_type = data.get("vm_type", "qemu")
new_vmid = data.get("new_vmid")
start = bool(data.get("start", False))
unique = bool(data.get("unique", True))
if not all([server_id, node, vmid, archive, storage]):
raise HTTPException(
status_code=400,
detail="server_id, node, vmid, archive, storage are required",
)
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)
if vm_type == "lxc":
result = client.restore_lxc(
node=node, vmid=int(vmid), archive=archive,
storage=storage, new_vmid=int(new_vmid) if new_vmid else None,
start=start,
)
else:
result = client.restore_vm(
node=node, vmid=int(vmid), archive=archive,
storage=storage, new_vmid=int(new_vmid) if new_vmid else None,
start=start, unique=unique,
)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="backup_restore", resource_type="backup",
resource_id=str(vmid), username=current_user.username,
server_id=server_id, server_name=server.name,
node_name=node, success=True,
details={"archive": archive, "storage": storage, "vm_type": vm_type},
)
return JSONResponse(content={"success": True, "upid": result.get("upid")})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Scheduled Backup Jobs ====================
@router.get("/api/backups/jobs")
def list_backup_jobs(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""List scheduled backup jobs"""
if current_user.is_admin or (current_user.role and current_user.role.name == "admin"):
jobs = db.query(BackupJob).all()
else:
jobs = db.query(BackupJob).filter(BackupJob.owner_id == current_user.id).all()
return JSONResponse(content={"jobs": [j.to_dict() for j in jobs]})
@router.post("/api/backups/jobs")
async def create_backup_job(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Create a new scheduled backup job"""
data = await request.json()
required = ["server_id", "node", "vmids", "storage", "cron_expression"]
missing = [f for f in required if not data.get(f)]
if missing:
raise HTTPException(status_code=400, detail=f"Missing fields: {', '.join(missing)}")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == data["server_id"]).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
job = BackupJob(
server_id=data["server_id"],
node=data["node"],
vmids=data["vmids"],
storage=data["storage"],
mode=data.get("mode", "snapshot"),
compress=data.get("compress", "zstd"),
notes=data.get("notes"),
keep_last=int(data.get("keep_last", 3)),
keep_daily=int(data.get("keep_daily", 7)),
keep_weekly=int(data.get("keep_weekly", 4)),
keep_monthly=int(data.get("keep_monthly", 6)),
cron_expression=data["cron_expression"],
enabled=bool(data.get("enabled", True)),
owner_id=current_user.id,
)
db.add(job)
db.commit()
db.refresh(job)
# Register with scheduler
try:
from ...services.backup_scheduler import scheduler
scheduler.add_job_from_model(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",
resource_id=str(job.id), username=current_user.username, success=True,
)
return JSONResponse(content={"success": True, "job": job.to_dict()})
@router.put("/api/backups/jobs/{job_id}")
async def update_backup_job(
job_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Update a scheduled backup job"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not (current_user.is_admin or
(current_user.role and current_user.role.name == "admin") or
job.owner_id == current_user.id):
raise HTTPException(status_code=403, detail="Access denied")
data = await request.json()
updatable = ["node", "vmids", "storage", "mode", "compress", "notes",
"keep_last", "keep_daily", "keep_weekly", "keep_monthly",
"cron_expression", "enabled"]
for field in updatable:
if field in data:
setattr(job, field, data[field])
job.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(job)
try:
from ...services.backup_scheduler import scheduler
scheduler.update_job_from_model(job)
except Exception as e:
logger.warning(f"Could not update job {job.id} in scheduler: {e}")
return JSONResponse(content={"success": True, "job": job.to_dict()})
@router.delete("/api/backups/jobs/{job_id}")
def delete_backup_job(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Delete a scheduled backup job"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not (current_user.is_admin or
(current_user.role and current_user.role.name == "admin") or
job.owner_id == current_user.id):
raise HTTPException(status_code=403, detail="Access denied")
try:
from ...services.backup_scheduler import scheduler
scheduler.remove_job(job_id)
except Exception as e:
logger.warning(f"Could not remove job {job_id} from scheduler: {e}")
db.delete(job)
db.commit()
return JSONResponse(content={"success": True})
@router.patch("/api/backups/jobs/{job_id}/toggle")
def toggle_backup_job(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Enable or disable a scheduled backup job"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
job.enabled = not job.enabled
job.updated_at = datetime.now(timezone.utc)
db.commit()
try:
from ...services.backup_scheduler import scheduler
if job.enabled:
scheduler.add_job_from_model(job)
else:
scheduler.remove_job(job_id)
except Exception as e:
logger.warning(f"Could not toggle job {job_id} in scheduler: {e}")
return JSONResponse(content={"success": True, "enabled": job.enabled})
@router.post("/api/backups/jobs/{job_id}/run-now")
def run_backup_job_now(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:create")),
):
"""Trigger a scheduled backup job immediately"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
try:
from ...services.backup_scheduler import run_backup_job
import asyncio
# Run in background - fire and forget via thread
import threading
thread = threading.Thread(
target=lambda: asyncio.run(run_backup_job(job_id)),
daemon=True,
)
thread.start()
return JSONResponse(content={"success": True, "message": "Job triggered"})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -43,6 +43,14 @@ class I18nService:
"ru": "Удалить",
"en": "Delete"
},
"saved": {
"ru": "Сохранено",
"en": "Saved"
},
"none": {
"ru": "Нет",
"en": "None"
},
"total": {
"ru": "Всего",
"en": "Total"
@@ -452,10 +460,242 @@ class I18nService:
"ru": "IPAM",
"en": "IPAM"
},
"nav_backups": {
"ru": "Резервные копии",
"en": "Backups"
},
"nav_docker": {
"ru": "Docker",
"en": "Docker"
},
"backup_tab_backups": {
"ru": "Резервные копии",
"en": "Backups"
},
"backup_tab_storages": {
"ru": "Хранилища",
"en": "Storages"
},
"backup_tab_schedule": {
"ru": "Расписание",
"en": "Schedule"
},
"backup_list_title": {
"ru": "Список резервных копий",
"en": "Backup List"
},
"backup_storages_title": {
"ru": "Хранилища Proxmox",
"en": "Proxmox Storages"
},
"backup_jobs_title": {
"ru": "Задания резервного копирования",
"en": "Backup Jobs"
},
"backup_create": {
"ru": "Создать бэкап",
"en": "Create Backup"
},
"backup_create_title": {
"ru": "Создать резервную копию",
"en": "Create Backup"
},
"backup_restore_title": {
"ru": "Восстановить из резервной копии",
"en": "Restore from Backup"
},
"backup_restore_btn": {
"ru": "Восстановить",
"en": "Restore"
},
"backup_restore_started": {
"ru": "Восстановление запущено",
"en": "Restore started"
},
"backup_restore_storage": {
"ru": "Хранилище для дисков",
"en": "Target storage for disks"
},
"backup_restore_new_vmid": {
"ru": "Новый VMID",
"en": "New VMID"
},
"backup_restore_vmid_hint": {
"ru": "Оставьте пустым для использования оригинального",
"en": "Leave empty to use original VMID"
},
"backup_restore_start": {
"ru": "Запустить после восстановления",
"en": "Start after restore"
},
"backup_restore_unique": {
"ru": "Сгенерировать уникальный MAC",
"en": "Generate unique MAC"
},
"backup_add_storage": {
"ru": "Добавить хранилище",
"en": "Add Storage"
},
"backup_add_job": {
"ru": "Добавить задание",
"en": "Add Job"
},
"backup_edit_job": {
"ru": "Редактировать задание",
"en": "Edit Job"
},
"backup_storage_id": {
"ru": "Хранилище",
"en": "Storage"
},
"backup_storage_type": {
"ru": "Тип хранилища",
"en": "Storage Type"
},
"backup_storage_content": {
"ru": "Типы контента",
"en": "Content Types"
},
"backup_storage_shared": {
"ru": "Общее",
"en": "Shared"
},
"backup_storage_usage": {
"ru": "Использование",
"en": "Usage"
},
"backup_storage_path": {
"ru": "Путь",
"en": "Path"
},
"backup_storage_delete_confirm": {
"ru": "Удалить хранилище из конфигурации Proxmox?",
"en": "Remove this storage from Proxmox configuration?"
},
"backup_storage_deleted": {
"ru": "Хранилище удалено",
"en": "Storage deleted"
},
"backup_storage_added": {
"ru": "Хранилище добавлено",
"en": "Storage added"
},
"backup_col_type": {
"ru": "Тип ВМ",
"en": "VM Type"
},
"backup_col_filename": {
"ru": "Файл",
"en": "File"
},
"backup_col_size": {
"ru": "Размер",
"en": "Size"
},
"backup_col_date": {
"ru": "Дата",
"en": "Date"
},
"backup_col_format": {
"ru": "Формат",
"en": "Format"
},
"backup_mode": {
"ru": "Режим",
"en": "Mode"
},
"backup_compress": {
"ru": "Сжатие",
"en": "Compression"
},
"backup_keep": {
"ru": "Хранить N последних",
"en": "Keep last N backups"
},
"backup_keep_hint": {
"ru": "0 — не удалять старые резервные копии",
"en": "0 — do not auto-remove old backups"
},
"backup_notes": {
"ru": "Заметка",
"en": "Notes"
},
"backup_notes_placeholder": {
"ru": "Описание резервной копии",
"en": "Backup description"
},
"backup_started": {
"ru": "Резервное копирование запущено",
"en": "Backup started"
},
"backup_deleted": {
"ru": "Резервная копия удалена",
"en": "Backup deleted"
},
"backup_delete_confirm": {
"ru": "Удалить резервную копию? Это действие необратимо.",
"en": "Delete this backup? This action cannot be undone."
},
"backup_empty": {
"ru": "Резервных копий нет",
"en": "No backups found"
},
"backup_no_storages": {
"ru": "Нет доступных хранилищ",
"en": "No storages available"
},
"backup_no_jobs": {
"ru": "Нет заданий резервного копирования",
"en": "No backup jobs configured"
},
"backup_task_log": {
"ru": "Лог задания",
"en": "Task Log"
},
"backup_job_last_run": {
"ru": "Последний запуск",
"en": "Last Run"
},
"backup_job_enabled": {
"ru": "Задание активно",
"en": "Job enabled"
},
"backup_job_enable": {
"ru": "Включить задание",
"en": "Enable job"
},
"backup_job_disable": {
"ru": "Отключить задание",
"en": "Disable job"
},
"backup_job_triggered": {
"ru": "Задание запущено",
"en": "Job triggered"
},
"backup_job_delete_confirm": {
"ru": "Удалить задание расписания?",
"en": "Delete this backup job?"
},
"backup_run_now": {
"ru": "Запустить сейчас",
"en": "Run now"
},
"backup_vmids_hint": {
"ru": "Список через запятую или 'all' для всех ВМ",
"en": "Comma-separated list or 'all' for all VMs"
},
"backup_cron_hint": {
"ru": "Пример: '0 2 * * *' — каждую ночь в 02:00 UTC",
"en": "Example: '0 2 * * *' — every night at 02:00 UTC"
},
"select_storage": {
"ru": "Выберите хранилище",
"en": "Select storage"
},
"select_server_and_storage": {
"ru": "Выберите сервер и хранилище",
"en": "Select a server and storage"
},
"nav_logs": {
"ru": "Логи",
"en": "Logs"
@@ -2007,10 +2247,242 @@ class I18nService:
"ru": "IPAM",
"en": "IPAM"
},
"nav_backups": {
"ru": "Резервные копии",
"en": "Backups"
},
"nav_docker": {
"ru": "Docker",
"en": "Docker"
},
"backup_tab_backups": {
"ru": "Резервные копии",
"en": "Backups"
},
"backup_tab_storages": {
"ru": "Хранилища",
"en": "Storages"
},
"backup_tab_schedule": {
"ru": "Расписание",
"en": "Schedule"
},
"backup_list_title": {
"ru": "Список резервных копий",
"en": "Backup List"
},
"backup_storages_title": {
"ru": "Хранилища Proxmox",
"en": "Proxmox Storages"
},
"backup_jobs_title": {
"ru": "Задания резервного копирования",
"en": "Backup Jobs"
},
"backup_create": {
"ru": "Создать бэкап",
"en": "Create Backup"
},
"backup_create_title": {
"ru": "Создать резервную копию",
"en": "Create Backup"
},
"backup_restore_title": {
"ru": "Восстановить из резервной копии",
"en": "Restore from Backup"
},
"backup_restore_btn": {
"ru": "Восстановить",
"en": "Restore"
},
"backup_restore_started": {
"ru": "Восстановление запущено",
"en": "Restore started"
},
"backup_restore_storage": {
"ru": "Хранилище для дисков",
"en": "Target storage for disks"
},
"backup_restore_new_vmid": {
"ru": "Новый VMID",
"en": "New VMID"
},
"backup_restore_vmid_hint": {
"ru": "Оставьте пустым для использования оригинального",
"en": "Leave empty to use original VMID"
},
"backup_restore_start": {
"ru": "Запустить после восстановления",
"en": "Start after restore"
},
"backup_restore_unique": {
"ru": "Сгенерировать уникальный MAC",
"en": "Generate unique MAC"
},
"backup_add_storage": {
"ru": "Добавить хранилище",
"en": "Add Storage"
},
"backup_add_job": {
"ru": "Добавить задание",
"en": "Add Job"
},
"backup_edit_job": {
"ru": "Редактировать задание",
"en": "Edit Job"
},
"backup_storage_id": {
"ru": "Хранилище",
"en": "Storage"
},
"backup_storage_type": {
"ru": "Тип хранилища",
"en": "Storage Type"
},
"backup_storage_content": {
"ru": "Типы контента",
"en": "Content Types"
},
"backup_storage_shared": {
"ru": "Общее",
"en": "Shared"
},
"backup_storage_usage": {
"ru": "Использование",
"en": "Usage"
},
"backup_storage_path": {
"ru": "Путь",
"en": "Path"
},
"backup_storage_delete_confirm": {
"ru": "Удалить хранилище из конфигурации Proxmox?",
"en": "Remove this storage from Proxmox configuration?"
},
"backup_storage_deleted": {
"ru": "Хранилище удалено",
"en": "Storage deleted"
},
"backup_storage_added": {
"ru": "Хранилище добавлено",
"en": "Storage added"
},
"backup_col_type": {
"ru": "Тип ВМ",
"en": "VM Type"
},
"backup_col_filename": {
"ru": "Файл",
"en": "File"
},
"backup_col_size": {
"ru": "Размер",
"en": "Size"
},
"backup_col_date": {
"ru": "Дата",
"en": "Date"
},
"backup_col_format": {
"ru": "Формат",
"en": "Format"
},
"backup_mode": {
"ru": "Режим",
"en": "Mode"
},
"backup_compress": {
"ru": "Сжатие",
"en": "Compression"
},
"backup_keep": {
"ru": "Хранить N последних",
"en": "Keep last N backups"
},
"backup_keep_hint": {
"ru": "0 — не удалять старые резервные копии",
"en": "0 — do not auto-remove old backups"
},
"backup_notes": {
"ru": "Заметка",
"en": "Notes"
},
"backup_notes_placeholder": {
"ru": "Описание резервной копии",
"en": "Backup description"
},
"backup_started": {
"ru": "Резервное копирование запущено",
"en": "Backup started"
},
"backup_deleted": {
"ru": "Резервная копия удалена",
"en": "Backup deleted"
},
"backup_delete_confirm": {
"ru": "Удалить резервную копию? Это действие необратимо.",
"en": "Delete this backup? This action cannot be undone."
},
"backup_empty": {
"ru": "Резервных копий нет",
"en": "No backups found"
},
"backup_no_storages": {
"ru": "Нет доступных хранилищ",
"en": "No storages available"
},
"backup_no_jobs": {
"ru": "Нет заданий резервного копирования",
"en": "No backup jobs configured"
},
"backup_task_log": {
"ru": "Лог задания",
"en": "Task Log"
},
"backup_job_last_run": {
"ru": "Последний запуск",
"en": "Last Run"
},
"backup_job_enabled": {
"ru": "Задание активно",
"en": "Job enabled"
},
"backup_job_enable": {
"ru": "Включить задание",
"en": "Enable job"
},
"backup_job_disable": {
"ru": "Отключить задание",
"en": "Disable job"
},
"backup_job_triggered": {
"ru": "Задание запущено",
"en": "Job triggered"
},
"backup_job_delete_confirm": {
"ru": "Удалить задание расписания?",
"en": "Delete this backup job?"
},
"backup_run_now": {
"ru": "Запустить сейчас",
"en": "Run now"
},
"backup_vmids_hint": {
"ru": "Список через запятую или 'all' для всех ВМ",
"en": "Comma-separated list or 'all' for all VMs"
},
"backup_cron_hint": {
"ru": "Пример: '0 2 * * *' — каждую ночь в 02:00 UTC",
"en": "Example: '0 2 * * *' — every night at 02:00 UTC"
},
"select_storage": {
"ru": "Выберите хранилище",
"en": "Select storage"
},
"select_server_and_storage": {
"ru": "Выберите сервер и хранилище",
"en": "Select a server and storage"
},
"nav_logs": {
"ru": "Логи",
"en": "Logs"

View File

@@ -122,10 +122,19 @@ async def lifespan(app: FastAPI):
logger.info("Background monitoring worker started")
except Exception as e:
logger.warning(f"Background worker startup failed: {e}")
# Start backup scheduler
backup_scheduler = None
try:
from .services.backup_scheduler import start_backup_scheduler
backup_scheduler = start_backup_scheduler()
logger.info("Backup scheduler started")
except Exception as e:
logger.warning(f"Backup scheduler startup failed: {e}")
logger.info("Application startup complete")
yield
# Cleanup
try:
if 'scheduler' in locals():
@@ -133,6 +142,13 @@ async def lifespan(app: FastAPI):
logger.info("Background worker stopped")
except Exception as e:
logger.warning(f"Background worker shutdown failed: {e}")
try:
if backup_scheduler:
backup_scheduler.shutdown()
logger.info("Backup scheduler stopped")
except Exception as e:
logger.warning(f"Backup scheduler shutdown failed: {e}")
logger.info("Application shutdown")
@@ -219,6 +235,12 @@ def create_app() -> FastAPI:
async def root():
return RedirectResponse(url="/dashboard", status_code=302)
# Backup Storage page
@app.get("/backups", include_in_schema=False)
async def backups_page(request: Request):
"""Backup storage management page"""
return RedirectResponse(url="/proxmox/api/backups/page", status_code=302)
# Legacy redirect for old /servers URL
@app.get("/servers", include_in_schema=False)
async def servers_redirect():

View File

@@ -845,4 +845,77 @@ class VMSnapshotArchive(Base):
return f"<VMSnapshotArchive(id={self.id}, vmid={self.vmid}, snapname='{self.snapname}')>"
# ==================== Backup Models ====================
class BackupJob(Base):
"""Scheduled backup job"""
__tablename__ = "backup_jobs"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), nullable=False)
node = Column(String(100), nullable=False)
vmids = Column(JSON, nullable=False, default=[]) # list[int] or ["all"]
storage = Column(String(100), nullable=False)
# Backup settings
mode = Column(String(20), default="snapshot", nullable=False) # snapshot|suspend|stop
compress = Column(String(20), default="zstd", nullable=False) # 0|lzo|gzip|zstd
notes = Column(String(500), nullable=True)
# Pruning
keep_last = Column(Integer, default=3, nullable=False)
keep_daily = Column(Integer, default=7, nullable=False)
keep_weekly = Column(Integer, default=4, nullable=False)
keep_monthly = Column(Integer, default=6, nullable=False)
# Schedule
cron_expression = Column(String(100), nullable=False) # "0 2 * * *"
enabled = Column(Boolean, default=True, nullable=False)
# Ownership
owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Tracking
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
last_run_at = Column(DateTime(timezone=True), nullable=True)
last_status = Column(String(20), nullable=True) # success|failed|running
last_error = Column(Text, nullable=True)
last_upid = Column(String(200), nullable=True) # Last Proxmox UPID
server = relationship("ProxmoxServer", backref="backup_jobs")
owner = relationship("User", foreign_keys=[owner_id])
__table_args__ = (
Index('idx_backup_jobs_server', 'server_id'),
Index('idx_backup_jobs_enabled', 'enabled'),
Index('idx_backup_jobs_owner', 'owner_id'),
)
def __repr__(self):
return f"<BackupJob(id={self.id}, server_id={self.server_id}, cron='{self.cron_expression}')>"
def to_dict(self) -> dict:
return {
'id': self.id,
'server_id': self.server_id,
'node': self.node,
'vmids': self.vmids,
'storage': self.storage,
'mode': self.mode,
'compress': self.compress,
'notes': self.notes,
'keep_last': self.keep_last,
'keep_daily': self.keep_daily,
'keep_weekly': self.keep_weekly,
'keep_monthly': self.keep_monthly,
'cron_expression': self.cron_expression,
'enabled': self.enabled,
'owner_id': self.owner_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'last_run_at': self.last_run_at.isoformat() if self.last_run_at else None,
'last_status': self.last_status,
'last_error': self.last_error,
'last_upid': self.last_upid,
}

View File

@@ -2895,6 +2895,181 @@ class ProxmoxClient:
logger.error(f"Error getting snapshot {snapname} config: {e}")
return None
# ==================== Storage Methods ====================
def get_storages(self) -> List[Dict]:
"""Get all storages configured on this Proxmox cluster"""
if not self.proxmox:
return []
try:
storages = self.proxmox.storage.get()
return storages
except Exception as e:
logger.error(f"Error getting storages from {self.host}: {e}")
return []
def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
"""Get storages available on a specific node, optionally filtered by content type"""
if not self.proxmox:
return []
try:
params = {}
if content:
params['content'] = content
storages = self.proxmox.nodes(node).storage.get(**params)
return storages
except Exception as e:
logger.error(f"Error getting storages for node {node}: {e}")
return []
def create_storage(self, storage_id: str, storage_type: str, **kwargs) -> Dict:
"""Create a new storage configuration"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.storage.post(storage=storage_id, type=storage_type, **kwargs)
return {"success": True}
except Exception as e:
logger.error(f"Error creating storage {storage_id}: {e}")
return {"success": False, "error": str(e)}
def update_storage(self, storage_id: str, **kwargs) -> Dict:
"""Update storage configuration"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.storage(storage_id).put(**kwargs)
return {"success": True}
except Exception as e:
logger.error(f"Error updating storage {storage_id}: {e}")
return {"success": False, "error": str(e)}
def delete_storage(self, storage_id: str) -> Dict:
"""Delete storage configuration"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.storage(storage_id).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting storage {storage_id}: {e}")
return {"success": False, "error": str(e)}
# ==================== Backup Methods ====================
def list_backups(self, node: str, storage: str, vmid: int = None) -> List[Dict]:
"""List backup files in a storage on a node"""
if not self.proxmox:
return []
try:
params = {"content": "backup"}
if vmid:
params["vmid"] = vmid
items = self.proxmox.nodes(node).storage(storage).content.get(**params)
return items
except Exception as e:
logger.error(f"Error listing backups on {node}/{storage}: {e}")
return []
def create_backup(self, node: str, vmid: int, storage: str,
mode: str = "snapshot", compress: str = "zstd",
remove: int = 1, notes: str = None) -> Dict:
"""
Trigger vzdump backup for a VM/container.
Returns {"success": True, "upid": "..."} or {"success": False, "error": "..."}
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {
"vmid": vmid,
"storage": storage,
"mode": mode,
"compress": compress,
"remove": remove,
}
if notes:
params["notes"] = notes
upid = self.proxmox.nodes(node).vzdump.post(**params)
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error creating backup for VM {vmid} on {node}: {e}")
return {"success": False, "error": str(e)}
def get_task_status(self, node: str, upid: str) -> Dict:
"""Get status of a Proxmox task by UPID"""
if not self.proxmox:
return {}
try:
status = self.proxmox.nodes(node).tasks(upid).status.get()
return status
except Exception as e:
logger.error(f"Error getting task status {upid}: {e}")
return {}
def get_task_log(self, node: str, upid: str, start: int = 0, limit: int = 500) -> List[Dict]:
"""Get task log lines"""
if not self.proxmox:
return []
try:
log = self.proxmox.nodes(node).tasks(upid).log.get(start=start, limit=limit)
return log
except Exception as e:
logger.error(f"Error getting task log {upid}: {e}")
return []
def restore_vm(self, node: str, vmid: int, archive: str, storage: str,
new_vmid: int = None, start: bool = False, unique: bool = True) -> Dict:
"""
Restore a QEMU VM from backup.
archive: volume id like 'local:backup/vzdump-qemu-100-...'
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {
"vmid": new_vmid if new_vmid else vmid,
"archive": archive,
"storage": storage,
"start": 1 if start else 0,
"unique": 1 if unique else 0,
}
upid = self.proxmox.nodes(node).qemu.post(**{"restore": 1, **params})
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error restoring VM {vmid} on {node}: {e}")
return {"success": False, "error": str(e)}
def restore_lxc(self, node: str, vmid: int, archive: str, storage: str,
new_vmid: int = None, start: bool = False) -> Dict:
"""Restore an LXC container from backup"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {
"vmid": new_vmid if new_vmid else vmid,
"ostemplate": archive,
"storage": storage,
"restore": 1,
"start": 1 if start else 0,
}
upid = self.proxmox.nodes(node).lxc.post(**params)
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error restoring LXC {vmid} on {node}: {e}")
return {"success": False, "error": str(e)}
def delete_backup(self, node: str, storage: str, volid: str) -> Dict:
"""Delete a specific backup file by its volume ID"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).storage(storage).content(volid).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting backup {volid}: {e}")
return {"success": False, "error": str(e)}
def get_proxmox_resources(host: str, user: str = "root@pam",
password: str = None, token_name: str = None,

View File

@@ -441,6 +441,13 @@ def _create_permissions() -> PermissionRegistry:
category="Backups",
requires=["backup:view"]
))
registry.register(Permission(
resource="backup", action="restore",
display_name="Restore Backups",
description="Restore VM/LXC from backups",
category="Backups",
requires=["backup:view"]
))
registry.register(Permission(
resource="backup", action="delete",
display_name="Delete Backups",
@@ -450,10 +457,10 @@ def _create_permissions() -> PermissionRegistry:
))
registry.register(Permission(
resource="backup", action="manage",
display_name="Restore Backups",
description="Restore from backups",
display_name="Manage Backups",
description="Manage storages and scheduled backup jobs",
category="Backups",
requires=["backup:view", "vm:create"]
requires=["backup:view"]
))
# IPAM permissions

View File

@@ -0,0 +1,237 @@
"""
Backup Scheduler Service
========================
APScheduler-based service for running scheduled backup jobs defined in the database.
Runs vzdump on the configured Proxmox server at the specified cron times.
"""
from datetime import datetime, timezone
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from loguru import logger
from ..db import SessionLocal
from ..models import BackupJob, ProxmoxServer
from ..proxmox_client import ProxmoxClient
# Singleton scheduler instance
_scheduler: Optional[AsyncIOScheduler] = None
def get_scheduler() -> AsyncIOScheduler:
global _scheduler
if _scheduler is None:
_scheduler = AsyncIOScheduler(timezone="UTC")
return _scheduler
class BackupScheduler:
"""Wrapper around APScheduler for managing backup jobs"""
def __init__(self):
self._aps = get_scheduler()
def _job_id(self, job_id: int) -> str:
return f"backup_job_{job_id}"
def add_job_from_model(self, job: BackupJob) -> None:
"""Register a BackupJob in APScheduler"""
if not job.enabled:
return
job_id_str = self._job_id(job.id)
# Parse cron expression (5 or 6 fields)
parts = job.cron_expression.strip().split()
if len(parts) == 5:
minute, hour, day, month, day_of_week = parts
trigger = CronTrigger(
minute=minute, hour=hour,
day=day, month=month, day_of_week=day_of_week,
timezone="UTC",
)
else:
logger.warning(f"BackupJob {job.id}: invalid cron expression '{job.cron_expression}'")
return
# Remove existing job if any
if self._aps.get_job(job_id_str):
self._aps.remove_job(job_id_str)
self._aps.add_job(
run_backup_job,
trigger=trigger,
args=[job.id],
id=job_id_str,
replace_existing=True,
misfire_grace_time=300,
)
logger.info(f"Scheduled backup job {job.id} with cron '{job.cron_expression}'")
def update_job_from_model(self, job: BackupJob) -> None:
"""Update (replace) a job in APScheduler"""
self.remove_job(job.id)
if job.enabled:
self.add_job_from_model(job)
def remove_job(self, job_id: int) -> None:
"""Remove a job from APScheduler"""
job_id_str = self._job_id(job_id)
if self._aps.get_job(job_id_str):
self._aps.remove_job(job_id_str)
logger.info(f"Removed backup job {job_id} from scheduler")
def load_all_from_db(self) -> int:
"""Load and schedule all enabled jobs from the database on startup"""
db = SessionLocal()
count = 0
try:
jobs = db.query(BackupJob).filter(BackupJob.enabled.is_(True)).all()
for job in jobs:
try:
self.add_job_from_model(job)
count += 1
except Exception as e:
logger.warning(f"Could not schedule backup job {job.id}: {e}")
finally:
db.close()
logger.info(f"Loaded {count} backup jobs from database")
return count
def start(self) -> None:
if not self._aps.running:
self._aps.start()
logger.info("Backup scheduler started")
def shutdown(self) -> None:
if self._aps.running:
self._aps.shutdown(wait=False)
logger.info("Backup scheduler stopped")
# Module-level singleton
scheduler = BackupScheduler()
async def run_backup_job(job_id: int) -> None:
"""Execute a single backup job. Called by APScheduler or manual trigger."""
db = SessionLocal()
try:
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
logger.warning(f"Backup job {job_id} not found, skipping")
return
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == job.server_id).first()
if not server:
logger.error(f"Backup job {job_id}: server {job.server_id} not found")
_update_job_status(db, job, "failed", "Server not found")
return
_update_job_status(db, job, "running")
logger.info(f"Running backup job {job_id} for vmids={job.vmids} on {server.name}/{job.node}")
try:
client = _build_client(server)
except Exception as e:
_update_job_status(db, job, "failed", str(e))
return
vmids = job.vmids
if vmids == ["all"] or vmids == "all":
target_vmids = ["all"]
else:
target_vmids = [int(v) for v in vmids] if vmids else []
errors = []
last_upid = None
for vmid in (target_vmids if target_vmids != ["all"] else ["all"]):
try:
result = client.create_backup(
node=job.node,
vmid=vmid if vmid != "all" else 0,
storage=job.storage,
mode=job.mode,
compress=job.compress,
remove=job.keep_last,
notes=job.notes,
)
if result.get("success"):
last_upid = result.get("upid")
logger.info(f"Backup job {job_id} VMID {vmid}: started, UPID={last_upid}")
else:
errors.append(f"VMID {vmid}: {result.get('error', 'unknown error')}")
except Exception as e:
errors.append(f"VMID {vmid}: {str(e)}")
if errors:
_update_job_status(db, job, "failed", "; ".join(errors), last_upid)
else:
_update_job_status(db, job, "success", None, last_upid)
# Send notification to all admins
try:
from ..services.notification_service import NotificationService
from ..models import User
status_text = "успешно" if not errors else "с ошибками"
level = "success" if not errors else "warning"
admins = db.query(User).filter(User.is_admin == True).all() # noqa: E712
for admin in admins:
NotificationService.notify_system_event(
db=db,
user_id=admin.id,
title=f"Резервное копирование #{job_id}",
message=f"Задание завершено {status_text}. VMIDs: {vmids}",
level=level,
)
except Exception as e:
logger.warning(f"Could not send backup notification: {e}")
finally:
db.close()
def _build_client(server: ProxmoxServer) -> ProxmoxClient:
"""Build a ProxmoxClient from a ProxmoxServer model"""
if server.use_password:
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl,
)
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl,
)
def _update_job_status(db, job: BackupJob, status: str,
error: str = None, upid: str = None) -> None:
"""Update job tracking fields in DB"""
try:
job.last_run_at = datetime.now(timezone.utc)
job.last_status = status
job.last_error = error
if upid:
job.last_upid = upid
db.commit()
except Exception as e:
logger.warning(f"Could not update backup job {job.id} status: {e}")
try:
db.rollback()
except Exception:
pass
def start_backup_scheduler() -> BackupScheduler:
"""Start the backup scheduler and load jobs from DB. Called from app lifespan."""
scheduler.load_all_from_db()
scheduler.start()
return scheduler

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

View File

@@ -0,0 +1,911 @@
{% extends "base.html" %}
{% block title %}{{ t('nav_backups') }}{% endblock %}
{% block content %}
<div class="page-container">
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="page-title mb-0">
<svg class="material-icon me-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:28px;height:28px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{{ t('nav_backups') }}
</h1>
</div>
<div class="d-flex gap-2">
<select id="serverSelect" class="form-select form-select-sm" style="min-width:180px" onchange="onServerChange()">
<option value="">{{ t('select_server') }}</option>
{% for s in servers %}
<option value="{{ s.id }}" data-name="{{ s.name }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-4" id="backupTabs">
<li class="nav-item">
<a class="nav-link active" id="tab-backups" href="#" onclick="showTab('backups',this)">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 3H8L2 7h20z"/></svg>
{{ t('backup_tab_backups') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-storages" href="#" onclick="showTab('storages',this)">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/></svg>
{{ t('backup_tab_storages') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-jobs" href="#" onclick="showTab('jobs',this)">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{ t('backup_tab_schedule') }}
</a>
</li>
</ul>
<!-- =================== TAB: BACKUPS =================== -->
<div id="pane-backups">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ t('backup_list_title') }}</h5>
<div class="d-flex gap-2 align-items-center">
<select id="storageSelectBackups" class="form-select form-select-sm" style="min-width:150px" onchange="loadBackups()">
<option value="">{{ t('select_storage') }}</option>
</select>
<input type="number" id="filterVmid" class="form-control form-control-sm" placeholder="VMID" style="width:90px" oninput="loadBackups()">
<button class="btn btn-primary btn-sm" onclick="openCreateBackupModal()">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t('backup_create') }}
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="backupsTable">
<thead><tr>
<th>VMID</th>
<th>{{ t('backup_col_type') }}</th>
<th>{{ t('backup_col_filename') }}</th>
<th>{{ t('backup_col_size') }}</th>
<th>{{ t('backup_col_date') }}</th>
<th>{{ t('backup_col_format') }}</th>
<th class="text-end">{{ t('actions') }}</th>
</tr></thead>
<tbody id="backupsTbody">
<tr><td colspan="7" class="text-center py-4 text-muted">{{ t('select_server_and_storage') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- =================== TAB: STORAGES =================== -->
<div id="pane-storages" class="d-none">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ t('backup_storages_title') }}</h5>
<button class="btn btn-primary btn-sm" onclick="openAddStorageModal()">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t('backup_add_storage') }}
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead><tr>
<th>{{ t('backup_storage_id') }}</th>
<th>{{ t('backup_storage_type') }}</th>
<th>{{ t('backup_storage_content') }}</th>
<th>{{ t('backup_storage_shared') }}</th>
<th>{{ t('backup_storage_usage') }}</th>
<th>{{ t('status') }}</th>
<th class="text-end">{{ t('actions') }}</th>
</tr></thead>
<tbody id="storagesTbody">
<tr><td colspan="7" class="text-center py-4 text-muted">{{ t('select_server') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- =================== TAB: SCHEDULE =================== -->
<div id="pane-jobs" class="d-none">
<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()">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t('backup_add_job') }}
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead><tr>
<th>{{ t('server') }}</th>
<th>{{ t('node') }}</th>
<th>VMIDs</th>
<th>{{ t('backup_storage_id') }}</th>
<th>Cron</th>
<th>{{ t('backup_job_last_run') }}</th>
<th>{{ t('status') }}</th>
<th class="text-end">{{ t('actions') }}</th>
</tr></thead>
<tbody id="jobsTbody">
<tr><td colspan="8" class="text-center py-4 text-muted" id="jobsEmpty">{{ t('loading') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: CREATE BACKUP =================== -->
<div class="modal fade" id="createBackupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_create_title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ t('node') }}</label>
<select id="cbNode" class="form-select" required></select>
</div>
<div class="mb-3">
<label class="form-label">VMID</label>
<input type="number" id="cbVmid" class="form-control" placeholder="100" required>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_id') }}</label>
<select id="cbStorage" class="form-select" required></select>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_mode') }}</label>
<select id="cbMode" class="form-select">
<option value="snapshot">snapshot</option>
<option value="suspend">suspend</option>
<option value="stop">stop</option>
</select>
</div>
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_compress') }}</label>
<select id="cbCompress" class="form-select">
<option value="zstd">zstd</option>
<option value="gzip">gzip</option>
<option value="lzo">lzo</option>
<option value="0">{{ t('none') }}</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_keep') }}</label>
<input type="number" id="cbRemove" class="form-control" value="1" min="0">
<div class="form-text">{{ t('backup_keep_hint') }}</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_notes') }}</label>
<input type="text" id="cbNotes" class="form-control" placeholder="{{ t('backup_notes_placeholder') }}">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="submitCreateBackup()">{{ t('backup_create') }}</button>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: RESTORE =================== -->
<div class="modal fade" id="restoreModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_restore_title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small" id="restoreInfo"></div>
<div class="mb-3">
<label class="form-label">{{ t('backup_restore_storage') }}</label>
<select id="restoreStorage" class="form-select"></select>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_restore_new_vmid') }}</label>
<input type="number" id="restoreNewVmid" class="form-control" placeholder="{{ t('backup_restore_vmid_hint') }}">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="restoreStart">
<label class="form-check-label">{{ t('backup_restore_start') }}</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="restoreUnique" checked>
<label class="form-check-label">{{ t('backup_restore_unique') }}</label>
</div>
<input type="hidden" id="restoreArchive">
<input type="hidden" id="restoreNode">
<input type="hidden" id="restoreVmid">
<input type="hidden" id="restoreVmType">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-warning" onclick="submitRestore()">{{ t('backup_restore_btn') }}</button>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: TASK LOG =================== -->
<div class="modal fade" id="taskLogModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_task_log') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="taskLogStatus" class="px-3 pt-3"></div>
<pre id="taskLogContent" style="max-height:400px;overflow-y:auto;background:var(--bs-dark,#1e1e2e);color:#cdd6f4;padding:1rem;margin:0;font-size:.82rem;"></pre>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: ADD STORAGE =================== -->
<div class="modal fade" id="addStorageModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_add_storage') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">ID</label>
<input type="text" id="asId" class="form-control" placeholder="my-backup-storage" required>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_type') }}</label>
<select id="asType" class="form-select" onchange="onStorageTypeChange(this.value)">
<option value="dir">dir</option>
<option value="nfs">NFS</option>
<option value="cifs">CIFS/SMB</option>
<option value="pbs">PBS (Proxmox Backup Server)</option>
<option value="lvm">LVM</option>
<option value="zfspool">ZFS Pool</option>
</select>
</div>
<div id="asTypeFields">
<!-- dir -->
<div class="mb-3 as-field-dir">
<label class="form-label">{{ t('backup_storage_path') }}</label>
<input type="text" id="asPath" class="form-control" placeholder="/mnt/backup">
</div>
<!-- nfs -->
<div class="mb-3 as-field-nfs d-none">
<label class="form-label">NFS Server</label>
<input type="text" id="asNfsServer" class="form-control" placeholder="192.168.1.10">
</div>
<div class="mb-3 as-field-nfs d-none">
<label class="form-label">Export path</label>
<input type="text" id="asNfsExport" class="form-control" placeholder="/export/backup">
</div>
<!-- cifs -->
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">CIFS Server</label>
<input type="text" id="asCifsServer" class="form-control" placeholder="192.168.1.10">
</div>
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">Share</label>
<input type="text" id="asCifsShare" class="form-control" placeholder="backup">
</div>
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">Username</label>
<input type="text" id="asCifsUser" class="form-control">
</div>
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">Password</label>
<input type="password" id="asCifsPass" class="form-control">
</div>
<!-- pbs -->
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">PBS Server</label>
<input type="text" id="asPbsServer" class="form-control" placeholder="pbs.example.com">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Datastore</label>
<input type="text" id="asPbsDatastore" class="form-control" placeholder="main">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Username</label>
<input type="text" id="asPbsUser" class="form-control" placeholder="backup@pbs">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Password / Token</label>
<input type="password" id="asPbsPass" class="form-control">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Fingerprint</label>
<input type="text" id="asPbsFingerprint" class="form-control" placeholder="A0:...">
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_content') }}</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="asContentBackup" value="backup" checked>
<label class="form-check-label">backup</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="asContentImages" value="images">
<label class="form-check-label">images</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="asContentIso" value="iso">
<label class="form-check-label">iso</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="submitAddStorage()">{{ t('save') }}</button>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: ADD JOB =================== -->
<div class="modal fade" id="addJobModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addJobModalTitle">{{ t('backup_add_job') }}</h5>
<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>
<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>
<select id="ajNode" class="form-select"></select>
</div>
</div>
<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>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_id') }}</label>
<select id="ajStorage" class="form-select"></select>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_mode') }}</label>
<select id="ajMode" class="form-select">
<option value="snapshot">snapshot</option>
<option value="suspend">suspend</option>
<option value="stop">stop</option>
</select>
</div>
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_compress') }}</label>
<select id="ajCompress" class="form-select">
<option value="zstd">zstd</option>
<option value="gzip">gzip</option>
<option value="lzo">lzo</option>
<option value="0">{{ t('none') }}</option>
</select>
</div>
</div>
<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>
</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>
<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>
</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">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="submitSaveJob()">{{ t('save') }}</button>
</div>
</div>
</div>
</div>
<script>
let currentServerId = null;
let currentServerNodes = [];
let serversData = [{% for s in servers %}{"id":{{ s.id }},"name":"{{ s.name }}"},{% endfor %}];
// ===== Tab switching =====
function showTab(name, el) {
document.querySelectorAll('[id^="pane-"]').forEach(p => p.classList.add('d-none'));
document.querySelectorAll('#backupTabs .nav-link').forEach(l => l.classList.remove('active'));
document.getElementById('pane-' + name).classList.remove('d-none');
el.classList.add('active');
if (name === 'storages') loadStorages();
if (name === 'jobs') loadJobs();
}
function onServerChange() {
currentServerId = document.getElementById('serverSelect').value || null;
// Refresh current tab
const activeTab = document.querySelector('#backupTabs .nav-link.active')?.id?.replace('tab-','');
if (activeTab === 'storages') loadStorages();
if (activeTab === 'backups') { populateStorageDropdown(); }
if (currentServerId) fetchNodes(currentServerId).then(nodes => { currentServerNodes = nodes; });
}
// ===== Nodes =====
async function fetchNodes(serverId) {
try {
const data = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
return (data.nodes || []).filter(Boolean);
} catch { return []; }
}
function fillNodeSelect(selectId, nodes) {
const sel = document.getElementById(selectId);
sel.innerHTML = '';
nodes.forEach(n => { const o = document.createElement('option'); o.value = n; o.textContent = n; sel.appendChild(o); });
}
// ===== Storage dropdown =====
async function populateStorageDropdown(targetId = 'storageSelectBackups') {
if (!currentServerId) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`);
const backupStorages = (r.storages || []).filter(s => (s.content || '').includes('backup'));
const sel = document.getElementById(targetId);
sel.innerHTML = '<option value="">{{ t("select_storage") }}</option>';
backupStorages.forEach(s => {
const o = document.createElement('option');
o.value = s.storage;
o.textContent = `${s.storage} (${s.type})`;
sel.appendChild(o);
});
} catch(e) { console.error(e); }
}
// ===== BACKUPS Tab =====
async function loadBackups() {
if (!currentServerId) return;
const storage = document.getElementById('storageSelectBackups').value;
if (!storage) { document.getElementById('backupsTbody').innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("select_storage") }}</td></tr>'; return; }
// need node - pick first node
if (!currentServerNodes.length) currentServerNodes = await fetchNodes(currentServerId);
if (!currentServerNodes.length) return;
const node = currentServerNodes[0];
const vmid = document.getElementById('filterVmid').value || null;
let url = `/proxmox/api/backups/list/${currentServerId}?node=${node}&storage=${storage}`;
if (vmid) url += `&vmid=${vmid}`;
try {
const r = await fetchWithAuth(url);
const backups = r.backups || [];
const tbody = document.getElementById('backupsTbody');
if (!backups.length) { tbody.innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("backup_empty") }}</td></tr>'; return; }
tbody.innerHTML = backups.map(b => {
const vmid = b.vmid || '-';
const vtype = b.content ? b.content.split(':')[0] : '-';
const fname = (b.volid || '').split('/').pop();
const sz = formatSize(b.size);
const dt = b.ctime ? new Date(b.ctime * 1000).toLocaleString() : '-';
const fmt = b.format || '-';
return `<tr>
<td>${vmid}</td>
<td><span class="badge bg-secondary">${vtype}</span></td>
<td class="font-monospace small text-truncate" style="max-width:260px" title="${b.volid}">${fname}</td>
<td>${sz}</td>
<td>${dt}</td>
<td>${fmt}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-warning me-1" onclick='openRestoreModal(${JSON.stringify(b)}, "${node}", "${storage}")' title="{{ t('backup_restore_btn') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.9"/></svg>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteBackup(${JSON.stringify(b.volid)}, '${node}', '${storage}')" title="{{ t('delete') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
</button>
</td>
</tr>`;
}).join('');
} catch(e) { console.error(e); }
}
function formatSize(bytes) {
if (!bytes) return '-';
const gb = bytes / (1024**3);
if (gb >= 1) return gb.toFixed(2) + ' GB';
return (bytes / (1024**2)).toFixed(0) + ' MB';
}
async function deleteBackup(volid, node, storage) {
if (!confirm('{{ t("backup_delete_confirm") }}')) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/${currentServerId}/backup`, {
method: 'DELETE',
body: JSON.stringify({ node, storage, volid }),
});
if (r.success) { showToast('{{ t("success") }}', '{{ t("backup_deleted") }}', 'success'); loadBackups(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== CREATE BACKUP =====
async function openCreateBackupModal() {
if (!currentServerId) { showToast('', '{{ t("select_server") }}', 'warning'); return; }
if (!currentServerNodes.length) currentServerNodes = await fetchNodes(currentServerId);
fillNodeSelect('cbNode', currentServerNodes);
await populateStorageDropdown('cbStorage');
new bootstrap.Modal(document.getElementById('createBackupModal')).show();
}
async function submitCreateBackup() {
const payload = {
server_id: parseInt(currentServerId),
node: document.getElementById('cbNode').value,
vmid: document.getElementById('cbVmid').value,
storage: document.getElementById('cbStorage').value,
mode: document.getElementById('cbMode').value,
compress: document.getElementById('cbCompress').value,
remove: parseInt(document.getElementById('cbRemove').value),
notes: document.getElementById('cbNotes').value || null,
};
try {
const r = await fetchWithAuth('/proxmox/api/backups/create', { method: 'POST', body: JSON.stringify(payload) });
if (r.success) {
bootstrap.Modal.getInstance(document.getElementById('createBackupModal'))?.hide();
showToast('{{ t("success") }}', '{{ t("backup_started") }}', 'success');
if (r.upid) openTaskLog(currentServerId, payload.node, r.upid);
}
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== RESTORE =====
function openRestoreModal(backup, node, storage) {
document.getElementById('restoreArchive').value = backup.volid;
document.getElementById('restoreNode').value = node;
document.getElementById('restoreVmid').value = backup.vmid || '';
document.getElementById('restoreVmType').value = backup.content?.includes('lxc') ? 'lxc' : 'qemu';
document.getElementById('restoreNewVmid').value = '';
document.getElementById('restoreStart').checked = false;
document.getElementById('restoreUnique').checked = true;
document.getElementById('restoreInfo').textContent = (backup.volid || '').split('/').pop();
populateStorageDropdown('restoreStorage');
document.getElementById('restoreStorage').value = storage;
new bootstrap.Modal(document.getElementById('restoreModal')).show();
}
async function submitRestore() {
const payload = {
server_id: parseInt(currentServerId),
node: document.getElementById('restoreNode').value,
vmid: document.getElementById('restoreVmid').value,
archive: document.getElementById('restoreArchive').value,
storage: document.getElementById('restoreStorage').value,
vm_type: document.getElementById('restoreVmType').value,
new_vmid: document.getElementById('restoreNewVmid').value || null,
start: document.getElementById('restoreStart').checked,
unique: document.getElementById('restoreUnique').checked,
};
try {
const r = await fetchWithAuth('/proxmox/api/backups/restore', { method: 'POST', body: JSON.stringify(payload) });
if (r.success) {
bootstrap.Modal.getInstance(document.getElementById('restoreModal'))?.hide();
showToast('{{ t("success") }}', '{{ t("backup_restore_started") }}', 'success');
if (r.upid) openTaskLog(currentServerId, payload.node, r.upid);
}
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== TASK LOG =====
async function openTaskLog(serverId, node, upid) {
document.getElementById('taskLogContent').textContent = '...';
document.getElementById('taskLogStatus').textContent = '';
const modal = new bootstrap.Modal(document.getElementById('taskLogModal'));
modal.show();
let done = false;
while (!done) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/task/${serverId}/${node}/${encodeURIComponent(upid)}`);
const status = r.status || {};
const lines = (r.log || []).map(l => l.t || '').join('\n');
document.getElementById('taskLogContent').textContent = lines;
document.getElementById('taskLogContent').scrollTop = document.getElementById('taskLogContent').scrollHeight;
const st = status.status || '';
if (st === 'stopped') {
const exitOk = status.exitstatus === 'OK';
document.getElementById('taskLogStatus').innerHTML = exitOk
? '<span class="badge bg-success">OK</span>'
: `<span class="badge bg-danger">${status.exitstatus || 'FAILED'}</span>`;
done = true;
} else {
document.getElementById('taskLogStatus').innerHTML = '<span class="badge bg-info">{{ t("running") }}</span>';
}
} catch(e) { done = true; }
if (!done) await new Promise(r => setTimeout(r, 2000));
}
}
// ===== STORAGES Tab =====
async function loadStorages() {
if (!currentServerId) { document.getElementById('storagesTbody').innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("select_server") }}</td></tr>'; return; }
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`);
const storages = r.storages || [];
const tbody = document.getElementById('storagesTbody');
if (!storages.length) { tbody.innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("backup_no_storages") }}</td></tr>'; return; }
tbody.innerHTML = storages.map(s => {
const usedPct = s.total ? Math.round(s.used / s.total * 100) : null;
const usageBar = usedPct !== null ? `<div class="progress" style="height:6px;min-width:80px"><div class="progress-bar ${usedPct > 80 ? 'bg-danger' : 'bg-success'}" style="width:${usedPct}%"></div></div><small>${usedPct}%</small>` : '-';
return `<tr>
<td class="font-monospace">${s.storage}</td>
<td><span class="badge bg-secondary">${s.type}</span></td>
<td class="small text-muted">${s.content || '-'}</td>
<td>${s.shared ? '<span class="badge bg-info">shared</span>' : '<span class="badge bg-secondary">local</span>'}</td>
<td>${usageBar}</td>
<td>${s.active ? '<span class="badge bg-success">active</span>' : '<span class="badge bg-danger">inactive</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="deleteStorage('${s.storage}')" title="{{ t('delete') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
</button>
</td>
</tr>`;
}).join('');
} catch(e) { console.error(e); }
}
async function deleteStorage(storageId) {
if (!confirm('{{ t("backup_storage_delete_confirm") }}')) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}/${storageId}`, { method: 'DELETE' });
if (r.success) { showToast('{{ t("success") }}', '{{ t("backup_storage_deleted") }}', 'success'); loadStorages(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
function openAddStorageModal() {
if (!currentServerId) { showToast('', '{{ t("select_server") }}', 'warning'); return; }
document.getElementById('asId').value = '';
document.getElementById('asType').value = 'dir';
onStorageTypeChange('dir');
new bootstrap.Modal(document.getElementById('addStorageModal')).show();
}
function onStorageTypeChange(type) {
document.querySelectorAll('[class*="as-field-"]').forEach(el => el.classList.add('d-none'));
document.querySelectorAll('.as-field-' + type).forEach(el => el.classList.remove('d-none'));
}
async function submitAddStorage() {
const type = document.getElementById('asType').value;
const content = [];
if (document.getElementById('asContentBackup').checked) content.push('backup');
if (document.getElementById('asContentImages').checked) content.push('images');
if (document.getElementById('asContentIso').checked) content.push('iso');
const payload = {
storage: document.getElementById('asId').value.trim(),
type,
content: content.join(','),
};
if (type === 'dir') payload.path = document.getElementById('asPath').value;
if (type === 'nfs') { payload.server = document.getElementById('asNfsServer').value; payload.export = document.getElementById('asNfsExport').value; }
if (type === 'cifs') { payload.server = document.getElementById('asCifsServer').value; payload.share = document.getElementById('asCifsShare').value; payload.username = document.getElementById('asCifsUser').value; payload.password = document.getElementById('asCifsPass').value; }
if (type === 'pbs') { payload.server = document.getElementById('asPbsServer').value; payload.datastore = document.getElementById('asPbsDatastore').value; payload.username = document.getElementById('asPbsUser').value; payload.password = document.getElementById('asPbsPass').value; if (document.getElementById('asPbsFingerprint').value) payload.fingerprint = document.getElementById('asPbsFingerprint').value; }
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`, { method: 'POST', body: JSON.stringify(payload) });
if (r.success) { bootstrap.Modal.getInstance(document.getElementById('addStorageModal'))?.hide(); showToast('{{ t("success") }}', '{{ t("backup_storage_added") }}', 'success'); loadStorages(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== JOBS Tab =====
async function loadJobs() {
try {
const r = await fetchWithAuth('/proxmox/api/backups/jobs');
const jobs = r.jobs || [];
const tbody = document.getElementById('jobsTbody');
if (!jobs.length) { tbody.innerHTML = '<tr><td colspan="8" class="text-center py-3 text-muted">{{ t("backup_no_jobs") }}</td></tr>'; return; }
tbody.innerHTML = jobs.map(j => {
const srv = serversData.find(s => s.id === j.server_id);
const srvName = srv ? srv.name : j.server_id;
const lastRun = j.last_run_at ? new Date(j.last_run_at).toLocaleString() : '-';
const statusBadge = j.last_status === 'success' ? 'bg-success' : j.last_status === 'failed' ? 'bg-danger' : j.last_status === 'running' ? 'bg-info' : 'bg-secondary';
const enabledIcon = j.enabled
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M20 6L9 17l-5-5"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
return `<tr>
<td>${srvName}</td>
<td>${j.node}</td>
<td class="font-monospace small">${Array.isArray(j.vmids) ? j.vmids.join(', ') : j.vmids}</td>
<td>${j.storage}</td>
<td class="font-monospace small">${j.cron_expression}</td>
<td class="small">${lastRun}</td>
<td>${j.last_status ? `<span class="badge ${statusBadge}">${j.last_status}</span>` : '-'}</td>
<td class="text-end d-flex gap-1 justify-content-end">
<button class="btn btn-sm ${j.enabled ? 'btn-outline-warning' : 'btn-outline-success'}" onclick="toggleJob(${j.id})" title="${j.enabled ? '{{ t("backup_job_disable") }}' : '{{ t("backup_job_enable") }}'}">${enabledIcon}</button>
<button class="btn btn-sm btn-outline-primary" onclick="runJobNow(${j.id})" title="{{ t('backup_run_now') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick='openEditJobModal(${JSON.stringify(j)})' title="{{ t('edit') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteJob(${j.id})" title="{{ t('delete') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
</button>
</td>
</tr>`;
}).join('');
} catch(e) { console.error(e); }
}
async function onJobServerChange(serverId) {
const nodes = await fetchNodes(serverId);
fillNodeSelect('ajNode', nodes);
await populateJobStorages(serverId);
}
async function populateJobStorages(serverId) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${serverId}`);
const backupStorages = (r.storages || []).filter(s => (s.content || '').includes('backup'));
const sel = document.getElementById('ajStorage');
sel.innerHTML = '';
backupStorages.forEach(s => { const o = document.createElement('option'); o.value = s.storage; o.textContent = `${s.storage} (${s.type})`; sel.appendChild(o); });
} catch(e) {}
}
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;
const firstServer = serversData[0];
if (firstServer) {
document.getElementById('ajServer').value = firstServer.id;
await onJobServerChange(firstServer.id);
}
new bootstrap.Modal(document.getElementById('addJobModal')).show();
}
async function openEditJobModal(job) {
document.getElementById('addJobModalTitle').textContent = '{{ t("backup_edit_job") }}';
document.getElementById('ajJobId').value = job.id;
document.getElementById('ajServer').value = job.server_id;
await onJobServerChange(job.server_id);
document.getElementById('ajNode').value = job.node;
document.getElementById('ajVmids').value = Array.isArray(job.vmids) ? job.vmids.join(', ') : job.vmids;
document.getElementById('ajStorage').value = job.storage;
document.getElementById('ajMode').value = job.mode;
document.getElementById('ajCompress').value = job.compress;
document.getElementById('ajCron').value = job.cron_expression;
document.getElementById('ajKeepLast').value = job.keep_last;
document.getElementById('ajKeepDaily').value = job.keep_daily;
document.getElementById('ajKeepWeekly').value = job.keep_weekly;
document.getElementById('ajKeepMonthly').value = job.keep_monthly;
document.getElementById('ajNotes').value = job.notes || '';
document.getElementById('ajEnabled').checked = job.enabled;
new bootstrap.Modal(document.getElementById('addJobModal')).show();
}
async function submitSaveJob() {
const jobId = document.getElementById('ajJobId').value;
const vmidsRaw = document.getElementById('ajVmids').value.trim();
let vmids;
if (vmidsRaw.toLowerCase() === 'all') {
vmids = ['all'];
} else {
vmids = vmidsRaw.split(/[\s,]+/).map(v => parseInt(v)).filter(v => !isNaN(v));
}
const payload = {
server_id: parseInt(document.getElementById('ajServer').value),
node: document.getElementById('ajNode').value,
vmids,
storage: document.getElementById('ajStorage').value,
mode: document.getElementById('ajMode').value,
compress: document.getElementById('ajCompress').value,
cron_expression: document.getElementById('ajCron').value.trim(),
keep_last: parseInt(document.getElementById('ajKeepLast').value),
keep_daily: parseInt(document.getElementById('ajKeepDaily').value),
keep_weekly: parseInt(document.getElementById('ajKeepWeekly').value),
keep_monthly: parseInt(document.getElementById('ajKeepMonthly').value),
notes: document.getElementById('ajNotes').value || null,
enabled: document.getElementById('ajEnabled').checked,
};
try {
const url = jobId ? `/proxmox/api/backups/jobs/${jobId}` : '/proxmox/api/backups/jobs';
const method = jobId ? 'PUT' : 'POST';
const r = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
if (r.success) {
bootstrap.Modal.getInstance(document.getElementById('addJobModal'))?.hide();
showToast('{{ t("success") }}', '{{ t("saved") }}', 'success');
loadJobs();
}
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
async function toggleJob(jobId) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}/toggle`, { method: 'PATCH' });
if (r.success) loadJobs();
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
async function runJobNow(jobId) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}/run-now`, { method: 'POST' });
if (r.success) { showToast('{{ t("success") }}', '{{ t("backup_job_triggered") }}', 'success'); setTimeout(loadJobs, 2000); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
async function deleteJob(jobId) {
if (!confirm('{{ t("backup_job_delete_confirm") }}')) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}`, { method: 'DELETE' });
if (r.success) { showToast('{{ t("success") }}', '{{ t("deleted") }}', 'success'); loadJobs(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== Toast helper =====
function showToast(title, msg, type = 'info') {
const id = 'toast_' + Date.now();
const colors = { success: 'bg-success', danger: 'bg-danger', warning: 'bg-warning text-dark', info: 'bg-info' };
const el = document.createElement('div');
el.className = `toast align-items-center text-white ${colors[type] || 'bg-secondary'} border-0`;
el.id = id;
el.setAttribute('role', 'alert');
el.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${title ? title + ': ' : ''}${msg}</strong></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
let container = document.getElementById('toastContainer');
if (!container) { container = document.createElement('div'); container.id = 'toastContainer'; container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; container.style.zIndex = 9999; document.body.appendChild(container); }
container.appendChild(el);
const t = new bootstrap.Toast(el, { delay: 4000 });
t.show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
// Init
document.addEventListener('DOMContentLoaded', () => {
loadJobs();
onStorageTypeChange('dir');
});
</script>
{% endblock %}

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<title>{{ title or t('app_name') }}</title>
<link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -561,6 +562,10 @@ 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><polyline points="10 9 9 9 8 9"></polyline></svg>
<span>{{ t('nav_templates') }}</span>
</a></li>
<li><a href="/backups" class="{{ 'active' if request.path.startswith('/backups') or request.path.startswith('/proxmox/api/backups/page') else '' }}" title="{{ t('nav_backups') }}">
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>{{ t('nav_backups') }}</span>
</a></li>
<li><a href="/ipam" class="{{ 'active' if request.path.startswith('/ipam') else '' }}" title="{{ t('nav_ipam') }}">
<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="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
<span>{{ t('nav_ipam') }}</span>

View File

@@ -64,6 +64,11 @@ DEFAULT_ROLES = [
"roles.view": True,
"roles.manage": True,
"notifications.manage": True,
"backup:view": True,
"backup:create": True,
"backup:restore": True,
"backup:delete": True,
"backup:manage": True,
}
},
{
@@ -103,6 +108,11 @@ DEFAULT_ROLES = [
"roles.view": False,
"roles.manage": False,
"notifications.manage": True,
"backup:view": True,
"backup:create": True,
"backup:restore": True,
"backup:delete": False,
"backup:manage": False,
}
},
{
@@ -984,6 +994,47 @@ def migrate_encrypt_passwords(conn):
logger.info(f"Migration 14: Encrypted {count} VM cloud-init password(s)")
def migrate_backup_jobs(conn):
"""Migration 15: Create backup_jobs table for scheduled backups"""
logger.info("Migration 15: Creating backup_jobs table...")
if table_exists(conn, 'backup_jobs'):
logger.info("Table backup_jobs already exists, skipping")
return
conn.execute(text("""
CREATE TABLE backup_jobs (
id SERIAL PRIMARY KEY,
server_id INTEGER NOT NULL REFERENCES proxmox_servers(id) ON DELETE CASCADE,
node VARCHAR(100) NOT NULL,
vmids JSONB NOT NULL DEFAULT '[]',
storage VARCHAR(100) NOT NULL,
mode VARCHAR(20) NOT NULL DEFAULT 'snapshot',
compress VARCHAR(20) NOT NULL DEFAULT 'zstd',
notes VARCHAR(500),
keep_last INTEGER NOT NULL DEFAULT 3,
keep_daily INTEGER NOT NULL DEFAULT 7,
keep_weekly INTEGER NOT NULL DEFAULT 4,
keep_monthly INTEGER NOT NULL DEFAULT 6,
cron_expression VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
last_run_at TIMESTAMP WITH TIME ZONE,
last_status VARCHAR(20),
last_error TEXT,
last_upid VARCHAR(200)
)
"""))
conn.execute(text("CREATE INDEX idx_backup_jobs_server ON backup_jobs(server_id)"))
conn.execute(text("CREATE INDEX idx_backup_jobs_enabled ON backup_jobs(enabled)"))
conn.execute(text("CREATE INDEX idx_backup_jobs_owner ON backup_jobs(owner_id)"))
logger.info("✓ backup_jobs table created")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1110,6 +1161,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"Password encryption migration: {e}")
conn.rollback()
# Migration 15: Backup Jobs
try:
migrate_backup_jobs(conn)
conn.commit()
except Exception as e:
logger.warning(f"Backup jobs migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)