2 Commits

Author SHA1 Message Date
markmorado
99d8ce750d feat(images): support uploading local ISO/vztmpl files to node storage
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Adds a local file upload path alongside the existing URL download flow:
a new /images/upload endpoint streams the file to a temp path and hands
it to a background worker that proxies it into Proxmox storage via the
upload API (requests-toolbelt handles the large multipart stream). Also
adds /images/resolve-filename to recover the real filename (Content-
Disposition) for URL-based downloads with opaque paths.

Raise nginx client_max_body_size and timeouts on both the frontend proxy
and the standalone nginx configs so multi-GB ISOs don't get cut off.
2026-07-24 06:17:46 +05:00
markmorado
8b4c8c3a30 fix(realtime): sync instance/workspace list state without manual refresh
- Invalidate workspace list cache after assigning servers/users so
  server/user counts on workspace cards update immediately.
- Invalidate node/VM caches after workspace server/user assignment so
  the Nodes page reflects visibility changes right away.
- Soft-delete the cached VM/LXC row and broadcast vm_deleted the moment
  a VM/container is destroyed, instead of waiting for the 10s
  background sync_vm_cache tick.
- Invalidate the VM list on the frontend when a deploy task completes,
  since the backend already persists the new instance to cache
  synchronously before marking the task done.
2026-07-24 05:39:23 +05:00
16 changed files with 584 additions and 57 deletions

View File

@@ -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, ...).

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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:

View File

@@ -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

View File

@@ -77,7 +77,7 @@ services:
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
start_period: 30s
# Ограничение ресурсов для стабильности
deploy:
resources:

View File

@@ -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

View File

@@ -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>

View File

@@ -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 */ }

View File

@@ -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) {

View File

@@ -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 });
},
});
}

View File

@@ -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 от самого логина — это «неверный логин/пароль»: отдаём ошибку в
// форму, а не перезагружаем страницу (иначе сообщение теряется).

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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