Add favicon, backup scheduler, backup API and templates, RBAC and migration updates
This commit is contained in:
@@ -4,6 +4,7 @@ from .sdn import router as sdn_router
|
|||||||
from .snapshots import router as snapshots_router
|
from .snapshots import router as snapshots_router
|
||||||
from .vms import router as vms_router
|
from .vms import router as vms_router
|
||||||
from .tasks import router as tasks_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
|
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -12,3 +13,4 @@ router.include_router(sdn_router)
|
|||||||
router.include_router(snapshots_router)
|
router.include_router(snapshots_router)
|
||||||
router.include_router(vms_router)
|
router.include_router(vms_router)
|
||||||
router.include_router(tasks_router)
|
router.include_router(tasks_router)
|
||||||
|
router.include_router(backup_router)
|
||||||
|
|||||||
532
backend/app/api/proxmox/backup.py
Normal file
532
backend/app/api/proxmox/backup.py
Normal 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))
|
||||||
@@ -43,6 +43,14 @@ class I18nService:
|
|||||||
"ru": "Удалить",
|
"ru": "Удалить",
|
||||||
"en": "Delete"
|
"en": "Delete"
|
||||||
},
|
},
|
||||||
|
"saved": {
|
||||||
|
"ru": "Сохранено",
|
||||||
|
"en": "Saved"
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"ru": "Нет",
|
||||||
|
"en": "None"
|
||||||
|
},
|
||||||
"total": {
|
"total": {
|
||||||
"ru": "Всего",
|
"ru": "Всего",
|
||||||
"en": "Total"
|
"en": "Total"
|
||||||
@@ -452,10 +460,242 @@ class I18nService:
|
|||||||
"ru": "IPAM",
|
"ru": "IPAM",
|
||||||
"en": "IPAM"
|
"en": "IPAM"
|
||||||
},
|
},
|
||||||
|
"nav_backups": {
|
||||||
|
"ru": "Резервные копии",
|
||||||
|
"en": "Backups"
|
||||||
|
},
|
||||||
"nav_docker": {
|
"nav_docker": {
|
||||||
"ru": "Docker",
|
"ru": "Docker",
|
||||||
"en": "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": {
|
"nav_logs": {
|
||||||
"ru": "Логи",
|
"ru": "Логи",
|
||||||
"en": "Logs"
|
"en": "Logs"
|
||||||
@@ -2007,10 +2247,242 @@ class I18nService:
|
|||||||
"ru": "IPAM",
|
"ru": "IPAM",
|
||||||
"en": "IPAM"
|
"en": "IPAM"
|
||||||
},
|
},
|
||||||
|
"nav_backups": {
|
||||||
|
"ru": "Резервные копии",
|
||||||
|
"en": "Backups"
|
||||||
|
},
|
||||||
"nav_docker": {
|
"nav_docker": {
|
||||||
"ru": "Docker",
|
"ru": "Docker",
|
||||||
"en": "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": {
|
"nav_logs": {
|
||||||
"ru": "Логи",
|
"ru": "Логи",
|
||||||
"en": "Logs"
|
"en": "Logs"
|
||||||
|
|||||||
@@ -122,10 +122,19 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Background monitoring worker started")
|
logger.info("Background monitoring worker started")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Background worker startup failed: {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")
|
logger.info("Application startup complete")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
try:
|
try:
|
||||||
if 'scheduler' in locals():
|
if 'scheduler' in locals():
|
||||||
@@ -133,6 +142,13 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Background worker stopped")
|
logger.info("Background worker stopped")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Background worker shutdown failed: {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")
|
logger.info("Application shutdown")
|
||||||
|
|
||||||
@@ -219,6 +235,12 @@ def create_app() -> FastAPI:
|
|||||||
async def root():
|
async def root():
|
||||||
return RedirectResponse(url="/dashboard", status_code=302)
|
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
|
# Legacy redirect for old /servers URL
|
||||||
@app.get("/servers", include_in_schema=False)
|
@app.get("/servers", include_in_schema=False)
|
||||||
async def servers_redirect():
|
async def servers_redirect():
|
||||||
|
|||||||
@@ -845,4 +845,77 @@ class VMSnapshotArchive(Base):
|
|||||||
return f"<VMSnapshotArchive(id={self.id}, vmid={self.vmid}, snapname='{self.snapname}')>"
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -2895,6 +2895,181 @@ class ProxmoxClient:
|
|||||||
logger.error(f"Error getting snapshot {snapname} config: {e}")
|
logger.error(f"Error getting snapshot {snapname} config: {e}")
|
||||||
return None
|
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",
|
def get_proxmox_resources(host: str, user: str = "root@pam",
|
||||||
password: str = None, token_name: str = None,
|
password: str = None, token_name: str = None,
|
||||||
|
|||||||
@@ -441,6 +441,13 @@ def _create_permissions() -> PermissionRegistry:
|
|||||||
category="Backups",
|
category="Backups",
|
||||||
requires=["backup:view"]
|
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(
|
registry.register(Permission(
|
||||||
resource="backup", action="delete",
|
resource="backup", action="delete",
|
||||||
display_name="Delete Backups",
|
display_name="Delete Backups",
|
||||||
@@ -450,10 +457,10 @@ def _create_permissions() -> PermissionRegistry:
|
|||||||
))
|
))
|
||||||
registry.register(Permission(
|
registry.register(Permission(
|
||||||
resource="backup", action="manage",
|
resource="backup", action="manage",
|
||||||
display_name="Restore Backups",
|
display_name="Manage Backups",
|
||||||
description="Restore from backups",
|
description="Manage storages and scheduled backup jobs",
|
||||||
category="Backups",
|
category="Backups",
|
||||||
requires=["backup:view", "vm:create"]
|
requires=["backup:view"]
|
||||||
))
|
))
|
||||||
|
|
||||||
# IPAM permissions
|
# IPAM permissions
|
||||||
|
|||||||
237
backend/app/services/backup_scheduler.py
Normal file
237
backend/app/services/backup_scheduler.py
Normal 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
|
||||||
BIN
backend/app/static/img/favicon.ico
Normal file
BIN
backend/app/static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 658 B |
911
backend/app/templates/backups.html
Normal file
911
backend/app/templates/backups.html
Normal 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 %}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{{ title or t('app_name') }}</title>
|
<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="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-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<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>
|
<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>
|
<span>{{ t('nav_templates') }}</span>
|
||||||
</a></li>
|
</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') }}">
|
<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>
|
<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>
|
<span>{{ t('nav_ipam') }}</span>
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ DEFAULT_ROLES = [
|
|||||||
"roles.view": True,
|
"roles.view": True,
|
||||||
"roles.manage": True,
|
"roles.manage": True,
|
||||||
"notifications.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.view": False,
|
||||||
"roles.manage": False,
|
"roles.manage": False,
|
||||||
"notifications.manage": True,
|
"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)")
|
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):
|
def run_all_migrations(engine, db_session=None):
|
||||||
"""
|
"""
|
||||||
Run all migrations in order.
|
Run all migrations in order.
|
||||||
@@ -1110,6 +1161,14 @@ def run_all_migrations(engine, db_session=None):
|
|||||||
logger.warning(f"Password encryption migration: {e}")
|
logger.warning(f"Password encryption migration: {e}")
|
||||||
conn.rollback()
|
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("=" * 50)
|
||||||
logger.info("✓ All migrations completed successfully")
|
logger.info("✓ All migrations completed successfully")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|||||||
Reference in New Issue
Block a user