feat(instances): add VM/LXC migration, autostart/boot order, protection
- 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:
24
.claude/settings.json
Normal file
24
.claude/settings.json
Normal 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
9
CLAUDE.md
Normal 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).
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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 образ')}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 }[] }>(
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Узел",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
setup_ssh.sh
Executable file → Normal file
0
setup_ssh.sh
Executable file → Normal file
0
update_host.sh
Executable file → Normal file
0
update_host.sh
Executable file → Normal file
Reference in New Issue
Block a user