Compare commits
2 Commits
e10f9a2a03
...
99d8ce750d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d8ce750d | ||
|
|
8b4c8c3a30 |
@@ -8,6 +8,7 @@ progress through DeployTask records (with `kind` field) + WebSocket broadcast.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
@@ -935,6 +936,69 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
|
||||
db.close()
|
||||
|
||||
|
||||
# ==================== Image upload (local file) ====================
|
||||
|
||||
def _do_image_upload_sync(task_id: int, server_id: int, node: str, storage: str,
|
||||
content: str, filename: str, tmp_path: str, tmp_dir: str,
|
||||
checksum: Optional[str], checksum_algorithm: Optional[str],
|
||||
user_id: int, username: str):
|
||||
"""Проксировать локально загруженный файл (уже сохранён на диске бэкенда
|
||||
endpoint'ом /images/upload) в хранилище ноды через Proxmox upload API.
|
||||
|
||||
В отличие от _do_image_download_sync, здесь нет UPID для polling'а прогресса
|
||||
(Proxmox отдаёт upload как единый блокирующий multipart POST) — прогресс
|
||||
задачи поэтому грубее: 'running' на время самого запроса, 'completed' по
|
||||
его завершении.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
server = None
|
||||
try:
|
||||
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10, 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
|
||||
|
||||
# Идемпотентность: если файл уже есть в хранилище — не грузим повторно
|
||||
existing_volid = f'{storage}:{content}/{filename}'
|
||||
if client.volume_exists(node, storage, existing_volid):
|
||||
_update_deploy_task(task_id, 'completed', f'Файл {filename} уже в хранилище', 100, node=node)
|
||||
logger.info(f"[IMG UPLOAD #{task_id}] {filename} already present on {node}:{storage}")
|
||||
return
|
||||
|
||||
_update_deploy_task(task_id, 'running', f'Загрузка {filename} на ноду...', 30, node=node)
|
||||
try:
|
||||
with open(tmp_path, 'rb') as f:
|
||||
client.upload_file(node, storage, content, filename, f,
|
||||
checksum=checksum, checksum_algorithm=checksum_algorithm)
|
||||
except Exception as e:
|
||||
_update_deploy_task(task_id, 'failed', 'Ошибка загрузки', 30, error=f'Upload failed: {e}')
|
||||
return
|
||||
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db, action='upload_image',
|
||||
resource_type='storage', resource_id=0,
|
||||
username=username, resource_name=filename,
|
||||
server_id=server_id, server_name=server.name, node_name=node,
|
||||
details={'content': content, 'storage': storage}, success=True,
|
||||
)
|
||||
|
||||
_update_deploy_task(task_id, 'completed', f'Файл {filename} загружен', 100, node=node)
|
||||
logger.info(f"[IMG UPLOAD #{task_id}] {filename} -> {node}:{storage} by {username}")
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
logger.error(f"[IMG UPLOAD #{task_id}] failed: {err}")
|
||||
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
|
||||
finally:
|
||||
db.close()
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _get_or_create_os_group(db, hint: Optional[str]) -> int:
|
||||
"""Найти/создать группу шаблонов по ОС (Ubuntu, AlmaLinux, ...).
|
||||
|
||||
|
||||
@@ -7,9 +7,16 @@ qcow2 (content=import) / vztmpl с прогрессом через DeployTask +
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from email.message import Message as _EmailMessage
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, model_validator
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -24,6 +31,18 @@ from ._helpers import _get_proxmox_client
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sanitize_filename(name: str, default: str = 'upload.iso') -> str:
|
||||
"""Свести произвольное (клиентское) имя файла к безопасному basename.
|
||||
|
||||
Отбрасывает путь и заменяет всё, кроме букв/цифр/._- на подчёркивание;
|
||||
strip('.') нейтрализует '..', '.' и ведущие точки — иначе basename вида
|
||||
'..' привёл бы к записи временного файла за пределами tmp_dir.
|
||||
"""
|
||||
name = os.path.basename((name or '').strip().replace('\\', '/'))
|
||||
name = re.sub(r'[^A-Za-z0-9._-]+', '_', name).strip('.')
|
||||
return name or default
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ImageDownloadResponse(BaseModel):
|
||||
@@ -243,6 +262,47 @@ def get_image_target_storages(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _filename_from_url_response(url: str, headers) -> str:
|
||||
"""Имя файла из Content-Disposition ответа, иначе — хвост пути (после редиректов)."""
|
||||
cd = headers.get('content-disposition')
|
||||
if cd:
|
||||
msg = _EmailMessage()
|
||||
msg['content-disposition'] = cd
|
||||
name = msg.get_filename()
|
||||
if name:
|
||||
return unquote(name.strip())
|
||||
path = urlparse(url).path
|
||||
return unquote(path.rsplit('/', 1)[-1]) or 'download'
|
||||
|
||||
|
||||
@router.get("/api/images/resolve-filename")
|
||||
async def resolve_image_filename(
|
||||
url: str = Query(..., description="URL образа для определения реального имени файла"),
|
||||
current_user: User = Depends(PermissionChecker("template:view")),
|
||||
):
|
||||
"""
|
||||
Определить настоящее имя файла по произвольному URL.
|
||||
|
||||
Простой хвост пути (как делает фронт на лету) не годится для ссылок вида
|
||||
/download?id=123 без расширения — здесь сервер обычно возвращает реальное
|
||||
имя в Content-Disposition, поэтому сначала пробуем HEAD и читаем заголовок,
|
||||
а если сервер не поддерживает HEAD — открываем GET-поток и сразу же его
|
||||
закрываем, не скачивая тело.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0, verify=False) as hc:
|
||||
try:
|
||||
resp = await hc.head(url)
|
||||
if resp.status_code >= 400:
|
||||
raise httpx.HTTPStatusError("HEAD not usable", request=resp.request, response=resp)
|
||||
return {"filename": _filename_from_url_response(str(resp.url), resp.headers)}
|
||||
except Exception:
|
||||
async with hc.stream('GET', url) as stream_resp:
|
||||
return {"filename": _filename_from_url_response(str(stream_resp.url), stream_resp.headers)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Не удалось определить имя файла: {e}")
|
||||
|
||||
|
||||
# ── Download ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
|
||||
@@ -348,3 +408,80 @@ async def download_image(
|
||||
|
||||
logger.info(f"[IMG DL] Task #{task_id} ({kind}) queued: {src['filename']} by {current_user.username}")
|
||||
return ImageDownloadResponse(task_id=task_id, status='pending', name=src['name'])
|
||||
|
||||
|
||||
@router.post("/api/{server_id}/images/upload", response_model=ImageDownloadResponse)
|
||||
async def upload_image_file(
|
||||
server_id: int,
|
||||
node: str = Form(...),
|
||||
storage: str = Form(...),
|
||||
content: str = Form('iso'),
|
||||
filename: Optional[str] = Form(None),
|
||||
checksum: Optional[str] = Form(None),
|
||||
checksum_algorithm: Optional[str] = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("template:manage")),
|
||||
):
|
||||
"""Загрузить локальный файл (ISO/vztmpl) на ноду.
|
||||
|
||||
Сохраняем присланный файл во временный файл на диске бэкенда (basename ==
|
||||
итоговое имя в хранилище — так его подхватит client.upload_file через
|
||||
file_obj.name), затем фоновый воркер проксирует его в Proxmox через upload
|
||||
API и чистит за собой временную директорию.
|
||||
"""
|
||||
from ..templates import _deploy_executor
|
||||
from .async_ops import _do_image_upload_sync, _normalize_iso_filename
|
||||
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
if content not in ('iso', 'vztmpl'):
|
||||
raise HTTPException(status_code=400, detail=f"Неподдерживаемый тип контента: {content}")
|
||||
|
||||
final_name = _sanitize_filename(filename or file.filename or 'upload.iso')
|
||||
if content == 'iso':
|
||||
final_name = _normalize_iso_filename(final_name)
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix='pveup_')
|
||||
tmp_path = os.path.join(tmp_dir, final_name)
|
||||
size = 0
|
||||
try:
|
||||
with open(tmp_path, 'wb') as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
size += len(chunk)
|
||||
except Exception as e:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail=f"Не удалось сохранить файл: {e}")
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
if size == 0:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail="Пустой файл")
|
||||
|
||||
task = DeployTask(
|
||||
status='pending', step='В очереди...', progress=0,
|
||||
kind='image_upload', name=final_name,
|
||||
server_id=server_id, user_id=current_user.id, node=node,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
task_id = task.id
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_in_executor(
|
||||
_deploy_executor, _do_image_upload_sync,
|
||||
task_id, server_id, node, storage, content,
|
||||
final_name, tmp_path, tmp_dir, checksum, checksum_algorithm,
|
||||
current_user.id, current_user.username,
|
||||
)
|
||||
|
||||
logger.info(f"[IMG UPLOAD] Task #{task_id} queued: {final_name} ({size} bytes) by {current_user.username}")
|
||||
return ImageDownloadResponse(task_id=task_id, status='pending', name=final_name)
|
||||
|
||||
@@ -951,6 +951,13 @@ async def delete_vm(
|
||||
# Удаляем VM
|
||||
result = client.delete_vm(node, vmid)
|
||||
if result:
|
||||
# Помечаем запись в локальном кэше удалённой сразу, не дожидаясь
|
||||
# фонового sync_vm_cache (тикает раз в 10с) — иначе VM ещё
|
||||
# какое-то время висит в списке после реального удаления.
|
||||
try:
|
||||
soft_delete_vm_instance(db, server_id, vmid)
|
||||
except Exception as _sde:
|
||||
logger.warning(f"Failed to soft-delete cached VM {vmid}: {_sde}")
|
||||
# Освобождаем IP в IPAM (если есть)
|
||||
try:
|
||||
ipam = IPAMService(db)
|
||||
@@ -1003,8 +1010,13 @@ async def delete_vm(
|
||||
success=True
|
||||
)
|
||||
logger.info(f"User {current_user.username} deleted VM {vmid} on {server.name}")
|
||||
try:
|
||||
from ...websocket_manager import broadcast_event
|
||||
broadcast_event("vm_deleted", server_id=server_id, vmid=vmid, node=node, name=vm_name, vm_type="qemu")
|
||||
except Exception as _we:
|
||||
logger.warning(f"Failed to broadcast vm_deleted for VM {vmid}: {_we}")
|
||||
return JSONResponse(content={
|
||||
"status": "success",
|
||||
"status": "success",
|
||||
"message": f"VM {vmid} удалена",
|
||||
"snapshots_archived": snapshot_result["archived"],
|
||||
"snapshots_deleted": snapshot_result["deleted"]
|
||||
@@ -1312,6 +1324,13 @@ async def delete_container(
|
||||
# Удаляем контейнер
|
||||
result = client.delete_container(node, vmid)
|
||||
if result:
|
||||
# Помечаем запись в локальном кэше удалённой сразу, не дожидаясь
|
||||
# фонового sync_vm_cache (тикает раз в 10с) — иначе контейнер ещё
|
||||
# какое-то время висит в списке после реального удаления.
|
||||
try:
|
||||
soft_delete_vm_instance(db, server_id, vmid)
|
||||
except Exception as _sde:
|
||||
logger.warning(f"Failed to soft-delete cached container {vmid}: {_sde}")
|
||||
# Освобождаем IP в IPAM (если есть)
|
||||
try:
|
||||
ipam = IPAMService(db)
|
||||
@@ -1359,8 +1378,13 @@ async def delete_container(
|
||||
)
|
||||
|
||||
logger.info(f"User {current_user.username} deleted container {vmid} on {server.name}")
|
||||
try:
|
||||
from ...websocket_manager import broadcast_event
|
||||
broadcast_event("vm_deleted", server_id=server_id, vmid=vmid, node=node, name=container_name, vm_type="lxc")
|
||||
except Exception as _we:
|
||||
logger.warning(f"Failed to broadcast vm_deleted for container {vmid}: {_we}")
|
||||
return JSONResponse(content={
|
||||
"status": "success",
|
||||
"status": "success",
|
||||
"message": f"Контейнер {vmid} удалён",
|
||||
"snapshots_archived": snapshot_result["archived"],
|
||||
"snapshots_deleted": snapshot_result["deleted"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
from typing import List, Dict, Optional, Union, Any, BinaryIO
|
||||
import time
|
||||
import urllib3
|
||||
from loguru import logger
|
||||
@@ -119,6 +119,46 @@ class StorageMixin:
|
||||
logger.error(f"Ошибка загрузки {url} на {node}:{storage}: {e}")
|
||||
raise
|
||||
|
||||
def upload_file(self, node: str, storage: str, content: str, filename: str,
|
||||
file_obj: BinaryIO, checksum: Optional[str] = None,
|
||||
checksum_algorithm: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Загрузить локальный файл (ISO / vztmpl) прямо в хранилище ноды через
|
||||
POST /nodes/{node}/storage/{storage}/upload (multipart).
|
||||
|
||||
proxmoxer сам детектирует io.IOBase-значения в data и переносит их в
|
||||
files, используя requests_toolbelt для потоковой отправки больших
|
||||
файлов (иначе есть риск упереться в 2 GiB лимит SSL-буфера). Имя файла
|
||||
в хранилище Proxmox берёт из file_obj.name — поэтому file_obj должен
|
||||
быть открыт из пути, чей basename уже равен нужному filename.
|
||||
|
||||
Args:
|
||||
node: имя ноды
|
||||
storage: целевое хранилище
|
||||
content: тип контента ('iso' или 'vztmpl')
|
||||
filename: имя файла в хранилище (для логов; фактическое имя Proxmox
|
||||
берёт из file_obj.name)
|
||||
file_obj: открытый бинарный файл (basename == filename)
|
||||
checksum: контрольная сумма (опционально)
|
||||
checksum_algorithm: алгоритм (md5/sha1/sha224/sha256/sha384/sha512)
|
||||
|
||||
Returns:
|
||||
UPID задачи или None
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
try:
|
||||
params = {'content': content, 'filename': file_obj}
|
||||
if checksum and checksum_algorithm:
|
||||
params['checksum'] = checksum
|
||||
params['checksum-algorithm'] = checksum_algorithm
|
||||
result = self.proxmox.nodes(node).storage(storage).upload.post(**params)
|
||||
logger.info(f"Загружен файл {filename} ({content}) на {node}:{storage}, UPID: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки файла {filename} на {node}:{storage}: {e}")
|
||||
raise
|
||||
|
||||
def volume_exists(self, node: str, storage: str, volid: str) -> bool:
|
||||
"""Проверить, есть ли уже том volid в хранилище (для идемпотентной загрузки)."""
|
||||
if not self.proxmox:
|
||||
|
||||
@@ -10,6 +10,7 @@ paramiko==3.4.1
|
||||
psutil==6.1.0
|
||||
proxmoxer==2.1.0
|
||||
requests==2.32.3
|
||||
requests-toolbelt==1.0.0
|
||||
aiofiles==24.1.0
|
||||
python-dotenv==1.0.1
|
||||
httpx==0.27.0
|
||||
|
||||
@@ -77,7 +77,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
start_period: 30s
|
||||
# Ограничение ресурсов для стабильности
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
@@ -27,6 +27,12 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
# Загрузка ISO/образов: файлы многогигабайтные, дефолтный
|
||||
# client_max_body_size 1m этого server{} блока и короткие таймауты
|
||||
# им не подходят.
|
||||
client_max_body_size 20g;
|
||||
proxy_send_timeout 300s;
|
||||
client_body_timeout 3600s;
|
||||
}
|
||||
|
||||
# Settings API proxy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Download } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Download, Upload, Wand2, FileUp, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useImageStorages, useDownloadImage } from '@/hooks/use-image-catalog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useImageStorages,
|
||||
useDownloadImage,
|
||||
useUploadImageFile,
|
||||
useResolveFilename,
|
||||
} from '@/hooks/use-image-catalog';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -43,20 +50,32 @@ function fmtSize(bytes?: number): string {
|
||||
return `${gb.toFixed(0)} GB`;
|
||||
}
|
||||
|
||||
/** Имя файла из URL: хвост пути без query-строки. */
|
||||
/** Размер выбранного локального файла: MB до 1 GB, иначе GB. */
|
||||
function fmtFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Имя файла из URL: хвост пути без query-строки (грубая догадка на лету). */
|
||||
function filenameFromUrl(url: string): string {
|
||||
const path = url.split('?')[0].split('#')[0];
|
||||
return path.split('/').pop() || '';
|
||||
}
|
||||
|
||||
type Mode = 'url' | 'local';
|
||||
|
||||
export default function DownloadIsoDialog({ open, onClose, serverId, node, source }: Props) {
|
||||
const fromMirror = !!source?.source_id;
|
||||
|
||||
const [mode, setMode] = useState<Mode>('url');
|
||||
const [url, setUrl] = useState('');
|
||||
const [filename, setFilename] = useState('');
|
||||
const [storage, setStorage] = useState('');
|
||||
const [checksum, setChecksum] = useState('');
|
||||
const [checksumAlgo, setChecksumAlgo] = useState('sha256');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: storages = [], isLoading: storagesLoading } = useImageStorages(
|
||||
open ? serverId : undefined,
|
||||
@@ -65,14 +84,19 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
);
|
||||
|
||||
const download = useDownloadImage(serverId);
|
||||
const uploadFile = useUploadImageFile(serverId);
|
||||
const resolveFilename = useResolveFilename();
|
||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode('url');
|
||||
setUrl(source?.url ?? '');
|
||||
setFilename(source?.url ? filenameFromUrl(source.url) : '');
|
||||
setChecksum('');
|
||||
setChecksumAlgo('sha256');
|
||||
setSelectedFile(null);
|
||||
setIsDragging(false);
|
||||
}, [open, source?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,11 +105,67 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
|
||||
// Пользователь мог не тронуть имя — тогда ведём его за URL.
|
||||
const effectiveFilename = filename.trim() || filenameFromUrl(url);
|
||||
const isPending = download.isPending || uploadFile.isPending;
|
||||
const submitDisabled =
|
||||
!storage || download.isPending || (!fromMirror && (!url.trim() || !effectiveFilename));
|
||||
!storage || isPending ||
|
||||
(fromMirror ? false : mode === 'url' ? (!url.trim() || !effectiveFilename) : !selectedFile);
|
||||
|
||||
const handleDetectFilename = () => {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return;
|
||||
resolveFilename.mutate(trimmed, {
|
||||
onSuccess: (data) => {
|
||||
if (data.filename) setFilename(data.filename);
|
||||
else toast.error('Не удалось определить имя файла');
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const pickFile = (file: File | undefined | null) => {
|
||||
if (!file) return;
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const onSuccess = (data: { task_id: number; status: string; name: string }, msg: string) => {
|
||||
addDeployTask({
|
||||
id: data.task_id,
|
||||
name: data.name,
|
||||
status: 'pending',
|
||||
step: 'В очереди...',
|
||||
progress: 0,
|
||||
vmid: null,
|
||||
node,
|
||||
error_message: null,
|
||||
kind: mode === 'local' ? 'image_upload' : 'image_download',
|
||||
server_id: serverId,
|
||||
});
|
||||
toast.success(msg);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (submitDisabled) return;
|
||||
|
||||
if (mode === 'local' && !fromMirror) {
|
||||
if (!selectedFile) return;
|
||||
uploadFile.mutate(
|
||||
{
|
||||
node,
|
||||
storage,
|
||||
content: 'iso',
|
||||
file: selectedFile,
|
||||
checksum: checksum.trim() || undefined,
|
||||
checksum_algorithm: checksum.trim() ? checksumAlgo : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => onSuccess(data, 'Загрузка ISO на ноду запущена в фоне'),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
download.mutate(
|
||||
{
|
||||
node,
|
||||
@@ -98,22 +178,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
checksum_algorithm: checksum.trim() ? checksumAlgo : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
addDeployTask({
|
||||
id: data.task_id,
|
||||
name: data.name,
|
||||
status: 'pending',
|
||||
step: 'В очереди...',
|
||||
progress: 0,
|
||||
vmid: null,
|
||||
node,
|
||||
error_message: null,
|
||||
kind: 'image_download',
|
||||
server_id: serverId,
|
||||
});
|
||||
toast.success('Загрузка ISO запущена в фоне');
|
||||
onClose();
|
||||
},
|
||||
onSuccess: (data) => onSuccess(data, 'Загрузка ISO запущена в фоне'),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
@@ -125,7 +190,11 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
<DialogHeader>
|
||||
<DialogTitle>Загрузить ISO</DialogTitle>
|
||||
<DialogDescription>
|
||||
{fromMirror ? source?.name : 'Proxmox скачает образ по ссылке прямо на ноду'}
|
||||
{fromMirror
|
||||
? source?.name
|
||||
: mode === 'local'
|
||||
? 'Файл будет передан на ноду через панель'
|
||||
: 'Proxmox скачает образ по ссылке прямо на ноду'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -135,29 +204,105 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
</div>
|
||||
|
||||
{!fromMirror && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="iso-url">URL образа</Label>
|
||||
<Input
|
||||
id="iso-url"
|
||||
placeholder="https://releases.example.org/linux-24.04-amd64.iso"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
<Tabs value={mode} onValueChange={(v) => v && setMode(v as Mode)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="url" className="flex-1">
|
||||
<Download className="mr-1.5 h-4 w-4" />По ссылке (URL)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="local" className="flex-1">
|
||||
<FileUp className="mr-1.5 h-4 w-4" />Локальный файл
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="url" className="mt-4 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="iso-url">URL образа</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="iso-url"
|
||||
placeholder="https://releases.example.org/linux-24.04-amd64.iso"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleDetectFilename}
|
||||
disabled={!url.trim() || resolveFilename.isPending}
|
||||
title="Определить имя файла по ссылке"
|
||||
>
|
||||
{resolveFilename.isPending
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Wand2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="iso-filename">Имя файла в хранилище</Label>
|
||||
<Input
|
||||
id="iso-filename"
|
||||
placeholder={filenameFromUrl(url) || 'linux-24.04-amd64.iso'}
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Proxmox принимает только .iso и .img — иначе расширение будет добавлено автоматически.
|
||||
Кнопка со звёздочкой рядом с URL определяет настоящее имя файла (по Content-Disposition
|
||||
или итоговой ссылке после редиректов), если оно не видно из самого URL.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="local" className="mt-4 space-y-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".iso,.img"
|
||||
className="hidden"
|
||||
onChange={(e) => pickFile(e.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="iso-filename">Имя файла в хранилище</Label>
|
||||
<Input
|
||||
id="iso-filename"
|
||||
placeholder={filenameFromUrl(url) || 'linux-24.04-amd64.iso'}
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Proxmox принимает только .iso и .img — иначе расширение будет добавлено автоматически.
|
||||
{!selectedFile ? (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
pickFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 text-center transition-colors',
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50',
|
||||
)}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Перетащите ISO-файл сюда</p>
|
||||
<p className="text-xs text-muted-foreground">или нажмите, чтобы выбрать (.iso, .img)</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{fmtFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSelectedFile(null)}
|
||||
title="Убрать файл"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Имя файла в хранилище назначается автоматически — из имени выбранного файла.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div>
|
||||
@@ -209,8 +354,12 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||
<Button onClick={submit} disabled={submitDisabled}>
|
||||
{download.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
Скачать
|
||||
{isPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: mode === 'local' && !fromMirror
|
||||
? <Upload className="mr-2 h-4 w-4" />
|
||||
: <Download className="mr-2 h-4 w-4" />}
|
||||
{mode === 'local' && !fromMirror ? 'Загрузить' : 'Скачать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -80,7 +81,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { ColumnFilter, multiSelectFilter } from '@/components/shared/column-filter';
|
||||
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync, useBulkTasksSync } from '@/hooks/use-instances';
|
||||
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync, useBulkTasksSync, vmKeys } from '@/hooks/use-instances';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { formatBytes, vmTypeLabel, formatUptime, formatVmConfig } from '@/lib/format';
|
||||
@@ -291,6 +292,7 @@ export default function InstancesPage() {
|
||||
}, [t]);
|
||||
|
||||
// ── Deploy task polling ──────────────────────────────────────────
|
||||
const qc = useQueryClient();
|
||||
const { tasks: deployTasks, updateTask, removeTask } = useDeployTasksStore();
|
||||
const activeTasks = deployTasks.filter((t) => t.status === 'pending' || t.status === 'running');
|
||||
|
||||
@@ -318,6 +320,10 @@ export default function InstancesPage() {
|
||||
{ description: data.error_message, duration: 15000 },
|
||||
);
|
||||
}
|
||||
// Новая VM уже сохранена в кэше бэкендом — подтягиваем список сразу,
|
||||
// не дожидаясь фонового vm_created от 10-секундного sync_vm_cache.
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
setTimeout(() => removeTask(task.id), 5000);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -99,6 +99,50 @@ export function useDownloadImage(serverId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export interface ImageUploadRequest {
|
||||
node: string;
|
||||
storage: string;
|
||||
content: 'iso' | 'vztmpl';
|
||||
file: File;
|
||||
filename?: string;
|
||||
checksum?: string;
|
||||
checksum_algorithm?: string;
|
||||
}
|
||||
|
||||
// Загрузка локального файла (drag&drop) на ноду: multipart, без JSON.
|
||||
export function useUploadImageFile(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: ImageUploadRequest) => {
|
||||
const form = new FormData();
|
||||
form.append('node', data.node);
|
||||
form.append('storage', data.storage);
|
||||
form.append('content', data.content);
|
||||
if (data.filename) form.append('filename', data.filename);
|
||||
if (data.checksum) form.append('checksum', data.checksum);
|
||||
if (data.checksum_algorithm) form.append('checksum_algorithm', data.checksum_algorithm);
|
||||
form.append('file', data.file);
|
||||
return apiClient.postForm<{ task_id: number; status: string; name: string }>(
|
||||
`/proxmox/api/${serverId}/images/upload`,
|
||||
form,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Определить реальное имя файла по URL (Content-Disposition / хвост после редиректов).
|
||||
export function useResolveFilename() {
|
||||
return useMutation({
|
||||
mutationFn: (url: string) =>
|
||||
apiClient.get<{ filename: string }>(
|
||||
`/proxmox/api/images/resolve-filename?url=${encodeURIComponent(url)}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Custom mirrors (admin) ───────────────────────────────────────────────────
|
||||
|
||||
export function useImageMirrors(enabled = true) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { Workspace } from '@/types';
|
||||
import { nodeKeys } from './use-nodes';
|
||||
import { vmKeys } from './use-instances';
|
||||
|
||||
export const workspaceKeys = {
|
||||
all: ['workspaces'] as const,
|
||||
@@ -61,7 +63,14 @@ export function useUpdateWorkspaceServers() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
|
||||
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/servers`, { ids }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.all });
|
||||
qc.invalidateQueries({ queryKey: ['workspace'] });
|
||||
qc.invalidateQueries({ queryKey: nodeKeys.servers });
|
||||
qc.invalidateQueries({ queryKey: nodeKeys.topology });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +79,13 @@ export function useUpdateWorkspaceUsers() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
|
||||
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/users`, { ids }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.all });
|
||||
qc.invalidateQueries({ queryKey: ['workspace'] });
|
||||
qc.invalidateQueries({ queryKey: nodeKeys.servers });
|
||||
qc.invalidateQueries({ queryKey: nodeKeys.topology });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,6 +128,31 @@ class ApiClient {
|
||||
headers,
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
// multipart/form-data: без Content-Type (браузер сам проставит boundary) и
|
||||
// без JSON.stringify тела — для загрузки файлов (напр. локальный ISO).
|
||||
async postForm<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
const wsId = getActiveWorkspaceId();
|
||||
if (wsId) {
|
||||
headers['X-Active-Workspace'] = String(wsId);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
private async handleResponse<T>(res: Response, path: string): Promise<T> {
|
||||
if (res.status === 401) {
|
||||
// 401 от самого логина — это «неверный логин/пароль»: отдаём ошибку в
|
||||
// форму, а не перезагружаем страницу (иначе сообщение теряется).
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface DeployTask {
|
||||
node: string | null;
|
||||
error_message: string | null;
|
||||
created_at: number;
|
||||
kind?: 'deploy' | 'vm_iso' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template' | 'migrate' | 'remote_migrate';
|
||||
kind?: 'deploy' | 'vm_iso' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_upload' | 'image_template' | 'migrate' | 'remote_migrate';
|
||||
server_id?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,12 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
# Загрузка ISO/образов: файлы многогигабайтные, дефолтный
|
||||
# client_max_body_size 100M и короткие таймауты им не подходят.
|
||||
client_max_body_size 20g;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
client_body_timeout 3600s;
|
||||
}
|
||||
|
||||
# WebSocket support — tasks и terminal
|
||||
|
||||
@@ -45,7 +45,12 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
# Загрузка ISO/образов: файлы многогигабайтные, дефолтный
|
||||
# client_max_body_size 100M и короткие таймауты им не подходят.
|
||||
client_max_body_size 20g;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
client_body_timeout 3600s;
|
||||
}
|
||||
|
||||
# WebSocket support — tasks и terminal
|
||||
|
||||
@@ -73,7 +73,12 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
# Загрузка ISO/образов: файлы многогигабайтные, дефолтный
|
||||
# client_max_body_size 100M и короткие таймауты им не подходят.
|
||||
client_max_body_size 20g;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
client_body_timeout 3600s;
|
||||
}
|
||||
|
||||
# WebSocket support — tasks и terminal
|
||||
|
||||
Reference in New Issue
Block a user