feat(instances): add VM/LXC migration, autostart/boot order, protection
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 52s
Deploy to Production / Trigger production update (push) Successful in 7s

- Migrate VMs/LXCs between cluster nodes with live progress tracking
- Configure autostart, boot order, startup/shutdown delays, and delete protection from the instance settings tab
- Group OS templates by detected OS (Ubuntu, AlmaLinux, etc.) instead of a single "Cloud Images" bucket
- Expand native Proxmox backup job management (schedule, compression, retention, guest selection, notifications)
- Add ru/en translations for the above
This commit is contained in:
markmorado
2026-07-03 02:40:58 +05:00
parent e8bce3e8ae
commit c12fbf13e5
30 changed files with 1411 additions and 111 deletions

24
.claude/settings.json Normal file
View File

@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);exts=('.py','.js','.ts','.tsx','.jsx','.astro','.vue','.svelte','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');vals=[str(t.get('file_path') or ''),str(t.get('pattern') or ''),str(t.get('path') or '')];j=' '.join(vals).lower().replace(chr(92),'/');tails=[('.'+x.rsplit('.',1)[-1]) for v in vals if v for x in [v.lower().replace(chr(92),'/').rsplit('/',1)[-1]] if '.' in x];sys.stdout.write('1' if 'graphify-out/' not in j and any(tl in exts for tl in tails) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
}
]
}
]
}
}

9
CLAUDE.md Normal file
View File

@@ -0,0 +1,9 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@@ -21,7 +21,7 @@ from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance, save
# Re-use deploy executor and update helper # Re-use deploy executor and update helper
from ..templates import _deploy_executor, _update_deploy_task # noqa: F401 from ..templates import _deploy_executor, _update_deploy_task, detect_os_group # noqa: F401
def _connect(server: ProxmoxServer) -> Optional[ProxmoxClient]: def _connect(server: ProxmoxServer) -> Optional[ProxmoxClient]:
@@ -519,6 +519,127 @@ def _do_clone_sync(task_id: int, server_id: int, src_vmid: int, node: str, vm_ty
db.close() db.close()
# ==================== Migrate ====================
def _do_migrate_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str,
target_node: str, target_storage: Optional[str], online: bool,
user_id: int, username: str):
"""Мигрировать VM/LXC на другую ноду кластера (фоновая задача с прогрессом)."""
db = SessionLocal()
server = None
is_lxc = (vm_type == 'lxc')
try:
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10,
vmid=vmid, node=node)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
_update_deploy_task(task_id, 'failed', 'Сервер не найден', 0, error='Proxmox сервер не найден')
return
client = _connect(server)
if not client:
_update_deploy_task(task_id, 'failed', 'Ошибка подключения к Proxmox', 10,
error='Не удалось подключиться к Proxmox серверу')
return
_update_deploy_task(task_id, 'running', f'Миграция → {target_node}...', 30,
vmid=vmid, node=node)
try:
if is_lxc:
upid = client.migrate_container(
node, vmid, target_node,
target_storage=target_storage or None, online=online,
)
else:
upid = client.migrate_vm(
node, vmid, target_node,
target_storage=target_storage or None, online=online,
)
except Exception as e:
_update_deploy_task(task_id, 'failed', 'Ошибка миграции', 30, error=f'Migrate failed: {e}')
return
if not upid:
_update_deploy_task(task_id, 'failed', 'Ошибка миграции', 30,
error='Proxmox не вернул UPID задачи миграции')
return
# Прогресс копирования диска (если есть) маппится в 30..95 из лога задачи
_update_deploy_task(task_id, 'running', 'Перенос данных...', 50, vmid=vmid, node=node)
ok = _wait_migrate_with_progress(client, task_id, node, upid, timeout=3600)
if not ok:
_update_deploy_task(task_id, 'failed', 'Миграция не удалась', 50,
error=f'Задача миграции завершилась с ошибкой. {_task_log_tail(client, node, upid)}'[:300])
return
# Обновляем ноду в локальной записи инстанса
try:
cached = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None),
).first()
if cached:
cached.node = target_node
db.commit()
except Exception as reg_err:
logger.warning(f"[MIGRATE #{task_id}] failed to update instance node: {reg_err}")
LoggingService.log_proxmox_action(
db=db, action='migrate',
resource_type='lxc' if is_lxc else 'vm', resource_id=vmid,
username=username, server_id=server_id,
server_name=server.name, node_name=node,
details={'target_node': target_node, 'target_storage': target_storage, 'online': online},
success=True,
)
_update_deploy_task(task_id, 'completed', f'Мигрировано на {target_node}', 100,
vmid=vmid, node=target_node)
logger.info(f"[MIGRATE #{task_id}] {vmid} {node} -> {target_node} by {username}")
except Exception as e:
err = str(e)
logger.error(f"[MIGRATE #{task_id}] failed: {err}")
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
finally:
db.close()
def _wait_migrate_with_progress(client, task_id: int, node: str, upid: str,
timeout: int = 3600) -> bool:
"""Дождаться задачи миграции, маппя % копирования диска из лога в 50..95."""
start = time.time()
log_pos = 0
last_reported = -1
while time.time() - start < timeout:
try:
lines = client.get_task_log(node, upid, start=log_pos, limit=500) or []
except Exception:
lines = []
pct = None
for line in lines:
log_pos = max(log_pos, line.get('n', log_pos) or log_pos)
m = _PCT_RE.search(line.get('t', '') or '')
if m:
try:
pct = float(m.group(1))
except ValueError:
pass
if pct is not None:
progress = 50 + int(min(pct, 100) * 0.45) # 50..95
if progress != last_reported:
last_reported = progress
_update_deploy_task(task_id, 'running', f'Перенос данных... {pct:.0f}%', progress)
status = client.get_task_status(node, upid)
if status and status.get('status') == 'stopped':
exit_status = status.get('exitstatus')
return exit_status == 'OK' or (
isinstance(exit_status, str) and exit_status.startswith('WARNINGS:'))
time.sleep(2)
logger.warning(f"Таймаут ожидания задачи миграции {upid}")
return False
# ==================== Image download ==================== # ==================== Image download ====================
# Расширения, которые Proxmox принимает для content=import (диск-образы). # Расширения, которые Proxmox принимает для content=import (диск-образы).
@@ -685,12 +806,24 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
db.close() db.close()
def _get_or_create_cloud_group(db) -> int: def _get_or_create_os_group(db, hint: Optional[str]) -> int:
"""Найти/создать группу шаблонов «Cloud Images» для импортированных образов.""" """Найти/создать группу шаблонов по ОС (Ubuntu, AlmaLinux, ...).
grp = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == 'Cloud Images').first()
Определяет группу по имени через detect_os_group(), чтобы импортированный
cloud-образ попадал в свою ОС-группу и фильтровался на странице шаблонов
(напр. AlmaLinux), а не в общую «Cloud Images».
"""
group_info = detect_os_group(hint or '')
group_name = group_info['name']
grp = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == group_name).first()
if not grp: if not grp:
grp = OSTemplateGroup(name='Cloud Images', icon='cloud', grp = OSTemplateGroup(
description='Импортированные cloud-образы', sort_order=50) name=group_name,
icon=group_info['icon'],
description=f'Шаблоны {group_name}',
sort_order=group_info['order'],
is_active=True,
)
db.add(grp) db.add(grp)
db.commit() db.commit()
db.refresh(grp) db.refresh(grp)
@@ -704,7 +837,8 @@ def _do_image_template_sync(task_id: int, server_id: int, node: str,
name: str, cores: int, memory: int, bridge: str, name: str, cores: int, memory: int, bridge: str,
ciuser: Optional[str], cipassword: Optional[str], ciuser: Optional[str], cipassword: Optional[str],
ssh_keys: Optional[str], os_name: Optional[str], ssh_keys: Optional[str], os_name: Optional[str],
version: Optional[str], user_id: int, username: str): version: Optional[str], user_id: int, username: str,
vmid: Optional[int] = None):
"""Скачать qcow2 cloud-образ и автоматически конвертировать в Proxmox VM-шаблон. """Скачать qcow2 cloud-образ и автоматически конвертировать в Proxmox VM-шаблон.
Пайплайн (без SSH, только API PVE 8.2+): Пайплайн (без SSH, только API PVE 8.2+):
@@ -750,7 +884,15 @@ def _do_image_template_sync(task_id: int, server_id: int, node: str,
# 2) Аллокация VMID и создание ВМ с импортом диска # 2) Аллокация VMID и создание ВМ с импортом диска
_update_deploy_task(task_id, 'running', 'Аллокация VMID...', 42, node=node) _update_deploy_task(task_id, 'running', 'Аллокация VMID...', 42, node=node)
new_vmid = client.get_next_vmid() or get_next_vmid(db, server_id) if vmid:
# Пользователь указал VMID явно — проверяем, что он свободен
if client.get_vm_status(node, vmid):
_update_deploy_task(task_id, 'failed', 'VMID уже занят', 42,
error=f'VMID {vmid} уже используется на ноде {node}')
return
new_vmid = vmid
else:
new_vmid = client.get_next_vmid() or get_next_vmid(db, server_id)
if not new_vmid: if not new_vmid:
_update_deploy_task(task_id, 'failed', 'Не удалось выделить VMID', 42, error='Failed to allocate VMID') _update_deploy_task(task_id, 'failed', 'Не удалось выделить VMID', 42, error='Failed to allocate VMID')
return return
@@ -804,7 +946,7 @@ def _do_image_template_sync(task_id: int, server_id: int, node: str,
# 5) Регистрация OSTemplate (становится доступен в деплое) # 5) Регистрация OSTemplate (становится доступен в деплое)
_update_deploy_task(task_id, 'running', 'Регистрация шаблона...', 98, vmid=new_vmid, node=node) _update_deploy_task(task_id, 'running', 'Регистрация шаблона...', 98, vmid=new_vmid, node=node)
try: try:
group_id = _get_or_create_cloud_group(db) group_id = _get_or_create_os_group(db, os_name or name)
tpl = OSTemplate( tpl = OSTemplate(
group_id=group_id, server_id=server_id, name=name, group_id=group_id, server_id=server_id, name=name,
vmid=new_vmid, vm_type='qemu', node=node, source_node=node, vmid=new_vmid, vm_type='qemu', node=node, source_node=node,

View File

@@ -541,6 +541,162 @@ def list_proxmox_native_jobs(
return JSONResponse(content={"jobs": [], "error": str(e)}) return JSONResponse(content={"jobs": [], "error": str(e)})
def _build_native_job_props(data: dict, *, for_create: bool) -> dict:
"""Собрать параметры vzdump для POST/PUT /cluster/backup из тела запроса."""
props: dict = {}
schedule = data.get("schedule")
if schedule:
props["schedule"] = schedule
storage = data.get("storage")
if storage:
props["storage"] = storage
if data.get("mode"):
props["mode"] = data["mode"]
if data.get("compress") is not None:
props["compress"] = data["compress"]
props["enabled"] = 1 if data.get("enabled", True) else 0
if data.get("comment"):
props["comment"] = data["comment"]
if data.get("mailto"):
props["mailto"] = data["mailto"]
if data.get("notes_template"):
props["notes-template"] = data["notes_template"]
if data.get("node"):
props["node"] = data["node"]
# Выбор гостей: все / список VMID / пул. Взаимоисключающие поля в PVE —
# при обновлении чистим неиспользуемые через delete, иначе PVE ругается.
selection = data.get("selection_mode", "all")
to_delete: list[str] = []
if selection == "all":
props["all"] = 1
to_delete += ["vmid", "pool"]
elif selection == "pool" and data.get("pool"):
props["pool"] = data["pool"]
to_delete += ["vmid", "all"]
elif selection == "vmid":
vmid = data.get("vmid")
if isinstance(vmid, list):
vmid = ",".join(str(v) for v in vmid)
if vmid:
props["vmid"] = vmid
to_delete += ["all", "pool"]
if not for_create and to_delete:
props["delete"] = ",".join(to_delete)
# Retention → prune-backups="keep-last=N,keep-daily=N,..."
keep_parts = []
for key, pve_key in (
("keep_last", "keep-last"), ("keep_daily", "keep-daily"),
("keep_weekly", "keep-weekly"), ("keep_monthly", "keep-monthly"),
):
val = data.get(key)
if val:
keep_parts.append(f"{pve_key}={int(val)}")
if keep_parts:
props["prune-backups"] = ",".join(keep_parts)
return props
@router.post("/api/backups/proxmox-jobs/{server_id}")
async def create_proxmox_native_job(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Создать нативное задание бэкапа Proxmox (cluster/backup)."""
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()
if not data.get("schedule") or not data.get("storage"):
raise HTTPException(status_code=400, detail="schedule and storage are required")
props = _build_native_job_props(data, for_create=True)
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.create_cluster_backup_job, props)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="create", resource_type="backup_job",
resource_id=str(props.get("id") or "native"), username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": True, "schedule": props.get("schedule")},
)
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/proxmox-jobs/{server_id}/{job_id}")
async def update_proxmox_native_job(
server_id: int,
job_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Обновить нативное задание бэкапа Proxmox (cluster/backup/{id})."""
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()
props = _build_native_job_props(data, for_create=False)
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.update_cluster_backup_job, job_id, props)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="update", resource_type="backup_job",
resource_id=job_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": 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.delete("/api/backups/proxmox-jobs/{server_id}/{job_id}")
async def delete_proxmox_native_job(
server_id: int,
job_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Удалить нативное задание бэкапа Proxmox (cluster/backup/{id})."""
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 = await _run_in_executor(client.delete_cluster_backup_job, job_id)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="delete", resource_type="backup_job",
resource_id=job_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": 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.get("/api/backups/jobs") @router.get("/api/backups/jobs")
def list_backup_jobs( def list_backup_jobs(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -47,6 +47,7 @@ class ImageDownloadRequest(BaseModel):
# Авто-конвертация qcow2 → VM-шаблон (Phase 2) # Авто-конвертация qcow2 → VM-шаблон (Phase 2)
to_template: bool = False to_template: bool = False
vmid: Optional[int] = None # желаемый VMID шаблона; None → авто-выбор
disk_storage: Optional[str] = None # хранилище диска ВМ (content=images); по умолч. = storage disk_storage: Optional[str] = None # хранилище диска ВМ (content=images); по умолч. = storage
cores: Optional[int] = None cores: Optional[int] = None
memory: Optional[int] = None # MB memory: Optional[int] = None # MB
@@ -331,7 +332,7 @@ async def download_image(
src['url'], src['filename'], src['checksum'], src['checksum_algorithm'], src['url'], src['filename'], src['checksum'], src['checksum_algorithm'],
src['name'], req.cores or 2, req.memory or 2048, req.bridge or 'vmbr0', src['name'], req.cores or 2, req.memory or 2048, req.bridge or 'vmbr0',
req.ciuser, req.cipassword, req.ssh_keys, src.get('os'), src.get('version'), req.ciuser, req.cipassword, req.ssh_keys, src.get('os'), src.get('version'),
current_user.id, current_user.username, current_user.id, current_user.username, req.vmid,
) )
else: else:
loop.run_in_executor( loop.run_in_executor(

View File

@@ -381,11 +381,17 @@ def list_lxc_templates(
def list_lxc_storages( def list_lxc_storages(
server_id: int, server_id: int,
node: Optional[str] = None, node: Optional[str] = None,
content: Optional[str] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")), current_user: User = Depends(PermissionChecker("vm:view")),
): ):
""" """
Список хранилищ ноды, пригодных для rootfs LXC контейнера. Список хранилищ ноды для дисков инстансов.
content:
'rootdir' — хранилища для rootfs LXC контейнера
'images' — хранилища для диска ВМ KVM (файлы диска)
None — обратная совместимость: rootdir ИЛИ images
""" """
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server: if not server:
@@ -402,19 +408,24 @@ def list_lxc_storages(
if not target_node: if not target_node:
raise HTTPException(status_code=400, detail="Не удалось определить ноду") raise HTTPException(status_code=400, detail="Не удалось определить ноду")
storages = client.get_node_storages(target_node) if content in ("rootdir", "images", "vztmpl", "import", "iso"):
result = [] # Точная фильтрация по типу контента (активные/включённые хранилища)
for s in storages: storages = client.get_download_target_storages(target_node, content)
content = s.get("content", "") else:
if "rootdir" in content or "images" in content: # Без параметра — прежнее поведение: rootdir ИЛИ images
result.append({ storages = [
"storage": s.get("storage"), s for s in client.get_node_storages(target_node)
"type": s.get("type"), if "rootdir" in (s.get("content", "") or "") or "images" in (s.get("content", "") or "")
"avail": s.get("avail"), ]
"total": s.get("total"),
"used": s.get("used"), result = [{
"active": s.get("active", 0), "storage": s.get("storage"),
}) "type": s.get("type"),
"avail": s.get("avail"),
"total": s.get("total"),
"used": s.get("used"),
"active": s.get("active", 0),
} for s in storages]
return JSONResponse(content={"storages": result}) return JSONResponse(content={"storages": result})

View File

@@ -376,6 +376,12 @@ def _resolve_clone_owner(db: Session, current_user: User, owner_id: Optional[int
return current_user.id return current_user.id
class MigrateRequest(BaseModel):
target_node: str
target_storage: Optional[str] = None
online: bool = False
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
username: str = "root" username: str = "root"
password: str password: str
@@ -461,6 +467,76 @@ def clone_container_endpoint(
return DeployTaskStartResponse(task_id=task.id, status='pending', name=body.new_name) return DeployTaskStartResponse(task_id=task.id, status='pending', name=body.new_name)
# -------- Migrate (move VM/LXC to another cluster node) --------
@router.post("/api/{server_id}/vm/{vmid}/migrate", status_code=202)
def migrate_vm_endpoint(
server_id: int,
vmid: int,
node: str,
body: MigrateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:migrate"))
):
"""Мигрировать VM (qemu) на другую ноду — фоновая задача."""
from ...models import DeployTask
from ..templates import _deploy_executor, DeployTaskStartResponse
from .async_ops import _do_migrate_sync
require_vm_access(db, current_user, server_id, vmid)
_resolve_server(db, server_id)
if body.target_node == node:
raise HTTPException(status_code=400, detail="Целевая нода совпадает с текущей")
task = DeployTask(
kind='migrate', name=f'migrate {vmid} → {body.target_node}', status='pending',
step='В очереди...', progress=0,
template_id=None, server_id=server_id, user_id=current_user.id, node=node,
)
db.add(task); db.commit(); db.refresh(task)
_deploy_executor.submit(
_do_migrate_sync, task.id, server_id, vmid, node, 'qemu',
body.target_node, body.target_storage, body.online,
current_user.id, current_user.username,
)
return DeployTaskStartResponse(task_id=task.id, status='pending', name=task.name)
@router.post("/api/{server_id}/container/{vmid}/migrate", status_code=202)
def migrate_container_endpoint(
server_id: int,
vmid: int,
node: str,
body: MigrateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("lxc:migrate"))
):
"""Мигрировать LXC контейнер на другую ноду — фоновая задача."""
from ...models import DeployTask
from ..templates import _deploy_executor, DeployTaskStartResponse
from .async_ops import _do_migrate_sync
require_vm_access(db, current_user, server_id, vmid)
_resolve_server(db, server_id)
if body.target_node == node:
raise HTTPException(status_code=400, detail="Целевая нода совпадает с текущей")
task = DeployTask(
kind='migrate', name=f'migrate {vmid} → {body.target_node}', status='pending',
step='В очереди...', progress=0,
template_id=None, server_id=server_id, user_id=current_user.id, node=node,
)
db.add(task); db.commit(); db.refresh(task)
_deploy_executor.submit(
_do_migrate_sync, task.id, server_id, vmid, node, 'lxc',
body.target_node, body.target_storage, body.online,
current_user.id, current_user.username,
)
return DeployTaskStartResponse(task_id=task.id, status='pending', name=task.name)
# -------- Reinstall (re-clone from saved template) -------- # -------- Reinstall (re-clone from saved template) --------
@router.post("/api/{server_id}/vm/{vmid}/reinstall", status_code=202) @router.post("/api/{server_id}/vm/{vmid}/reinstall", status_code=202)

View File

@@ -396,40 +396,64 @@ def discover_proxmox_templates(
# ==================== Auto-Import Templates ==================== # ==================== Auto-Import Templates ====================
# Маппинг ключевых слов на группы
# Иконки используют CSS классы для адаптации к теме # Иконки используют CSS классы для адаптации к теме
OS_GROUP_MAPPING = { _UBUNTU = {'name': 'Ubuntu', 'icon': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>', 'order': 1}
'ubuntu': {'name': 'Ubuntu', 'icon': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>', 'order': 1}, _DEBIAN = {'name': 'Debian', 'icon': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>', 'order': 2}
'debian': {'name': 'Debian', 'icon': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>', 'order': 2}, _CENTOS = {'name': 'CentOS', 'icon': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>', 'order': 3}
'centos': {'name': 'CentOS', 'icon': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>', 'order': 3}, _ROCKY = {'name': 'Rocky Linux', 'icon': '<span class="os-icon-rocky-svg"></span>', 'order': 4}
'rocky': {'name': 'Rocky Linux', 'icon': '<span class="os-icon-rocky-svg"></span>', 'order': 4}, _ALMA = {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5}
'alma': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5}, _FEDORA = {'name': 'Fedora', 'icon': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>', 'order': 6}
'almalinux': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5}, _RHEL = {'name': 'RHEL', 'icon': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>', 'order': 7}
'fedora': {'name': 'Fedora', 'icon': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>', 'order': 6}, _ORACLE = {'name': 'Oracle Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>', 'order': 8}
'rhel': {'name': 'RHEL', 'icon': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>', 'order': 7}, _SUSE = {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9}
'oracle': {'name': 'Oracle Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>', 'order': 8}, _ARCH = {'name': 'Arch Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>', 'order': 10}
'suse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9}, _GENTOO = {'name': 'Gentoo', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>', 'order': 11}
'opensuse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9}, _ALPINE = {'name': 'Alpine', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>', 'order': 12}
'arch': {'name': 'Arch Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>', 'order': 10}, _KALI = {'name': 'Kali Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-kali"></i>', 'order': 13}
'gentoo': {'name': 'Gentoo', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>', 'order': 11}, _MINT = {'name': 'Linux Mint', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-mint"></i>', 'order': 14}
'alpine': {'name': 'Alpine', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>', 'order': 12}, _WINDOWS = {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20}
'windows': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20}, _FREEBSD = {'name': 'FreeBSD', 'icon': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>', 'order': 30}
'win': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20}, _OPENBSD = {'name': 'OpenBSD', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>', 'order': 31}
'freebsd': {'name': 'FreeBSD', 'icon': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>', 'order': 30}, _OTHER = {'name': 'Other', 'icon': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>', 'order': 99}
'openbsd': {'name': 'OpenBSD', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>', 'order': 31},
'other': {'name': 'Other', 'icon': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>', 'order': 99}, # Маппинг ключевых слов на группы. Порядок важен — более специфичные/длинные
} # ключи должны идти раньше (напр. 'oraclelinux' перед 'oracle', 'rockylinux'
# перед 'rocky'), чтобы не перехватывались более общими. Включены частые
# опечатки и варианты написания (alama/allma, redhat/red hat, sles/leap/...).
_OS_KEYWORD_GROUPS: list[tuple[tuple[str, ...], dict]] = [
(('ubuntu',), _UBUNTU),
(('debian',), _DEBIAN),
(('linuxmint', 'mint'), _MINT),
(('centos',), _CENTOS),
(('rockylinux', 'rocky'), _ROCKY),
(('almalinux', 'alma', 'alama', 'allma', 'alamalinux'), _ALMA),
(('fedora',), _FEDORA),
(('oraclelinux', 'oracle'), _ORACLE),
(('rhel', 'redhat', 'red hat', 'red-hat'), _RHEL),
(('opensuse', 'sles', 'suse', 'leap', 'tumbleweed'), _SUSE),
(('archlinux', 'arch'), _ARCH),
(('gentoo',), _GENTOO),
(('alpine',), _ALPINE),
(('kali',), _KALI),
(('windows', 'win2', 'win10', 'win11', 'winserver', 'win7', 'win8'), _WINDOWS),
(('freebsd',), _FREEBSD),
(('openbsd',), _OPENBSD),
]
# Обратная совместимость: плоский маппинг keyword → group_info.
OS_GROUP_MAPPING = {kw: info for keys, info in _OS_KEYWORD_GROUPS for kw in keys}
OS_GROUP_MAPPING['other'] = _OTHER
def detect_os_group(template_name: str) -> dict: def detect_os_group(template_name: str) -> dict:
"""Определить группу ОС по имени шаблона""" """Определить группу ОС по имени шаблона (с учётом вариантов и опечаток)."""
name_lower = template_name.lower() name_lower = (template_name or '').lower()
for keyword, group_info in OS_GROUP_MAPPING.items(): for keys, group_info in _OS_KEYWORD_GROUPS:
if keyword in name_lower: if any(kw in name_lower for kw in keys):
return group_info return group_info
return OS_GROUP_MAPPING['other'] return _OTHER
def _do_auto_import(server_id: int, username: str): def _do_auto_import(server_id: int, username: str):

View File

@@ -1295,20 +1295,25 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
# Фильтруем разрешенные параметры # Фильтруем разрешенные параметры
allowed_params = { allowed_params = {
'cores', 'sockets', 'memory', 'balloon', 'name', 'description', 'cores', 'sockets', 'memory', 'balloon', 'name', 'description',
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'boot', 'bootdisk', 'cpu', 'cpulimit', 'cpuunits', 'onboot', 'startup', 'boot', 'bootdisk',
'agent', 'ostype', 'tablet', 'hotplug', 'agent', 'ostype', 'tablet', 'hotplug', 'protection',
'ciuser', 'cipassword', 'sshkeys', 'ciuser', 'cipassword', 'sshkeys',
} }
indexed_re = re.compile(r'^(net|scsi|virtio|ide|sata|ipconfig)\d+$') indexed_re = re.compile(r'^(net|scsi|virtio|ide|sata|ipconfig)\d+$')
delete_re = re.compile(r'^net\d+(,net\d+)*$') # Удаляемые ключи: сетевые интерфейсы + опциональные булевы флаги
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
filtered_config = { filtered_config = {}
k: v for k, v in config.items() for k, v in config.items():
if k in allowed_params or indexed_re.match(k) if k in ('onboot', 'protection'):
} filtered_config[k] = 1 if v in (True, 1, '1') else 0
# Удаление интерфейсов: delete='net1' (или 'net1,net2') elif k in allowed_params or indexed_re.match(k):
filtered_config[k] = v
# Удаление ключей: delete='net1' / 'startup' (или 'net1,startup')
delete_value = config.get('delete') delete_value = config.get('delete')
if isinstance(delete_value, str) and delete_re.match(delete_value): if isinstance(delete_value, str) and delete_value and all(
delete_token_re.match(tok) for tok in delete_value.split(',')
):
filtered_config['delete'] = delete_value filtered_config['delete'] = delete_value
if not filtered_config: if not filtered_config:
@@ -1363,19 +1368,22 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
allowed_params = { allowed_params = {
'hostname', 'memory', 'swap', 'cores', 'cpulimit', 'cpuunits', 'hostname', 'memory', 'swap', 'cores', 'cpulimit', 'cpuunits',
'onboot', 'description', 'nameserver', 'searchdomain', 'tags', 'onboot', 'startup', 'protection', 'description', 'nameserver',
'searchdomain', 'tags',
} }
net_re = re.compile(r'^net\d+$') net_re = re.compile(r'^net\d+$')
delete_re = re.compile(r'^net\d+(,net\d+)*$') delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
filtered_config = {} filtered_config = {}
for k, v in config.items(): for k, v in config.items():
if k == 'onboot': if k in ('onboot', 'protection'):
filtered_config['onboot'] = 1 if v in (True, 1, '1') else 0 filtered_config[k] = 1 if v in (True, 1, '1') else 0
elif k in allowed_params or net_re.match(k): elif k in allowed_params or net_re.match(k):
filtered_config[k] = v filtered_config[k] = v
delete_value = config.get('delete') delete_value = config.get('delete')
if isinstance(delete_value, str) and delete_re.match(delete_value): if isinstance(delete_value, str) and delete_value and all(
delete_token_re.match(tok) for tok in delete_value.split(',')
):
filtered_config['delete'] = delete_value filtered_config['delete'] = delete_value
if not filtered_config: if not filtered_config:
@@ -2002,6 +2010,49 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.error(f"Ошибка клонирования LXC {source_vmid} -> {new_vmid}: {e}") logger.error(f"Ошибка клонирования LXC {source_vmid} -> {new_vmid}: {e}")
return None return None
def migrate_vm(
self,
node: str,
vmid: int,
target_node: str,
target_storage: str = None,
online: bool = False,
with_local_disks: bool = True,
) -> Optional[str]:
"""
Мигрировать VM (qemu) на другую ноду.
Args:
node: Текущая нода VM
vmid: ID виртуальной машины
target_node: Целевая нода
target_storage: Целевое хранилище (опционально, storage migration)
online: Онлайн (live) миграция для запущенной VM
with_local_disks: Мигрировать локальные диски (обязательно для live
миграции VM с дисками на local storage)
Returns:
UPID задачи или None
"""
if not self.proxmox:
return None
try:
params = {'target': target_node}
if online:
params['online'] = 1
if with_local_disks:
params['with-local-disks'] = 1
if target_storage:
params['targetstorage'] = target_storage
result = self.proxmox.nodes(node).qemu(vmid).migrate.post(**params)
logger.info(f"Миграция VM {vmid} с {node} на {target_node}")
return result if isinstance(result, str) else None
except Exception as e:
logger.error(f"Ошибка миграции VM {vmid}: {e}")
raise RuntimeError(f"Proxmox: {e}") from e
def migrate_container( def migrate_container(
self, self,
node: str, node: str,
@@ -2039,10 +2090,10 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
result = self.proxmox.nodes(node).lxc(vmid).migrate.post(**params) result = self.proxmox.nodes(node).lxc(vmid).migrate.post(**params)
logger.info(f"Миграция LXC {vmid} с {node} на {target_node}") logger.info(f"Миграция LXC {vmid} с {node} на {target_node}")
return result return result if isinstance(result, str) else None
except Exception as e: except Exception as e:
logger.error(f"Ошибка миграции LXC {vmid}: {e}") logger.error(f"Ошибка миграции LXC {vmid}: {e}")
return None raise RuntimeError(f"Proxmox: {e}") from e
def get_vm_termproxy(self, node: str, vmid: int) -> Dict: def get_vm_termproxy(self, node: str, vmid: int) -> Dict:
""" """
@@ -2711,3 +2762,43 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.error(f"Error fetching cluster backup jobs: {e}") logger.error(f"Error fetching cluster backup jobs: {e}")
return [] return []
def create_cluster_backup_job(self, props: Dict) -> Dict:
"""Создать нативное задание бэкапа Proxmox (POST /cluster/backup).
props — уже подготовленные параметры vzdump (schedule, storage, mode,
compress, enabled, all/vmid/pool/node, prune-backups, comment, ...).
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.backup.post(**props)
logger.info(f"Создано нативное задание бэкапа: {props.get('id') or '(auto id)'}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка создания задания бэкапа: {e}")
return {"success": False, "error": str(e)}
def update_cluster_backup_job(self, job_id: str, props: Dict) -> Dict:
"""Обновить нативное задание бэкапа Proxmox (PUT /cluster/backup/{id})."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.backup(job_id).put(**props)
logger.info(f"Обновлено нативное задание бэкапа: {job_id}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка обновления задания бэкапа {job_id}: {e}")
return {"success": False, "error": str(e)}
def delete_cluster_backup_job(self, job_id: str) -> Dict:
"""Удалить нативное задание бэкапа Proxmox (DELETE /cluster/backup/{id})."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.backup(job_id).delete()
logger.info(f"Удалено нативное задание бэкапа: {job_id}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка удаления задания бэкапа {job_id}: {e}")
return {"success": False, "error": str(e)}

0
deploy.sh Executable file → Normal file
View File

View File

@@ -109,7 +109,10 @@ function SelectContent({
sideOffset = 4, sideOffset = 4,
align = "center", align = "center",
alignOffset = 0, alignOffset = 0,
alignItemWithTrigger = true, // Открывать список как обычную выпадашку под/над полем, а не выравнивать
// выбранный пункт над триггером — иначе длинные списки (напр. в диалогах)
// «уезжают» вверх и отрываются от поля.
alignItemWithTrigger = false,
...props ...props
}: SelectPrimitive.Popup.Props & }: SelectPrimitive.Popup.Props &
Pick< Pick<

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Database, CalendarClock } from 'lucide-react'; import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -13,6 +13,8 @@ import { useServers } from '@/hooks/use-nodes';
import { import {
useBackupStorages, useProxmoxBackupJobs, useBackupStorages, useProxmoxBackupJobs,
useCreateStorage, useUpdateStorage, useDeleteStorage, useCreateStorage, useUpdateStorage, useDeleteStorage,
useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob,
type ProxmoxJobInput,
} from '@/hooks/use-backups'; } from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format'; import { formatBytes } from '@/lib/format';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -179,6 +181,207 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
); );
} }
// ==================== Native PVE backup job ====================
type NativeJob = Record<string, unknown>;
const BACKUP_MODES = ['snapshot', 'suspend', 'stop'];
const COMPRESS_OPTS = ['zstd', 'gzip', 'lzo', '0'];
/** Разобрать prune-backups="keep-last=3,keep-daily=7" в отдельные поля. */
function parsePrune(spec: unknown): Record<string, number> {
const out: Record<string, number> = {};
if (typeof spec !== 'string') return out;
for (const part of spec.split(',')) {
const [k, v] = part.split('=');
const n = Number(v);
if (k && !Number.isNaN(n)) out[k.trim()] = n;
}
return out;
}
interface NativeJobForm {
schedule: string;
storage: string;
mode: string;
compress: string;
enabled: boolean;
selection_mode: 'all' | 'vmid';
vmid: string;
keep_last: string;
keep_daily: string;
keep_weekly: string;
keep_monthly: string;
comment: string;
mailto: string;
}
function jobToForm(job: NativeJob | null): NativeJobForm {
const prune = parsePrune(job?.['prune-backups']);
const hasVmid = !!(job && (job.vmid || job.pool) && job.all !== 1 && job.all !== '1');
return {
schedule: String(job?.schedule ?? ''),
storage: String(job?.storage ?? ''),
mode: String(job?.mode ?? 'snapshot'),
compress: String(job?.compress ?? 'zstd'),
enabled: !(job && (job.enabled === 0 || job.enabled === '0')),
selection_mode: hasVmid ? 'vmid' : 'all',
vmid: String(job?.vmid ?? ''),
keep_last: prune['keep-last'] != null ? String(prune['keep-last']) : '',
keep_daily: prune['keep-daily'] != null ? String(prune['keep-daily']) : '',
keep_weekly: prune['keep-weekly'] != null ? String(prune['keep-weekly']) : '',
keep_monthly: prune['keep-monthly'] != null ? String(prune['keep-monthly']) : '',
comment: String(job?.comment ?? ''),
mailto: String(job?.mailto ?? ''),
};
}
function NativeJobDialog({ serverId, open, onOpenChange, editing, backupStorages }: {
serverId: number;
open: boolean;
onOpenChange: (v: boolean) => void;
editing: NativeJob | null;
backupStorages: string[];
}) {
const { t } = useTranslation();
const [form, setForm] = useState<NativeJobForm>(() => jobToForm(editing));
const create = useCreateProxmoxJob(serverId);
const update = useUpdateProxmoxJob(serverId);
const pending = create.isPending || update.isPending;
useEffect(() => {
if (open) setForm(jobToForm(editing));
}, [open, editing]);
const set = <K extends keyof NativeJobForm>(k: K, v: NativeJobForm[K]) =>
setForm(prev => ({ ...prev, [k]: v }));
const submit = () => {
if (!form.schedule.trim() || !form.storage) {
toast.error(t('storages.job_schedule_storage_required'));
return;
}
const payload: ProxmoxJobInput = {
schedule: form.schedule.trim(),
storage: form.storage,
mode: form.mode,
compress: form.compress,
enabled: form.enabled,
selection_mode: form.selection_mode,
vmid: form.selection_mode === 'vmid' ? form.vmid.trim() : undefined,
keep_last: form.keep_last ? Number(form.keep_last) : undefined,
keep_daily: form.keep_daily ? Number(form.keep_daily) : undefined,
keep_weekly: form.keep_weekly ? Number(form.keep_weekly) : undefined,
keep_monthly: form.keep_monthly ? Number(form.keep_monthly) : undefined,
comment: form.comment.trim() || undefined,
mailto: form.mailto.trim() || undefined,
};
const opts = {
onSuccess: () => { toast.success(editing ? t('storages.job_updated') : t('storages.job_created')); onOpenChange(false); },
onError: (e: Error) => toast.error(e.message),
};
if (editing) update.mutate({ id: String(editing.id), data: payload }, opts);
else create.mutate(payload, opts);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CalendarClock className="h-5 w-5" />
{editing ? t('storages.edit_native_job') : t('storages.add_native_job')}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('storages.schedule')}</Label>
<Input value={form.schedule} onChange={e => set('schedule', e.target.value)} placeholder="sat 03:00" />
</div>
<div className="space-y-1.5">
<Label>{t('storages.id')}</Label>
<Select value={form.storage} onValueChange={v => v && set('storage', v)}>
<SelectTrigger className="w-full"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<SelectContent>
{backupStorages.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<p className="-mt-1 text-xs text-muted-foreground">{t('storages.schedule_hint')}</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('backups.mode')}</Label>
<Select value={form.mode} onValueChange={v => v && set('mode', v)}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>{BACKUP_MODES.map(m => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t('storages.compress')}</Label>
<Select value={form.compress} onValueChange={v => v && set('compress', v)}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>{COMPRESS_OPTS.map(c => <SelectItem key={c} value={c}>{c === '0' ? t('storages.no_compress') : c}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>{t('storages.selection')}</Label>
<Select value={form.selection_mode} onValueChange={v => v && set('selection_mode', v as 'all' | 'vmid')}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('storages.select_all_guests')}</SelectItem>
<SelectItem value="vmid">{t('storages.select_vmids')}</SelectItem>
</SelectContent>
</Select>
</div>
{form.selection_mode === 'vmid' && (
<div className="space-y-1.5">
<Label>{t('backups.vmids')}</Label>
<Input value={form.vmid} onChange={e => set('vmid', e.target.value)} placeholder="100,101,102" />
</div>
)}
<div>
<Label className="text-xs text-muted-foreground">{t('storages.retention')}</Label>
<div className="mt-1.5 grid grid-cols-4 gap-2">
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_last')}</Label><Input type="number" min={0} value={form.keep_last} onChange={e => set('keep_last', e.target.value)} /></div>
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_daily')}</Label><Input type="number" min={0} value={form.keep_daily} onChange={e => set('keep_daily', e.target.value)} /></div>
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_weekly')}</Label><Input type="number" min={0} value={form.keep_weekly} onChange={e => set('keep_weekly', e.target.value)} /></div>
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_monthly')}</Label><Input type="number" min={0} value={form.keep_monthly} onChange={e => set('keep_monthly', e.target.value)} /></div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('storages.comment')}</Label>
<Input value={form.comment} onChange={e => set('comment', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>{t('storages.mailto')}</Label>
<Input value={form.mailto} onChange={e => set('mailto', e.target.value)} placeholder="admin@example.com" />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.enabled} onChange={e => set('enabled', e.target.checked)} />
<span>{t('storages.enabled')}</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={pending}>
{editing ? t('common.save') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function StoragesTab() { export default function StoragesTab() {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
@@ -192,12 +395,21 @@ export default function StoragesTab() {
const { data: storagesData, isLoading } = useBackupStorages(serverId); const { data: storagesData, isLoading } = useBackupStorages(serverId);
const { data: jobsData } = useProxmoxBackupJobs(serverId); const { data: jobsData } = useProxmoxBackupJobs(serverId);
const deleteStorage = useDeleteStorage(serverId); const deleteStorage = useDeleteStorage(serverId);
const updateJob = useUpdateProxmoxJob(serverId);
const deleteJob = useDeleteProxmoxJob(serverId);
const storages = (storagesData?.storages ?? []) as StorageRow[]; const storages = (storagesData?.storages ?? []) as StorageRow[];
const nativeJobs = (jobsData?.jobs ?? []) as Array<Record<string, unknown>>; const nativeJobs = (jobsData?.jobs ?? []) as Array<Record<string, unknown>>;
// Хранилища, поддерживающие бэкапы — для селекта в диалоге задания
const backupStorages = storages
.filter(s => (s.content ?? '').split(',').map(c => c.trim()).includes('backup'))
.map(s => s.storage);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<StorageRow | null>(null); const [editing, setEditing] = useState<StorageRow | null>(null);
const [jobDialogOpen, setJobDialogOpen] = useState(false);
const [editingJob, setEditingJob] = useState<NativeJob | null>(null);
const handleDelete = async (s: string) => { const handleDelete = async (s: string) => {
if (!await confirm(`${t('storages.delete_confirm')} "${s}"?`)) return; if (!await confirm(`${t('storages.delete_confirm')} "${s}"?`)) return;
@@ -207,6 +419,43 @@ export default function StoragesTab() {
}); });
}; };
const handleDeleteJob = async (job: NativeJob) => {
if (!await confirm(`${t('storages.delete_job_confirm')} "${String(job.id)}"?`)) return;
deleteJob.mutate(String(job.id), {
onSuccess: () => toast.success(t('storages.job_deleted')),
onError: (e: Error) => toast.error(e.message),
});
};
const handleToggleJob = (job: NativeJob) => {
const currentlyEnabled = !(job.enabled === 0 || job.enabled === '0');
const form = jobToForm(job);
updateJob.mutate(
{
id: String(job.id),
data: {
schedule: form.schedule,
storage: form.storage,
mode: form.mode,
compress: form.compress,
enabled: !currentlyEnabled,
selection_mode: form.selection_mode,
vmid: form.selection_mode === 'vmid' ? form.vmid : undefined,
keep_last: form.keep_last ? Number(form.keep_last) : undefined,
keep_daily: form.keep_daily ? Number(form.keep_daily) : undefined,
keep_weekly: form.keep_weekly ? Number(form.keep_weekly) : undefined,
keep_monthly: form.keep_monthly ? Number(form.keep_monthly) : undefined,
comment: form.comment || undefined,
mailto: form.mailto || undefined,
},
},
{
onSuccess: () => toast.success(currentlyEnabled ? t('storages.job_disabled') : t('storages.job_enabled')),
onError: (e: Error) => toast.error(e.message),
}
);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -265,9 +514,14 @@ export default function StoragesTab() {
</CardContent> </CardContent>
</Card> </Card>
{/* Native PVE backup schedules (read-only) */} {/* Native PVE backup schedules */}
<Card> <Card>
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><CalendarClock className="h-5 w-5" />{t('storages.native_jobs')}</CardTitle></CardHeader> <CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2 text-lg"><CalendarClock className="h-5 w-5" />{t('storages.native_jobs')}</CardTitle>
<Button size="sm" variant="outline" onClick={() => { setEditingJob(null); setJobDialogOpen(true); }} disabled={!serverId}>
<Plus className="mr-1 h-4 w-4" />{t('storages.add_native_job')}
</Button>
</CardHeader>
<CardContent> <CardContent>
{nativeJobs.length === 0 ? ( {nativeJobs.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('storages.no_native_jobs')}</p> <p className="py-4 text-center text-sm text-muted-foreground">{t('storages.no_native_jobs')}</p>
@@ -277,21 +531,38 @@ export default function StoragesTab() {
<TableHead>ID</TableHead><TableHead>{t('storages.schedule')}</TableHead> <TableHead>ID</TableHead><TableHead>{t('storages.schedule')}</TableHead>
<TableHead>{t('storages.id')}</TableHead><TableHead>{t('backups.mode')}</TableHead> <TableHead>{t('storages.id')}</TableHead><TableHead>{t('backups.mode')}</TableHead>
<TableHead>{t('common.status')}</TableHead> <TableHead>{t('common.status')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow></TableHeader> </TableRow></TableHeader>
<TableBody> <TableBody>
{nativeJobs.map((j, i) => ( {nativeJobs.map((j, i) => {
<TableRow key={String(j.id ?? i)}> const enabled = !(j.enabled === 0 || j.enabled === '0');
<TableCell className="font-mono text-xs">{String(j.id ?? '—')}</TableCell> return (
<TableCell className="font-mono text-xs">{String(j.schedule ?? j.starttime ?? '—')}</TableCell> <TableRow key={String(j.id ?? i)}>
<TableCell className="text-xs">{String(j.storage ?? '—')}</TableCell> <TableCell className="font-mono text-xs">{String(j.id ?? '—')}</TableCell>
<TableCell className="text-xs">{String(j.mode ?? '—')}</TableCell> <TableCell className="font-mono text-xs">{String(j.schedule ?? j.starttime ?? '—')}</TableCell>
<TableCell> <TableCell className="text-xs">{String(j.storage ?? '—')}</TableCell>
<Badge variant={j.enabled === 0 || j.enabled === '0' ? 'secondary' : 'default'}> <TableCell className="text-xs">{String(j.mode ?? '—')}</TableCell>
{j.enabled === 0 || j.enabled === '0' ? t('storages.disabled') : t('storages.enabled')} <TableCell>
</Badge> <Badge variant={enabled ? 'default' : 'secondary'}>
</TableCell> {enabled ? t('storages.enabled') : t('storages.disabled')}
</TableRow> </Badge>
))} </TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" title={enabled ? t('backups.disable') : t('backups.enable')} onClick={() => handleToggleJob(j)}>
{enabled ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('common.edit')} onClick={() => { setEditingJob(j); setJobDialogOpen(true); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('common.delete')} onClick={() => handleDeleteJob(j)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
)} )}
@@ -306,6 +577,15 @@ export default function StoragesTab() {
editing={editing} editing={editing}
/> />
)} )}
{jobDialogOpen && (
<NativeJobDialog
serverId={serverId}
open={jobDialogOpen}
onOpenChange={(v) => { setJobDialogOpen(v); if (!v) setEditingJob(null); }}
editing={editingJob}
backupStorages={backupStorages}
/>
)}
</div> </div>
); );
} }

View File

@@ -50,6 +50,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
const [storage, setStorage] = useState(''); // download storage (import / vztmpl) const [storage, setStorage] = useState(''); // download storage (import / vztmpl)
const [toTemplate, setToTemplate] = useState(true); const [toTemplate, setToTemplate] = useState(true);
const [diskStorage, setDiskStorage] = useState(''); // VM disk storage (images) const [diskStorage, setDiskStorage] = useState(''); // VM disk storage (images)
const [vmid, setVmid] = useState<string>(''); // желаемый VMID шаблона (пусто = авто)
const [cores, setCores] = useState(2); const [cores, setCores] = useState(2);
const [memory, setMemory] = useState(2048); const [memory, setMemory] = useState(2048);
const [bridge, setBridge] = useState('vmbr0'); const [bridge, setBridge] = useState('vmbr0');
@@ -77,6 +78,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
if (open) { if (open) {
setStorage(storages.length === 1 ? storages[0].storage : ''); setStorage(storages.length === 1 ? storages[0].storage : '');
setToTemplate(true); setToTemplate(true);
setVmid('');
} }
}, [open, storages]); }, [open, storages]);
@@ -100,6 +102,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
kind: image.kind, kind: image.kind,
template: image.source_id ? undefined : image.template || undefined, template: image.source_id ? undefined : image.template || undefined,
to_template: makeTemplate, to_template: makeTemplate,
vmid: makeTemplate && vmid.trim() ? Number(vmid) : undefined,
disk_storage: makeTemplate ? diskStorage : undefined, disk_storage: makeTemplate ? diskStorage : undefined,
cores: makeTemplate ? cores : undefined, cores: makeTemplate ? cores : undefined,
memory: makeTemplate ? memory : undefined, memory: makeTemplate ? memory : undefined,
@@ -193,6 +196,11 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
</p> </p>
)} )}
</div> </div>
<div>
<Label htmlFor="img-vmid">VMID шаблона (пусто = авто)</Label>
<Input id="img-vmid" type="number" min={100} placeholder="напр. 9001"
value={vmid} onChange={(e) => setVmid(e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<Label htmlFor="img-cores">Ядра</Label> <Label htmlFor="img-cores">Ядра</Label>

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon, Play, Square, RotateCcw, Power, AlertTriangle } from 'lucide-react'; import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon, Play, Square, RotateCcw, Power, AlertTriangle, ArrowRightLeft } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -21,6 +21,8 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import {
useCloneInstance, useCloneInstance,
useMigrateInstance,
useVMStatus,
useReinstallInstance, useReinstallInstance,
useChangePassword, useChangePassword,
useUpdateNotes, useUpdateNotes,
@@ -41,6 +43,7 @@ export type PowerAction = 'start' | 'stop' | 'restart' | 'shutdown';
export type InstanceAction = export type InstanceAction =
| 'clone' | 'clone'
| 'migrate'
| 'reinstall' | 'reinstall'
| 'change-password' | 'change-password'
| 'notes' | 'notes'
@@ -67,6 +70,7 @@ export default function InstanceActionDialogs(props: Props) {
return ( return (
<> <>
<CloneDialog open={open === 'clone'} onClose={close} {...shared} /> <CloneDialog open={open === 'clone'} onClose={close} {...shared} />
<MigrateDialog open={open === 'migrate'} onClose={close} {...shared} />
<ReinstallDialog open={open === 'reinstall'} onClose={close} {...shared} /> <ReinstallDialog open={open === 'reinstall'} onClose={close} {...shared} />
<ChangePasswordDialog open={open === 'change-password'} onClose={close} {...shared} /> <ChangePasswordDialog open={open === 'change-password'} onClose={close} {...shared} />
<NotesDialog open={open === 'notes'} onClose={close} {...shared} /> <NotesDialog open={open === 'notes'} onClose={close} {...shared} />
@@ -223,6 +227,119 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
); );
} }
// ==================== Migrate ====================
const MIGRATE_SAME_STORAGE = '__same__';
function MigrateDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const [targetNode, setTargetNode] = useState('');
const [targetStorage, setTargetStorage] = useState('');
const [online, setOnline] = useState(false);
const migrate = useMigrateInstance(serverId, vmid, type, node);
const addDeployTask = useDeployTasksStore((s) => s.addTask);
const { data: nodesResp } = useNodes(serverId);
const nodes = (nodesResp?.nodes ?? []).filter((n) => n.node !== node);
const { data: status } = useVMStatus(serverId, vmid, type, node, open);
const isRunning = status?.status === 'running';
const { data: storages = [] } = useLXCStorages(serverId, targetNode || node);
// Сброс формы при открытии; online по умолчанию = инстанс запущен.
useEffect(() => {
if (open) {
setTargetNode('');
setTargetStorage('');
setOnline(isRunning);
}
}, [open, isRunning]);
const submit = () => {
if (!targetNode) return;
migrate.mutate(
{
target_node: targetNode,
target_storage: targetStorage || undefined,
online,
},
{
onSuccess: (data) => {
addDeployTask({
id: data.task_id,
name: data.name,
status: 'pending',
step: 'В очереди...',
progress: 0,
vmid,
node: targetNode,
error_message: null,
kind: 'migrate',
server_id: serverId,
});
toast.success(t('instances.migrate_started'));
onClose();
},
onError: (e) => toast.error(e.message),
}
);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><ArrowRightLeft className="h-4 w-4" /> {t('instances.migrate')}</DialogTitle>
<DialogDescription>{t('instances.migrate_desc')} <strong>{name ?? `#${vmid}`}</strong> ({node})</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>{t('instances.migrate_target_node')}</Label>
<Select value={targetNode} onValueChange={(v) => { if (v) { setTargetNode(v); setTargetStorage(''); } }}>
<SelectTrigger><SelectValue placeholder={t('instances.migrate_select_node')} /></SelectTrigger>
<SelectContent>
{nodes.map((n) => (
<SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>
))}
</SelectContent>
</Select>
{nodes.length === 0 && (
<p className="text-xs text-amber-500">{t('instances.migrate_no_targets')}</p>
)}
</div>
<div className="space-y-1.5">
<Label>{t('instances.migrate_target_storage')}</Label>
<Select value={targetStorage || MIGRATE_SAME_STORAGE} onValueChange={(v) => setTargetStorage(!v || v === MIGRATE_SAME_STORAGE ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={MIGRATE_SAME_STORAGE}>{t('instances.migrate_same_storage')}</SelectItem>
{storages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" checked={online} onChange={(e) => setOnline(e.target.checked)} className="h-4 w-4 rounded border-input accent-primary" />
<span className="text-sm">{t('instances.migrate_online')}</span>
</label>
<p className="text-xs text-muted-foreground -mt-2">
{isRunning ? t('instances.migrate_online_hint_running') : t('instances.migrate_online_hint_stopped')}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!targetNode || migrate.isPending}>
{migrate.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.migrate')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Reinstall ==================== // ==================== Reinstall ====================
function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) { function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {

View File

@@ -17,6 +17,7 @@ import {
KeyRound, KeyRound,
FileText, FileText,
Disc, Disc,
ArrowRightLeft,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -204,6 +205,9 @@ export default function InstanceDetailPage() {
<DropdownMenuItem onClick={() => setDialog('clone')}> <DropdownMenuItem onClick={() => setDialog('clone')}>
<Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')} <Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDialog('migrate')}>
<ArrowRightLeft className="mr-2 h-4 w-4" /> {t('instances.migrate', 'Миграция')}
</DropdownMenuItem>
{isQemu && ( {isQemu && (
<DropdownMenuItem onClick={() => setDialog('iso')}> <DropdownMenuItem onClick={() => setDialog('iso')}>
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')} <Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Settings, Loader2 } from 'lucide-react'; import { Settings, Loader2, Power } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -148,6 +148,118 @@ function ExecuteScriptCard({ serverId, vmid, node }: { serverId: number; vmid: n
); );
} }
/** Разобрать строку startup Proxmox ("order=1,up=30,down=60") на поля. */
function parseStartup(spec: unknown): { order: string; up: string; down: string } {
const out = { order: '', up: '', down: '' };
if (typeof spec !== 'string') return out;
for (const part of spec.split(',')) {
const [k, v] = part.split('=');
if (k === 'order') out.order = v ?? '';
else if (k === 'up') out.up = v ?? '';
else if (k === 'down') out.down = v ?? '';
}
return out;
}
function AutostartCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const [onboot, setOnboot] = useState(false);
const [protection, setProtection] = useState(false);
const [order, setOrder] = useState('');
const [up, setUp] = useState('');
const [down, setDown] = useState('');
// Инициализация из конфига Proxmox
useEffect(() => {
if (!config) return;
setOnboot(Number(config.onboot) === 1);
setProtection(Number(config.protection) === 1);
const s = parseStartup(config.startup);
setOrder(s.order);
setUp(s.up);
setDown(s.down);
}, [config]);
const save = () => {
const updates: Record<string, unknown> = {
onboot: onboot ? 1 : 0,
protection: protection ? 1 : 0,
};
// Собираем startup=order=..,up=..,down.. — либо удаляем, если всё пусто
const parts: string[] = [];
if (order.trim() !== '') parts.push(`order=${Number(order)}`);
if (up.trim() !== '') parts.push(`up=${Number(up)}`);
if (down.trim() !== '') parts.push(`down=${Number(down)}`);
if (parts.length > 0) updates.startup = parts.join(',');
else updates.delete = 'startup';
updateConfig.mutate(updates, {
onSuccess: () => toast.success(t('instances.autostart_saved')),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Power className="h-4 w-4" />{t('instances.autostart')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={onboot}
onChange={e => setOnboot(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">{t('instances.start_on_boot')}</span>
</label>
<p className="text-xs text-muted-foreground -mt-2">{t('instances.start_on_boot_hint')}</p>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="startup-order">{t('instances.startup_order')}</Label>
<Input id="startup-order" type="number" min={0} value={order}
onChange={e => setOrder(e.target.value)} placeholder="any" />
</div>
<div className="space-y-1.5">
<Label htmlFor="startup-up">{t('instances.startup_up_delay')}</Label>
<Input id="startup-up" type="number" min={0} value={up}
onChange={e => setUp(e.target.value)} placeholder="0" />
</div>
<div className="space-y-1.5">
<Label htmlFor="startup-down">{t('instances.startup_down_timeout')}</Label>
<Input id="startup-down" type="number" min={0} value={down}
onChange={e => setDown(e.target.value)} placeholder="0" />
</div>
</div>
<p className="text-xs text-muted-foreground">{t('instances.startup_order_hint')}</p>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={protection}
onChange={e => setProtection(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">{t('instances.protection')}</span>
</label>
<p className="text-xs text-muted-foreground -mt-2">{t('instances.protection_hint')}</p>
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
</CardContent>
</Card>
);
}
function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number }) { function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useSavedConfig(serverId, vmid); const { data } = useSavedConfig(serverId, vmid);
@@ -286,6 +398,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
<OwnerCard serverId={serverId} vmid={vmid} /> <OwnerCard serverId={serverId} vmid={vmid} />
<AutostartCard serverId={serverId} vmid={vmid} type={type} node={node} />
{type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />} {type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />}
{/* CPU & Memory */} {/* CPU & Memory */}

View File

@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useServers, useNodes, useStorages } from '@/hooks/use-nodes'; import { useServers, useNodes, useStorages } from '@/hooks/use-nodes';
import { useQuickDeployInfo, useDeployVM } from '@/hooks/use-templates'; import { useQuickDeployInfo, useDeployVM, useTemplateGroups, useUpdateTemplate } from '@/hooks/use-templates';
import { useAvailableLXCTemplates, useDownloadLXCTemplate } from '@/hooks/use-lxc-templates'; import { useAvailableLXCTemplates, useDownloadLXCTemplate } from '@/hooks/use-lxc-templates';
import type { OSTemplate } from '@/types'; import type { OSTemplate } from '@/types';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -177,3 +177,54 @@ export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: ()
</Dialog> </Dialog>
); );
} }
// ==================== Edit template group (category) ====================
export function EditTemplateDialog({ template, onClose }: { template: OSTemplate | null; onClose: () => void }) {
const { t } = useTranslation();
const open = !!template;
const { data: groups = [] } = useTemplateGroups();
const update = useUpdateTemplate();
const [groupId, setGroupId] = useState('');
useEffect(() => {
if (template) setGroupId(String(template.group_id));
}, [template]);
const submit = () => {
if (!template || !groupId) return;
update.mutate(
{ id: template.id, group_id: Number(groupId) },
{
onSuccess: () => { toast.success(t('templates.group_updated')); onClose(); },
onError: (e: Error) => toast.error(e.message),
},
);
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{t('templates.edit_group')}: {template?.name}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>{t('templates.group')}</Label>
<Select value={groupId} onValueChange={v => { if (v) setGroupId(v); }}>
<SelectTrigger className="mt-1 w-full"><SelectValue placeholder={t('templates.select_group')} /></SelectTrigger>
<SelectContent className="max-h-72">
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={update.isPending || !groupId || groupId === String(template?.group_id)}>
{update.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus } from 'lucide-react'; import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus, Pencil } from 'lucide-react';
import { OsLogo } from './OsLogo'; import { OsLogo } from './OsLogo';
import { DeployDialog, DownloadCTDialog } from './TemplateDialogs'; import { DeployDialog, DownloadCTDialog, EditTemplateDialog } from './TemplateDialogs';
import type { OSTemplate } from '@/types'; import type { OSTemplate } from '@/types';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -25,6 +25,7 @@ export default function TemplatesPage() {
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importServerId, setImportServerId] = useState<string>(''); const [importServerId, setImportServerId] = useState<string>('');
const [deployTpl, setDeployTpl] = useState<OSTemplate | null>(null); const [deployTpl, setDeployTpl] = useState<OSTemplate | null>(null);
const [editTpl, setEditTpl] = useState<OSTemplate | null>(null);
const [downloadOpen, setDownloadOpen] = useState(false); const [downloadOpen, setDownloadOpen] = useState(false);
const { data: groups = [] } = useTemplateGroups(); const { data: groups = [] } = useTemplateGroups();
@@ -123,14 +124,25 @@ export default function TemplatesPage() {
<p className="text-xs text-muted-foreground">{tpl.group_name}</p> <p className="text-xs text-muted-foreground">{tpl.group_name}</p>
</div> </div>
</div> </div>
<Button <div className="flex items-center gap-0.5">
variant="ghost" <Button
size="sm" variant="ghost"
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive" size="sm"
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }} className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0"
> title={t('templates.edit_group')}
<Trash2 className="h-3.5 w-3.5" /> onClick={() => setEditTpl(tpl)}
</Button> >
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Badge variant={tpl.vm_type === 'lxc' ? 'secondary' : 'default'} className="text-xs"> <Badge variant={tpl.vm_type === 'lxc' ? 'secondary' : 'default'} className="text-xs">
@@ -156,6 +168,7 @@ export default function TemplatesPage() {
)} )}
<DeployDialog template={deployTpl} onClose={() => setDeployTpl(null)} /> <DeployDialog template={deployTpl} onClose={() => setDeployTpl(null)} />
<EditTemplateDialog template={editTpl} onClose={() => setEditTpl(null)} />
<DownloadCTDialog open={downloadOpen} onClose={() => setDownloadOpen(false)} /> <DownloadCTDialog open={downloadOpen} onClose={() => setDownloadOpen(false)} />
{/* Import Dialog */} {/* Import Dialog */}

View File

@@ -24,6 +24,52 @@ export function useProxmoxBackupJobs(serverId: number) {
}); });
} }
export type ProxmoxJobInput = {
id?: string;
schedule: string;
storage: string;
mode?: string;
compress?: string;
enabled?: boolean;
selection_mode?: 'all' | 'vmid' | 'pool';
vmid?: string;
pool?: string;
node?: string;
keep_last?: number;
keep_daily?: number;
keep_weekly?: number;
keep_monthly?: number;
comment?: string;
mailto?: string;
};
export function useCreateProxmoxJob(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ProxmoxJobInput) =>
apiClient.post<{ success: boolean }>(`/proxmox/api/backups/proxmox-jobs/${serverId}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.proxmoxJobs(serverId) }),
});
}
export function useUpdateProxmoxJob(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: ProxmoxJobInput }) =>
apiClient.put<{ success: boolean }>(`/proxmox/api/backups/proxmox-jobs/${serverId}/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.proxmoxJobs(serverId) }),
});
}
export function useDeleteProxmoxJob(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (jobId: string) =>
apiClient.delete<{ success: boolean }>(`/proxmox/api/backups/proxmox-jobs/${serverId}/${jobId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.proxmoxJobs(serverId) }),
});
}
export function useCreateStorage(serverId: number) { export function useCreateStorage(serverId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -75,6 +75,7 @@ export interface ImageDownloadRequest {
checksum_algorithm?: string; checksum_algorithm?: string;
// Авто-конвертация qcow2 → VM-шаблон // Авто-конвертация qcow2 → VM-шаблон
to_template?: boolean; to_template?: boolean;
vmid?: number;
disk_storage?: string; disk_storage?: string;
cores?: number; cores?: number;
memory?: number; memory?: number;

View File

@@ -604,6 +604,21 @@ export function useReinstallInstance(serverId: number, vmid: number, node: strin
}); });
} }
// ==================== Migrate ====================
export interface MigrateRequest {
target_node: string;
target_storage?: string;
online?: boolean;
}
export function useMigrateInstance(serverId: number, vmid: number, type: string, node: string) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<AsyncTaskResponse, Error, MigrateRequest>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/migrate?node=${node}`, body),
});
}
// ==================== Change Password ==================== // ==================== Change Password ====================
export function useChangePassword(serverId: number, vmid: number, type: string, node: string) { export function useChangePassword(serverId: number, vmid: number, type: string, node: string) {
const prefix = type === 'lxc' ? 'container' : 'vm'; const prefix = type === 'lxc' ? 'container' : 'vm';

View File

@@ -4,7 +4,8 @@ import type { LXCTemplate, LXCDeployRequest } from '@/types';
export const lxcKeys = { export const lxcKeys = {
templates: (serverId: number) => ['lxc-templates', serverId] as const, templates: (serverId: number) => ['lxc-templates', serverId] as const,
storages: (serverId: number, node?: string) => ['lxc-storages', serverId, node] as const, storages: (serverId: number, node?: string, content?: string) =>
['lxc-storages', serverId, node, content] as const,
}; };
// CT templates change rarely — cache for 5 minutes // CT templates change rarely — cache for 5 minutes
@@ -20,11 +21,15 @@ export function useLXCTemplates(serverId?: number) {
}); });
} }
// Storage list changes rarely — cache for 5 minutes // Storage list changes rarely — cache for 5 minutes.
export function useLXCStorages(serverId?: number, node?: string) { // content: 'rootdir' (LXC rootfs) | 'images' (VM disk) | undefined (both)
const params = node ? `?node=${encodeURIComponent(node)}` : ''; export function useLXCStorages(serverId?: number, node?: string, content?: string) {
const qs = new URLSearchParams();
if (node) qs.set('node', node);
if (content) qs.set('content', content);
const params = qs.toString() ? `?${qs.toString()}` : '';
return useQuery({ return useQuery({
queryKey: lxcKeys.storages(serverId!, node), queryKey: lxcKeys.storages(serverId!, node, content),
queryFn: () => queryFn: () =>
apiClient apiClient
.get<{ storages: { storage: string; type: string; avail: number; total: number }[] }>( .get<{ storages: { storage: string; type: string; avail: number; total: number }[] }>(

View File

@@ -13,6 +13,14 @@ export function useTemplateGroups() {
return useQuery({ return useQuery({
queryKey: templateKeys.groups, queryKey: templateKeys.groups,
queryFn: () => apiClient.get<OSTemplateGroup[]>('/templates/api/groups'), queryFn: () => apiClient.get<OSTemplateGroup[]>('/templates/api/groups'),
// Список групп ОС — по алфавиту; «Other» (корзина «прочее») всегда в конце.
select: (groups) =>
[...groups].sort((a, b) => {
const aOther = a.name.toLowerCase() === 'other';
const bOther = b.name.toLowerCase() === 'other';
if (aOther !== bOther) return aOther ? 1 : -1;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
}),
}); });
} }
@@ -25,6 +33,11 @@ export function useTemplates(groupId?: number, serverId?: number, vmType?: strin
return useQuery({ return useQuery({
queryKey: templateKeys.templates(groupId, serverId, vmType), queryKey: templateKeys.templates(groupId, serverId, vmType),
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`), queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
// Шаблоны — по имени, по алфавиту (с числовой сортировкой: Debian-9 < Debian-11).
select: (templates) =>
[...templates].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
),
}); });
} }

View File

@@ -220,6 +220,27 @@
"owner_hint": "Assign this instance to a user (admin only).", "owner_hint": "Assign this instance to a user (admin only).",
"saved_config": "Saved configuration", "saved_config": "Saved configuration",
"saved_config_hint": "Values stored by the panel at creation/reinstall time.", "saved_config_hint": "Values stored by the panel at creation/reinstall time.",
"migrate": "Migrate",
"migrate_desc": "Move to another cluster node:",
"migrate_started": "Migration started in background",
"migrate_target_node": "Target node",
"migrate_select_node": "Select node",
"migrate_no_targets": "No other nodes available in the cluster.",
"migrate_target_storage": "Target storage",
"migrate_same_storage": "Keep source storage",
"migrate_online": "Online (live) migration",
"migrate_online_hint_running": "Instance is running — live migration keeps it online during the move.",
"migrate_online_hint_stopped": "Instance is stopped — leave off for a regular offline migration.",
"autostart": "Autostart",
"autostart_saved": "Autostart settings saved",
"start_on_boot": "Start on boot",
"start_on_boot_hint": "Automatically start this instance when the host node boots.",
"startup_order": "Startup order",
"startup_up_delay": "Start delay (sec)",
"startup_down_timeout": "Shutdown timeout (sec)",
"startup_order_hint": "Lower order starts first. Start delay waits before the next instance; shutdown timeout waits before force-off. Leave empty for defaults.",
"protection": "Protection",
"protection_hint": "Prevents the instance and its disks from being removed.",
"compute_resources": "Compute Resources", "compute_resources": "Compute Resources",
"vcpu_cores": "vCPU Cores", "vcpu_cores": "vCPU Cores",
"current_cores": "Current: {{count}} cores", "current_cores": "Current: {{count}} cores",
@@ -526,7 +547,11 @@
"version": "Version", "version": "Version",
"imported": "Imported", "imported": "Imported",
"select_server": "Select server", "select_server": "Select server",
"import_btn": "Import Templates" "import_btn": "Import Templates",
"group": "Group",
"edit_group": "Edit group",
"select_group": "Select group",
"group_updated": "Group updated"
}, },
"storages": { "storages": {
"title": "Storages", "title": "Storages",
@@ -556,7 +581,25 @@
"delete_confirm": "Delete storage", "delete_confirm": "Delete storage",
"native_jobs": "Native PVE backup schedules", "native_jobs": "Native PVE backup schedules",
"no_native_jobs": "No native backup schedules", "no_native_jobs": "No native backup schedules",
"schedule": "Schedule" "schedule": "Schedule",
"schedule_hint": "systemd calendar event, e.g. \"sat 03:00\" or \"mon..fri 02:30\".",
"compress": "Compression",
"no_compress": "None",
"selection": "Guest selection",
"select_all_guests": "All guests",
"select_vmids": "Selected VMIDs",
"retention": "Retention (prune-backups)",
"comment": "Comment",
"mailto": "Email notifications",
"add_native_job": "Add job",
"edit_native_job": "Edit backup job",
"delete_job_confirm": "Delete backup job",
"job_created": "Backup job created",
"job_updated": "Backup job updated",
"job_deleted": "Backup job deleted",
"job_enabled": "Job enabled",
"job_disabled": "Job disabled",
"job_schedule_storage_required": "Schedule and storage are required"
}, },
"backups": { "backups": {
"title": "Backups", "title": "Backups",
@@ -582,6 +625,10 @@
"notes": "Notes", "notes": "Notes",
"vmids": "VMIDs", "vmids": "VMIDs",
"storage_col": "Storage", "storage_col": "Storage",
"keep_last": "Last",
"keep_daily": "Daily",
"keep_weekly": "Weekly",
"keep_monthly": "Monthly",
"select_server": "Select server", "select_server": "Select server",
"select_storage": "Select storage", "select_storage": "Select storage",
"node": "Node", "node": "Node",

View File

@@ -220,6 +220,27 @@
"owner_hint": "Назначить инстанс пользователю (только админ).", "owner_hint": "Назначить инстанс пользователю (только админ).",
"saved_config": "Сохранённая конфигурация", "saved_config": "Сохранённая конфигурация",
"saved_config_hint": "Значения, сохранённые панелью при создании/переустановке.", "saved_config_hint": "Значения, сохранённые панелью при создании/переустановке.",
"migrate": "Миграция",
"migrate_desc": "Перенести на другую ноду кластера:",
"migrate_started": "Миграция запущена в фоне",
"migrate_target_node": "Целевая нода",
"migrate_select_node": "Выберите ноду",
"migrate_no_targets": "В кластере нет других нод.",
"migrate_target_storage": "Целевое хранилище",
"migrate_same_storage": "Оставить как у источника",
"migrate_online": "Онлайн (live) миграция",
"migrate_online_hint_running": "Инстанс запущен — live-миграция переносит его без остановки.",
"migrate_online_hint_stopped": "Инстанс остановлен — оставьте выключенным для обычной офлайн-миграции.",
"autostart": "Автозапуск",
"autostart_saved": "Настройки автозапуска сохранены",
"start_on_boot": "Запускать при загрузке",
"start_on_boot_hint": "Автоматически запускать инстанс при загрузке ноды.",
"startup_order": "Порядок запуска",
"startup_up_delay": "Задержка старта (сек)",
"startup_down_timeout": "Таймаут остановки (сек)",
"startup_order_hint": "Меньший порядок запускается раньше. Задержка старта — пауза перед следующим инстансом; таймаут остановки — пауза перед принудительным выключением. Пусто — значения по умолчанию.",
"protection": "Защита от удаления",
"protection_hint": "Запрещает удаление инстанса и его дисков.",
"compute_resources": "Вычислительные ресурсы", "compute_resources": "Вычислительные ресурсы",
"vcpu_cores": "Ядра vCPU", "vcpu_cores": "Ядра vCPU",
"current_cores": "Сейчас: {{count}} ядер", "current_cores": "Сейчас: {{count}} ядер",
@@ -526,7 +547,11 @@
"version": "Версия", "version": "Версия",
"imported": "Импортировано", "imported": "Импортировано",
"select_server": "Выберите сервер", "select_server": "Выберите сервер",
"import_btn": "Импортировать шаблоны" "import_btn": "Импортировать шаблоны",
"group": "Группа",
"edit_group": "Изменить группу",
"select_group": "Выберите группу",
"group_updated": "Группа обновлена"
}, },
"storages": { "storages": {
"title": "Хранилища", "title": "Хранилища",
@@ -556,7 +581,25 @@
"delete_confirm": "Удалить хранилище", "delete_confirm": "Удалить хранилище",
"native_jobs": "Нативные задания бэкапов PVE", "native_jobs": "Нативные задания бэкапов PVE",
"no_native_jobs": "Нативных заданий нет", "no_native_jobs": "Нативных заданий нет",
"schedule": "Расписание" "schedule": "Расписание",
"schedule_hint": "systemd calendar event, напр. «sat 03:00» или «mon..fri 02:30».",
"compress": "Сжатие",
"no_compress": "Без сжатия",
"selection": "Выбор гостей",
"select_all_guests": "Все гости",
"select_vmids": "Выбранные VMID",
"retention": "Хранение (prune-backups)",
"comment": "Комментарий",
"mailto": "Email-уведомления",
"add_native_job": "Добавить задание",
"edit_native_job": "Редактировать задание",
"delete_job_confirm": "Удалить задание бэкапа",
"job_created": "Задание бэкапа создано",
"job_updated": "Задание бэкапа обновлено",
"job_deleted": "Задание бэкапа удалено",
"job_enabled": "Задание включено",
"job_disabled": "Задание выключено",
"job_schedule_storage_required": "Нужны расписание и хранилище"
}, },
"backups": { "backups": {
"title": "Бэкапы", "title": "Бэкапы",
@@ -582,6 +625,10 @@
"notes": "Заметки", "notes": "Заметки",
"vmids": "VMID", "vmids": "VMID",
"storage_col": "Хранилище", "storage_col": "Хранилище",
"keep_last": "Последних",
"keep_daily": "Дневных",
"keep_weekly": "Недельных",
"keep_monthly": "Месячных",
"select_server": "Выберите сервер", "select_server": "Выберите сервер",
"select_storage": "Выберите хранилище", "select_storage": "Выберите хранилище",
"node": "Узел", "node": "Узел",

View File

@@ -11,7 +11,7 @@ export interface DeployTask {
node: string | null; node: string | null;
error_message: string | null; error_message: string | null;
created_at: number; created_at: number;
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template'; kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template' | 'migrate';
server_id?: number | null; server_id?: number | null;
} }

View File

@@ -133,6 +133,8 @@ export interface VMConfig {
boot?: string; boot?: string;
agent?: number; agent?: number;
onboot?: number; onboot?: number;
startup?: string;
protection?: number;
[key: string]: unknown; [key: string]: unknown;
} }

0
pve Executable file → Normal file
View File

0
setup_ssh.sh Executable file → Normal file
View File

0
update_host.sh Executable file → Normal file
View File