fix(reinstall): use OSTemplate.vmid (not template_vmid) and convert memory bytes->MB

- AttributeError: OSTemplate has no 'template_vmid' field; the actual column is 'vmid'.
- Use OSTemplate.get_vmid_for_node() to support cross-node replicated templates,
  falling back to source_node/vmid otherwise.
- VMInstance.memory is stored in bytes, but Proxmox config expects MB.
This commit is contained in:
markmorado
2026-04-29 23:48:48 +02:00
parent 5169bf80a3
commit 89d70654fc
45 changed files with 2946 additions and 741 deletions

57
.github/workflows/backend-tests.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Backend Tests
on:
push:
branches: [main]
paths:
- "backend/**"
- ".github/workflows/backend-tests.yml"
pull_request:
branches: [main]
paths:
- "backend/**"
- ".github/workflows/backend-tests.yml"
jobs:
pytest:
name: pytest (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12"]
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: backend/requirements-dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run pytest with coverage
run: |
python -m pytest \
--cov=app \
--cov-report=term-missing \
--cov-report=xml \
-p no:warnings
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage-${{ matrix.python-version }}
path: backend/coverage.xml
if-no-files-found: ignore

View File

@@ -1,147 +0,0 @@
# Отчет об исправлении ошибок в PVEManager
## Проблема
При попытке выполнить следующие операции возвращались HTTP 400 ошибки:
- Переустановка ВМ: `POST /api/{server_id}/vm/{vmid}/reinstall`
- Смена пароля ВМ: `POST /api/{server_id}/vm/{vmid}/change-password`
- Смена пароля LXC: `POST /api/{server_id}/container/{vmid}/change-password`
**Ошибка в логах**:
```
2026-04-30 01:26:56 | ERROR | logging:callHandlers:1762 - Database session error: 400: Invalid action
2026-04-30 01:26:56 | WARNING | app.main:custom_http_exception_handler:265 - HTTP exception: 400 - Invalid action
```
## Причина
В файле `backend/app/logging_service.py` в методе `log_proxmox_action` отсутствовали соответствия для новых типов операций в словаре `action_messages`. Когда система пыталась залогировать операцию, она не находила её описание и возвращала ошибку 400.
### Был код:
```python
action_messages = {
"start": "started",
"stop": "stopped",
"restart": "restarted",
"shutdown": "shutdown",
"delete": "deleted",
"create": "created",
"clone": "cloned",
"migrate": "migrated",
"backup": "backup created",
"restore": "restored",
"snapshot": "snapshot created",
"resize": "disk resized",
"console": "console accessed",
"config_update": "config updated"
# ОТСУТСТВОВАЛИ 'reinstall' и 'change-password'
}
```
## Решение
Добавлены два недостающих типа операций в словарь `action_messages`:
### Исправленный код:
```python
action_messages = {
"start": "started",
"stop": "stopped",
"restart": "restarted",
"shutdown": "shutdown",
"delete": "deleted",
"create": "created",
"clone": "cloned",
"migrate": "migrated",
"backup": "backup created",
"restore": "restored",
"snapshot": "snapshot created",
"resize": "disk resized",
"console": "console accessed",
"config_update": "config updated",
"reinstall": "reinstalled", # ← ДОБАВЛЕНО
"change-password": "password changed" # ← ДОБАВЛЕНО
}
```
## Файлы изменён
- `backend/app/logging_service.py` (строка 265-281)
## Проверка исправления
### Что было протестировано:
✅ LoggingService.log_proxmox_action() - все 16 типов операций присутствуют
✅ Endpoint'ы для новых операций доступны:
- POST /api/{server_id}/vm/{vmid}/reinstall
- POST /api/{server_id}/vm/{vmid}/change-password
- POST /api/{server_id}/container/{vmid}/change-password
✅ Методы ProxmoxClient работают:
- change_vm_password()
- change_container_password()
- clone_vm()
- clone_container()
- delete_vm()
- delete_container()
### Результат тестирования:
```
============================================================
Test Results Summary
============================================================
LoggingService: ✅ PASSED
VM Operations: ✅ PASSED
ProxmoxClient Methods: ✅ PASSED
============================================================
✅ All tests passed!
```
## Деталь операций
### 1. Переустановка ВМ/LXC
Путём переустановки система:
1. Сохраняет текущую конфигурацию (CPU, память, имя, описание)
2. Останавливает текущую инстанцию
3. Удаляет её (без архивирования)
4. Клонирует заново из исходного шаблона
5. Переприменяет сохранённую конфигурацию
Используемые методы: `clone_vm()`, `clone_container()`, `delete_vm()`, `delete_container()`
### 2. Смена пароля ВМ
Изменяет пароль через QEMU guest agent:
- Требует установленный `qemu-guest-agent` в ВМ
- Работает только для запущенных ВМ
- Пароль передаётся в открытом виде через API (используйте HTTPS в продакшене)
Используемый метод: `change_vm_password()`
### 3. Смена пароля LXC
Изменяет пароль через выполнение команды `chpasswd` в контейнере:
- Требует наличие утилиты `chpasswd` в контейнере
- Работает только для запущенных контейнеров
- Использует формат "user:password" для chpasswd
Используемый метод: `change_container_password()`
## Логирование
Все три операции теперь правильно логируются в таблицу `audit_logs`:
```sql
SELECT id, action, resource_type, resource_id, success, created_at
FROM audit_logs
WHERE action IN ('reinstall', 'change-password')
ORDER BY created_at DESC;
```
## Примечание о безопасности
⚠️ **При использовании операции смены пароля через незащищённый HTTP:**
- Пароли передаются в открытом виде
- Используйте HTTPS в продакшене
- Установите переменную окружения `FERNET_KEY` для шифрования паролей в БД
## Версия
Исправление применяется в v1.4.0 и позже.
## Статус
✅ **ГОТОВО К ИСПОЛЬЗОВАНИЮ**
Все новые операции работают корректно и полностью интегрированы в систему логирования.

103
CHANGELOG.ru.md Normal file
View File

@@ -0,0 +1,103 @@
# История изменений
Все заметные изменения в PVEmanager документируются в этом файле.
---
## [v1.4.0] - 2026-04-06
### 🌐 Сети — управление SDN и интерфейсы узлов
- **Страница сетей** — новая объединённая страница (`/proxmox/networks`) для управления зонами SDN, VNet, подсетями и сетевыми интерфейсами на уровне узла; добавлена в боковую навигацию с специальной иконкой
- **Редактирование зоны SDN** — `PUT /api/servers/{server_id}/sdn/zones/{zone}` — обновление свойств зоны (mtu, dns, reversedns, ipam и т.д.)
- **Редактирование VNet SDN** — `PUT /api/servers/{server_id}/sdn/vnets/{vnet}` — обновление alias, VLAN tag, vlanaware и т.д.
- **Удаление подсети SDN** — `DELETE /api/servers/{server_id}/sdn/vnets/{vnet}/subnets/{subnet_cidr}` — удаление подсети; опционально удаление связанной сети IPAM (`?delete_ipam_network=true`)
- **Автоматическое создание сети IPAM при создании подсети** — `POST .../subnets` теперь поддерживает `create_ipam_network: true`; создаёт соответствующую `IPAMNetwork`, связанную с сервером и VNet, или возвращает существующую
- **Сетевые интерфейсы узлов** — полный CRUD для интерфейсов на уровне узла (мосты, связки, VLAN, ethernet): список, получение, создание, обновление, удаление через `proxmox_client.py`
- **Применение / Откат конфигурации сети узла** — `PUT` и `DELETE` на `/nodes/{node}/network` для активации или отката ожидающих изменений
- **API получения списка узлов** — `GET /api/servers/{server_id}/nodes` — возвращает узлы для сервера Proxmox (используется селекторами страницы сетей)
### 🧹 Очистка кода
- **Очистка импортов модуля SDN** — удалены неиспользуемые импорты (`ssl`, `asyncio`, `httpx`, `websockets`, `func`, `List` и т.д.) из `sdn.py`
- **Маршрутизатор сетей** — новый модуль `networks.py` под `api/proxmox/` с маршрутом HTML страницы и API для узлов; зарегистрирован в `proxmox/__init__.py`
### 🌍 i18n
- **49 новых ключей переводов** (EN + RU) для страницы сетей: типы интерфейсов, поля bridge/bond/VLAN, применение/откат конфигурации, метки интеграции IPAM, редактирование зоны/vnet, удаление подсети и многое другое
---
## [v1.3.1] - 2026-04-05
### 🔍 Список ВМ — фильтрация по узлам и адаптивная панель инструментов
- **Фильтр по серверу** — новое выпадающее меню для фильтрации списка ВМ по серверу/кластеру Proxmox; заполняется динамически из загруженных ВМ
- **Фильтр по узлу** — новое выпадающее меню для фильтрации по узлу Proxmox; автоматически сужает список узлов, принадлежащих выбранному серверу
- Оба фильтра сохраняются в параметры URL (`?server=...&node=...`) и восстанавливаются при перезагрузке страницы
- **Панель инструментов переделана в 2 строки** — строка 1: все фильтры (тип / статус / сервер / узел / поиск с иконкой + статистика); строка 2: чекбокс выбрать все, пагинация, кнопки действий
- **Адаптивные точки разрыва** — при ≤900px метки кнопок скрыты; при ≤768px фильтры переносятся 2 в строку; при ≤480px каждый фильтр во всю ширину; счётчик статистики скрыт на мобильных
- Вспомогательные функции `populateServerFilter()` и `populateNodeFilter()` заполняют выпадающие меню из загруженных данных ВМ
- Панель инструментов снимков мигрирована с устаревших классов `.vm-toolbar-left/.vm-toolbar-right` на inline flex, не затронута переделкой панели инструментов
---
## [v1.3.0] - 2026-03-27
### 🖥️ Консоль — исправления VNC и терминала
- **VNC «Connection closed» исправлено** — `RFBClass` (noVNC) теперь получает строку URL WebSocket непосредственно вместо предварительно открытого объекта `WebSocket`; хак `onFirstMessage`, нарушавший квитирование RFB перед первым фреймом, полностью удалён из `virtual_machines.html` и `instance_detail.html`
- **VNCAuth исправлено для QEMU** — `vncproxy` теперь вызывается с `"generate-password": 1`; возвращаемый 8-символьный DES ключ передаётся в noVNC как пароль VNC; возвращается к `ticket` при отсутствии `password` (режим NoAuth Proxmox 9.x)
- **LXC `vncproxy` 500 исправлено** — LXC API `vncproxy` не принимает `generate-password`; параметр пропускается для LXC, который использует `ticket` как пароль VNC
- **Контекстное меню LXC открывает терминал xterm.js** — правый клик в списке ВМ теперь показывает **Терминал** для типа `lxc` (вызывает `openTerminalConsole()`) и **Консоль VNC** для типа `qemu`; ранее все типы использовали `openVNCConsole()`
- **Модальное окно терминала добавлено на страницу списка ВМ** — `#terminalModal` с полным xterm.js (FitAddon, WebLinksAddon, тема Dracula) добавлено в `virtual_machines.html`; переиспользует существующие API endpoints `GET /api/{server_id}/container/{vmid}/terminal` и `WebSocket /proxmox/ws/terminal/{server_id}/{node}/{vmid}`
---
## [v1.2.0] - 2026-03-16
### 🐛 Исправление ошибки — конфликт блокировки при изменении размера диска
- **Основная причина исправлена** — `resize.put()` в API Proxmox асинхронна и возвращает задачу UPID; старый код не ждал завершения этой задачи перед запуском ВМ, что заставляло Proxmox сообщать `can't lock file '/var/lock/qemu-server/lock-xxx.conf' — got timeout` и `command '/usr/bin/qemu-img resize' failed: got timeout`
- **`configure_vm` (создание ВМ из шаблона)** — теперь захватывает UPID изменения размера и вызывает `wait_for_task(120 s)` перед возвратом; ВМ может быть запущена только после полного изменения размера диска
- **`resize_vm_disk`** — то же исправление применено; задача изменения размера завершается перед возвратом функции
- **`resize_container_disk` (оба метода LXC)** — то же исправление; перезапуск контейнера выполняется только после подтверждения завершения задачи изменения размера
- **Удалено ненужное перезагрузка ВМ после изменения размера** — изменение размера диска QEMU происходит на уровне хранилища и не требует перезагрузки ВМ; автоматический вызов `restart_vm()` после ручного изменения размера удалён, чтобы предотвратить второй конфликт блокировки
---
## [v1.1.8] - 2026-03-16
### 👤 Назначение пользователю серверов
- **Таблица `user_servers`** (миграция 20) — новая таблица связей `User ↔ ProxmoxServer`; позволяет напрямую назначать конкретные серверы пользователям
- **API `GET /api/users/{id}/servers`** — список серверов, назначенных пользователю
- **API `GET /api/users/{id}/server-assignments`** — расширенный список всех серверов с флагами `assigned` и `compatible` (совместимость по рабочим областям)
- **API `PUT /api/users/{id}/servers`** — установка набора серверов для пользователя; проверяет пересечение рабочих областей — если сервер и пользователь не состоят ни в одной общей рабочей области, возвращает `409 workspace_conflict`
- **Фильтрация серверов для обычных пользователей** — пользователь видит только серверы, которые ему назначены **и** входят в его рабочую область
### 🖥️ Владелец ВМ / LXC
- **API `GET /api/{server_id}/vm/{vmid}/owner`** — получить текущего владельца ВМ/LXC и список доступных пользователей
- **API `PUT /api/{server_id}/vm/{vmid}/owner`** — назначить или снять владельца (только для админ); действие логируется в аудит
### 🔐 RBAC и роли
- **Миграция 21** — `dashboard:view` принудительно выставлен в `false` для роли `user` (новый формат ключей)
- **Миграция 22** — ключ `proxmox.cluster.manage` → `cluster:manage` в роли `admin`; старый dot-формат удаляется, чтобы не сбрасывать весь карту разрешений
- **`user_count`** в ответе `GET /api/roles` — для каждой роли возвращается количество активных пользователей
### 🚀 Умное перенаправление после входа
- После успешного входа производится запрос `GET /api/auth/me` — пользователь автоматически перенаправляется на первую страницу, к которой у него есть доступ (`/dashboard` → `/virtual-machines` → `/vms` → `/backups` → `/ipam` → `/logs` → `/settings`)
- Поддержка параметра `?returnUrl=` — возврат на запрошенную страницу после входа
### 🔧 Прочие улучшения
- `GET /api/servers` для непривилегированного пользователя объединяет фильтры `assigned_servers` и `WorkspaceServer` — пользователь видит только серверы из своей рабочей области, которые ему назначены
- `node_uptime` добавлен в ответ детального API сервера
- Удалена лишняя проверка `PermissionChecker` на GET-маршруте страницы `/users` (HTML)
---
*Для истории более ранних версий см. английскую версию [CHANGELOG.md](CHANGELOG.md)*

163
README.ru.md Normal file
View File

@@ -0,0 +1,163 @@
<div align="center">
# PVEmanager
**Современная веб-панель для управления серверами Proxmox, виртуальными машинами и контейнерами LXC**
[![Версия](https://img.shields.io/badge/версия-1.4.0-blue?style=flat-square)](CHANGELOG.md)
[![Лицензия](https://img.shields.io/badge/лицензия-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-готов-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.100+-009688?style=flat-square&logo=fastapi&logoColor=white)](backend/)
[![Proxmox](https://img.shields.io/badge/Proxmox%20VE-7.x%20%7C%208.x%20%7C%209.x-E57000?style=flat-square)](https://www.proxmox.com/)
</div>
---
## Описание
PVEmanager — это самостоятельно размещаемая веб-панель, которая предоставляет единый интерфейс для управления несколькими серверами и кластерами Proxmox VE. Она охватывает полный жизненный цикл ВМ/LXC — от развертывания до мониторинга — с контролем доступа на основе ролей, уведомлениями и управлением IP-адресами.
## Возможности
### Инфраструктура
- **Управление несколькими серверами** — подключайте и управляйте несколькими серверами и кластерами Proxmox с единой панели
- **Жизненный цикл ВМ и LXC** — создавайте, запускайте, останавливайте, перезагружайте, изменяйте размер, удаляйте виртуальные машины и контейнеры
- **Массовые операции** — массовый запуск/остановка/перезагрузка/удаление с очередью и отслеживанием прогресса
- **Управление снимками** — создавайте, откатывайте, удаляйте снимки с помощью асинхронной системы очередей
- **Шаблоны ОС** — развертывайте ВМ из шаблонов; автоматическая репликация между узлами
- **Поддержка SDN** — управляйте зонами Software-Defined Networking, VNet и подсетями; редактируйте зоны и VNet; автоматическое создание сетей IPAM для подсетей
- **Сетевые интерфейсы узлов** — управляйте сетевыми интерфейсами на уровне узла (мосты, связки, VLAN); применяйте/откатывайте ожидающую конфигурацию
- **Страница сетей** — унифицированный интерфейс для управления SDN и сетевыми интерфейсами узлов
### Доступ и консоль
- **Консоль VNC** — консоль в браузере через noVNC, не требуется клиентское ПО
- **Терминал xterm.js** — полностью интерактивный shell непосредственно в браузере для контейнеров LXC
- **Удаленные команды** — выполняйте команды bash через QEMU Guest Agent
- **RBAC v2** — детальный контроль доступа на основе ролей с разрешениями для каждого ресурса
- **Назначение серверов** — напрямую назначайте конкретные серверы Proxmox пользователям с проверкой конфликтов рабочих областей
- **Владелец ВМ/LXC** — назначайте пользователя-владельца любой ВМ или контейнеру LXC
- **Управление сессиями** — список активных сессий, отзыв удаленных сессий, защита входа
- **Умное перенаправление входа** — пользователи автоматически маршрутизируются на первую страницу, к которой у них есть доступ
### Мониторинг и оповещения
- **Метрики в реальном времени** — графики CPU, RAM, диска, сети для каждого узла и ВМ
- **Фоновая синхронизация** — автоматическая синхронизация состояния ВМ из API Proxmox
- **Уведомления** — In-App (иконка колокольчика), Email (SMTP), Telegram (Bot API)
- **Журнал аудита** — полный журнал действий с пользователем, временем и деталями
### Прочее
- **IPAM** — управление IP-адресами с историей выделения и обнаружением сиротских адресов
- **Многоязычность** — интерфейс на русском и английском языках
- **Зашифрованные учетные данные** — чувствительные поля хранятся зашифрованными в базе данных
---
## Быстрый старт
```bash
# 1. Клонируем
git clone https://git.tzim.uz/markmorado/pvemanager.git
cd pvemanager
# 2. Конфигурируем
cp .env.example .env
cp backend/.env.example backend/.env
# Отредактируйте .env — установите POSTGRES_PASSWORD и TZ
# 3. Запускаем
docker compose up -d
# 4. Открываем
# http://localhost:8000
# Вход по умолчанию: admin / admin123
```
> **Важно:** Измените пароль по умолчанию сразу после первого входа.
---
## Требования
| Требование | Минимум |
|---|---|
| Docker | 24.x+ |
| Docker Compose | v2.x+ |
| ОЗУ | 512 МБ (приложение) + 256 МБ (БД) |
| Proxmox VE | 7.x / 8.x / 9.x |
---
## Конфигурация
### Переменные окружения (`.env`)
```bash
# База данных
POSTGRES_PASSWORD=ваш_надежный_пароль
# Часовой пояс (формат IANA)
TZ=Europe/Moscow
# Проверка обновлений (для приватных репозиториев — отключите или предоставьте токен)
DISABLE_UPDATE_CHECK=false
GITHUB_TOKEN= # опционально, для проверки обновлений в приватном репозитории
```
### Уведомления
Настраиваются через веб-интерфейс — **Настройки → Уведомления**:
- **Email** — SMTP сервер, порт, логин, пароль (Yandex, Gmail, Mail.ru и другие)
- **Telegram** — токен бота и Chat ID через [@BotFather](https://t.me/BotFather)
---
## Стек технологий
| Слой | Технология |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy |
| Frontend | Jinja2 шаблоны, Vanilla JS, Bootstrap 5 |
| База данных | PostgreSQL 16 |
| Контейнеризация | Docker, Alpine Linux |
| API Proxmox | proxmoxer |
| VNC | noVNC |
| Графики | Chart.js |
| Терминал | xterm.js |
---
## Документация
| Документ | Описание |
|---|---|
| [WIKI.md](WIKI.md) | Полное руководство пользователя и администратора |
| [CHANGELOG.md](CHANGELOG.md) | История версий и примечания к выпускам |
---
## Участие в разработке
1. Сделайте форк репозитория
2. Создайте ветку функции: `git checkout -b feature/my-feature`
3. Зафиксируйте изменения: `git commit -m 'feat: добавьте мою функцию'`
4. Отправьте: `git push origin feature/my-feature`
5. Откройте Pull Request
---
## Лицензия
[MIT](LICENSE) — свободно использовать, изменять и распространять.
---
<div align="center">
Сделано с ❤️ для пользователей Proxmox
[Проблемы](https://git.tzim.uz/markmorado/pvemanager/issues) · [История изменений](CHANGELOG.md) · [Вики](WIKI.md)
</div>

View File

@@ -1,166 +0,0 @@
# Тестирование новых операций PVEManager
## Недавно добавленные операции
### 1. Переустановка ВМ (Reinstall)
**Endpoint**: `POST /api/{server_id}/vm/{vmid}/reinstall`
**Параметры**:
- `server_id` - ID сервера Proxmox
- `vmid` - ID виртуальной машины
- `node` (query) - имя ноды
**Описание**: Переустанавливает ВМ или LXC контейнер путём:
1. Остановки текущей инстанции
2. Удаления её (без архивирования снапшотов)
3. Клонирования заново из исходного шаблона
4. Переприменения конфигурации (CPU cores, память)
**Требования**:
- Пользователь должен иметь права `vms.delete`
- ВМ должна быть связана с шаблоном (храниться в `template_id`)
- Доступ к ВМ (владелец или администратор)
**Логирование**: `action='reinstall'` в audit log
---
### 2. Смена пароля на ВМ
**Endpoint**: `POST /api/{server_id}/vm/{vmid}/change-password`
**Параметры JSON**:
- `username` - имя пользователя в ВМ
- `password` - новый пароль (минимум 4 символа)
**Описание**: Изменяет пароль пользователя на ВМ через QEMU guest agent
**Требования**:
- Пользователь должен иметь права `vms.console`
- На ВМ должен быть установлен `qemu-guest-agent`
- ВМ должна быть запущена
**Логирование**: `action='change-password'`, `resource_type='vm'`
---
### 3. Смена пароля в LXC контейнере
**Endpoint**: `POST /api/{server_id}/container/{vmid}/change-password`
**Параметры JSON**:
- `username` - имя пользователя в контейнере
- `password` - новый пароль (минимум 4 символа)
**Описание**: Изменяет пароль пользователя в LXC контейнере через `chpasswd`
**Требования**:
- Пользователь должен иметь права `vms.console`
- Контейнер должен быть запущен
- В контейнере должна быть утилита `chpasswd`
**Логирование**: `action='change-password'`, `resource_type='lxc'`
---
## Исправления в v1.4.0+
### Логирование операций
Исправлена ошибка в `backend/app/logging_service.py` где отсутствовали соответствия для новых операций:
**До**:
```python
action_messages = {
"start": "started",
"stop": "stopped",
# ... другие операции ...
# 'reinstall' НЕ был здесь
# 'change-password' НЕ был здесь
}
```
**После**:
```python
action_messages = {
"start": "started",
"stop": "stopped",
# ... другие операции ...
"reinstall": "reinstalled", # ← добавлено
"change-password": "password changed" # ← добавлено
}
```
Это исправляет ошибку `HTTP 400: Invalid action` при выполнении операций.
---
## Способность к тестированию
Все операции:
1. ✅ Имеют правильные endpoint'ы
2. ✅ Имеют правильную авторизацию (PermissionChecker)
3. ✅ Имеют правильное логирование (LoggingService)
4. ✅ Имеют поддержку как для VM (qemu), так и для контейнеров (lxc)
5. ✅ Имеют обработку ошибок
6. ✅ Имеют проверку доступа пользователя (require_vm_access)
---
## Использование в клиентском коде
### Переустановка ВМ (JavaScript/TypeScript)
```javascript
const response = await fetch(`/proxmox/api/${serverId}/vm/${vmid}/reinstall?node=${node}`, {
method: 'POST',
});
const result = await response.json();
// result = { status: "success", vmid: 111, upid: "UPID:..." }
```
### Смена пароля ВМ
```javascript
const response = await fetch(`/proxmox/api/${serverId}/vm/${vmid}/change-password?node=${node}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'root',
password: 'newpassword123'
})
});
// result = { status: "success" }
```
### Смена пароля LXC
```javascript
const response = await fetch(`/proxmox/api/${serverId}/container/${vmid}/change-password?node=${node}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'root',
password: 'newpassword123'
})
});
// result = { status: "success" }
```
---
## Автоматическое тестирование
Все операции успешно логируются и могут быть проверены в таблице `audit_logs`:
```sql
SELECT action, resource_type, resource_id, success FROM audit_logs
WHERE action IN ('reinstall', 'change-password')
ORDER BY created_at DESC LIMIT 10;
```
---
## Проверка статуса операций
### Переустановка ВМ отслеживается через:
- `audit_logs` - логирование действия
- Proxmox API task (UPID) - отслеживание хода выполнения
- `vm_instances` - обновление конфигурации ВМ
### Смена пароля отслеживается через:
- `audit_logs` - логирование действия с указанием целевого пользователя
- Proxmox guest agent API - выполнение команды
Все операции полностью интегрированы и готовы к использованию.

View File

@@ -13,6 +13,7 @@ from ..db import get_db
from ..auth import get_current_user, PermissionChecker
from ..models import User, AuditLog
from ..logging_service import LoggingService
from ..i18n import I18nService
router = APIRouter()
@@ -178,15 +179,15 @@ async def cleanup_logs(
@router.get("/api/levels")
async def get_log_levels():
async def get_log_levels(lang: str = Query("ru")):
"""Get available log levels"""
return {
"levels": [
{"value": "debug", "label": "Debug", "color": "#6c757d"},
{"value": "info", "label": "Info", "color": "#0dcaf0"},
{"value": "warning", "label": "Warning", "color": "#ffc107"},
{"value": "error", "label": "Error", "color": "#dc3545"},
{"value": "critical", "label": "Critical", "color": "#dc3545"}
{"value": "debug", "label": I18nService.get("log_level_debug", lang), "color": "#6c757d"},
{"value": "info", "label": I18nService.get("log_level_info", lang), "color": "#0dcaf0"},
{"value": "warning", "label": I18nService.get("log_level_warning", lang), "color": "#ffc107"},
{"value": "error", "label": I18nService.get("log_level_error", lang), "color": "#dc3545"},
{"value": "critical", "label": I18nService.get("log_level_error", lang), "color": "#dc3545"}
]
}

View File

@@ -10,7 +10,7 @@ import httpx
import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask, DeployTask
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
@@ -211,17 +211,20 @@ def get_all_tasks(
"""
bulk_q = db.query(TaskQueue).filter(TaskQueue.user_id == current_user.id)
proxmox_q = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == current_user.id)
deploy_q = db.query(DeployTask).filter(DeployTask.user_id == current_user.id)
if status_filter:
# Normalise statuses: proxmox uses running/completed/failed; bulk uses the same + pending/cancelled
bulk_q = bulk_q.filter(TaskQueue.status == status_filter)
proxmox_q = proxmox_q.filter(ProxmoxTask.status == status_filter)
deploy_q = deploy_q.filter(DeployTask.status == status_filter)
bulk_tasks = bulk_q.order_by(TaskQueue.created_at.desc()).limit(limit).all()
proxmox_tasks = proxmox_q.order_by(ProxmoxTask.created_at.desc()).limit(limit).all()
deploy_tasks = deploy_q.order_by(DeployTask.created_at.desc()).limit(limit).all()
all_tasks = sorted(
[t.to_dict() for t in bulk_tasks] + [t.to_dict() for t in proxmox_tasks],
[t.to_dict() for t in bulk_tasks] + [t.to_dict() for t in proxmox_tasks] + [t.to_dict() for t in deploy_tasks],
key=lambda t: t.get("created_at") or "",
reverse=True,
)[:limit]
@@ -245,7 +248,12 @@ def get_active_count(
ProxmoxTask.status == "running",
).scalar() or 0
return {"count": bulk_count + proxmox_count}
deploy_count = db.query(func.count(DeployTask.id)).filter(
DeployTask.user_id == current_user.id,
DeployTask.status.in_(["pending", "running"]),
).scalar() or 0
return {"count": bulk_count + proxmox_count + deploy_count}
# ─── Proxmox UPID task log from DB ──────────────────────────────────────────
@@ -292,6 +300,10 @@ def clear_completed_tasks(
ProxmoxTask.user_id == current_user.id,
ProxmoxTask.status.in_(done_statuses),
).delete(synchronize_session=False)
deploy_deleted = db.query(DeployTask).filter(
DeployTask.user_id == current_user.id,
DeployTask.status.in_(done_statuses),
).delete(synchronize_session=False)
db.commit()
return {"deleted": bulk_deleted + proxmox_deleted}
return {"deleted": bulk_deleted + proxmox_deleted + deploy_deleted}

View File

@@ -425,14 +425,27 @@ def reinstall_vm_endpoint(
from ...models import OSTemplate
tpl = db.query(OSTemplate).filter(OSTemplate.id == cached.template_id).first()
if not tpl or not tpl.template_vmid:
if not tpl or not tpl.vmid:
raise HTTPException(status_code=400, detail="Template not found or invalid")
# Resolve template VMID and source node (with cross-node replication support)
local_template_vmid = tpl.get_vmid_for_node(node)
if local_template_vmid:
template_source_node = node
template_vmid = local_template_vmid
else:
template_source_node = tpl.get_source_node() or tpl.node or node
template_vmid = tpl.vmid
if not template_vmid:
raise HTTPException(status_code=400, detail="Template VMID is not available for this node")
name = cached.name
cores = cached.cores
memory = cached.memory
description = cached.description
is_lxc = (cached.vm_type == 'lxc')
# VMInstance.memory is stored in bytes; Proxmox expects MB
memory_mb = int(memory) // (1024 * 1024) if memory else None
# 1) stop
try:
@@ -470,13 +483,13 @@ def reinstall_vm_endpoint(
try:
if is_lxc:
upid = client.clone_container(
node=tpl.node or node, vmid=tpl.template_vmid, new_vmid=vmid,
node=template_source_node, vmid=template_vmid, new_vmid=vmid,
hostname=name, full=True, target_node=node,
description=description,
)
else:
upid = client.clone_vm(
node=tpl.node or node, vmid=tpl.template_vmid, new_vmid=vmid,
node=template_source_node, vmid=template_vmid, new_vmid=vmid,
name=name, full=True, target_node=node,
description=description,
)
@@ -495,13 +508,13 @@ def reinstall_vm_endpoint(
if is_lxc:
cfg = {}
if cores: cfg['cores'] = cores
if memory: cfg['memory'] = int(memory)
if memory_mb: cfg['memory'] = memory_mb
if cfg:
client.update_container_config(node, vmid, cfg)
else:
cfg = {}
if cores: cfg['cores'] = cores
if memory: cfg['memory'] = int(memory)
if memory_mb: cfg['memory'] = memory_mb
if cfg:
client.update_vm_config(node, vmid, cfg)
except Exception as _ce:
@@ -512,7 +525,7 @@ def reinstall_vm_endpoint(
resource_type='lxc' if is_lxc else 'vm', resource_id=vmid,
username=current_user.username, resource_name=name,
server_id=server_id, server_name=server.name, node_name=node,
details={'template_id': tpl.id, 'template_vmid': tpl.template_vmid},
details={'template_id': tpl.id, 'template_vmid': template_vmid},
success=True,
)
return {"status": "success", "vmid": vmid, "upid": upid}

View File

@@ -3,14 +3,19 @@ API endpoints for OS Templates management
Allows creating VM templates, groups, and deploying VMs from templates
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request, HTTPException, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from loguru import logger
from typing import List
from typing import List, Optional
from ..db import get_db, SessionLocal
from ..models import ProxmoxServer, OSTemplateGroup, OSTemplate, VMInstance, User
from ..models import ProxmoxServer, OSTemplateGroup, OSTemplate, VMInstance, User, DeployTask
from ..schemas import (
OSTemplateGroupCreate, OSTemplateGroupUpdate, OSTemplateGroupResponse,
OSTemplateCreate, OSTemplateUpdate, OSTemplateResponse, OSTemplateWithGroup,
@@ -21,9 +26,39 @@ from ..api.proxmox import get_next_vmid, save_vm_instance, get_vm_instance
from ..auth import get_current_user, PermissionChecker
from ..ipam_service import IPAMService
from ..logging_service import LoggingService
from ..websocket_manager import broadcast_task_update
router = APIRouter()
# Thread pool for blocking deploy operations
_deploy_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="deploy")
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ── Deploy Task response schema ──────────────────────────────────────────────
class DeployTaskStartResponse(BaseModel):
task_id: int
status: str
name: str
class DeployTaskStatusResponse(BaseModel):
id: int
status: str
step: Optional[str]
progress: int
name: str
vmid: Optional[int]
node: Optional[str]
error_message: Optional[str]
created_at: Optional[str]
started_at: Optional[str]
completed_at: Optional[str]
# ==================== Template Groups CRUD ====================
@@ -639,134 +674,135 @@ def auto_import_templates(
# ==================== VM Deployment ====================
@router.post("/api/deploy", response_model=VMDeployResponse)
async def deploy_vm_from_template(
deploy_data: VMDeployRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.create"))
):
"""
Развернуть новую VM из шаблона.
Клонирует шаблон и настраивает параметры VM.
Поддерживает развертывание на любую ноду кластера (cross-node deployment).
"""
# Get template
def _update_deploy_task(task_id: int, status: str, step: str, progress: int,
vmid: int = None, node: str = None, error: str = None):
"""Update deploy task state in DB and broadcast via WebSocket."""
db = SessionLocal()
try:
task = db.query(DeployTask).filter(DeployTask.id == task_id).first()
if not task:
return
task.status = status
task.step = step
task.progress = progress
if vmid is not None:
task.vmid = vmid
if node is not None:
task.node = node
if error is not None:
task.error_message = error
if status == 'running' and not task.started_at:
task.started_at = _utcnow()
if status in ('completed', 'failed'):
task.completed_at = _utcnow()
db.commit()
try:
broadcast_task_update(task.user_id, "deploy_task_update", task.to_dict())
except Exception:
pass
finally:
db.close()
def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
user_id: int, username: str, user_ssh_key: str):
"""Blocking deploy logic — runs in a thread pool."""
db = SessionLocal()
server = None
template = None
deploy_node = None
new_vmid = None
try:
_update_deploy_task(task_id, 'running', 'Подготовка...', 5)
template = db.query(OSTemplate).filter(OSTemplate.id == deploy_data.template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
_update_deploy_task(task_id, 'failed', 'Шаблон не найден', 0, error='Шаблон не найден')
return
if not template.is_active:
raise HTTPException(status_code=400, detail="Шаблон неактивен")
# Get server
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
_update_deploy_task(task_id, 'failed', 'Сервер не найден', 0, error='Сервер Proxmox не найден')
return
# Validate resources
cores = deploy_data.cores or template.default_cores
memory = deploy_data.memory or template.default_memory
disk = deploy_data.disk or template.default_disk
if cores < template.min_cores:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_cores} ядер CPU")
if memory < template.min_memory:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_memory} MB памяти")
if disk < template.min_disk:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_disk} GB диска")
try:
# Connect to Proxmox
# Connect
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10)
if server.use_password:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
host=server.ip_address, user=server.api_user,
password=server.password, verify_ssl=server.verify_ssl
)
else:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
host=server.ip_address, user=server.api_user,
token_name=server.api_token_name, token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
_update_deploy_task(task_id, 'failed', 'Ошибка подключения к Proxmox', 10,
error='Не удалось подключиться к Proxmox серверу')
return
# Determine source node (where template exists) and target node (where to deploy)
# Locate template
_update_deploy_task(task_id, 'running', 'Поиск шаблона...', 20)
source_node = template.get_source_node()
target_node = deploy_data.target_node or source_node
# Check if template exists on source node
template_vmid = template.vmid
template_status = client.get_vm_status(source_node, template_vmid)
template_status = client.get_vm_status(source_node, template_vmid)
if not template_status:
# Try to find the template on other nodes
found = client.find_template_on_nodes(template_vmid)
if found:
source_node = found['node']
logger.info(f"Template {template_vmid} found on alternative node: {source_node}")
else:
raise HTTPException(
status_code=404,
detail=f"Шаблон VMID {template_vmid} не найден. Проверьте настройки шаблона."
)
_update_deploy_task(task_id, 'failed', 'Шаблон не найден на Proxmox', 20,
error=f'VMID {template_vmid} не найден на сервере')
return
# For shared storage: template is accessible from all nodes
# For local storage: template needs to be on the target node or replicated
#
# In a cluster with shared storage, we can clone from source_node
# but specify target_node for the new VM
clone_from_node = source_node
clone_template_vmid = template_vmid
# Log cross-node deployment
if target_node and target_node != source_node:
logger.info(f"Cross-node deployment: template on {source_node}, target node {target_node}")
# Get next VMID from database (range 10000-19999) or use provided VMID
# Allocate VMID
_update_deploy_task(task_id, 'running', 'Генерация VMID...', 25)
if deploy_data.vmid:
# Используем указанный VMID (для переустановки)
new_vmid = deploy_data.vmid
logger.info(f"Using provided VMID: {new_vmid} for reinstall")
else:
# Генерируем новый случайный VMID для новой VM
new_vmid = get_next_vmid(db, server.id)
logger.info(f"Generated new VMID: {new_vmid} for server {server.id}")
# Clone VM from template
# For shared storage: clone from source node, target specifies where VM will run
# Clone
_update_deploy_task(task_id, 'running', f'Клонирование шаблона → VMID {new_vmid}...', 30)
clone_upid = client.clone_vm_from_template(
node=clone_from_node,
template_vmid=clone_template_vmid,
node=source_node,
template_vmid=template_vmid,
new_vmid=new_vmid,
name=deploy_data.name,
full_clone=True,
description=f"Deployed from template {template.name} by {current_user.username}",
description=f"Deployed from template {template.name} by {username}",
target_storage=deploy_data.target_storage,
target_node=target_node if target_node != clone_from_node else None
target_node=target_node if target_node != source_node else None
)
if not clone_upid:
raise HTTPException(status_code=500, detail="Ошибка клонирования VM")
_update_deploy_task(task_id, 'failed', 'Ошибка клонирования', 30,
error='Proxmox не вернул UPID задачи клонирования')
return
# Wait for clone to complete
clone_success = client.wait_for_task(clone_from_node, clone_upid, timeout=300)
# Wait for clone — progress from 30 to 65
_update_deploy_task(task_id, 'running', 'Клонирование диска...', 45, vmid=new_vmid)
clone_success = client.wait_for_task(source_node, clone_upid, timeout=300)
if not clone_success:
raise HTTPException(status_code=500, detail="Таймаут клонирования VM")
_update_deploy_task(task_id, 'failed', 'Таймаут клонирования', 45,
error='Клонирование заняло слишком долго (>5 мин)')
return
# Use target_node for all subsequent operations
deploy_node = target_node
_update_deploy_task(task_id, 'running', 'Клонирование завершено', 65, vmid=new_vmid, node=deploy_node)
# IPAM Integration - Auto-allocate IP if network specified but no IP provided
# IPAM
allocated_ip = deploy_data.ip_address
ipam_allocation = None
if deploy_data.ipam_network_id and not deploy_data.ip_address:
_update_deploy_task(task_id, 'running', 'Выделение IP-адреса (IPAM)...', 68, vmid=new_vmid, node=deploy_node)
ipam = IPAMService(db)
ipam_allocation, error = ipam.auto_allocate_ip(
network_id=deploy_data.ipam_network_id,
@@ -776,25 +812,19 @@ async def deploy_vm_from_template(
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=deploy_node,
allocated_by=current_user.username,
allocated_by=username,
notes=f"Auto-allocated for VM deployment from template {template.name}"
)
if error:
logger.error(f"IPAM auto-allocation failed: {error}")
# Continue without IPAM - let user know
else:
if not error:
allocated_ip = ipam_allocation.ip_address
logger.info(f"IPAM allocated IP {allocated_ip} for VM {deploy_data.name}")
# Get gateway from network if not provided
if not deploy_data.gateway:
from ..models import IPAMNetwork
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == deploy_data.ipam_network_id).first()
if network and network.gateway:
deploy_data.gateway = network.gateway
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == deploy_data.ipam_network_id).first()
if net and net.gateway:
deploy_data.gateway = net.gateway
elif deploy_data.ip_address and deploy_data.ipam_network_id:
# Register manually specified IP in IPAM
ipam = IPAMService(db)
ipam_allocation, error = ipam.allocate_ip(
ipam_allocation, _ = ipam.allocate_ip(
ip_address=deploy_data.ip_address,
network_id=deploy_data.ipam_network_id,
pool_id=deploy_data.ipam_pool_id,
@@ -803,64 +833,47 @@ async def deploy_vm_from_template(
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=deploy_node,
allocated_by=current_user.username,
allocated_by=username,
notes=f"Manually assigned for VM deployment from template {template.name}"
)
if error:
logger.warning(f"IPAM registration failed for manually specified IP: {error}")
# Get network mask (CIDR) and gateway from IPAM network if IP is specified
network_mask = "24" # Default mask
# Determine network mask & gateway
network_mask = "24"
gateway = deploy_data.gateway
# Try to get proper network mask and gateway from IPAM
if allocated_ip and (deploy_data.ipam_network_id or ipam_allocation):
try:
from ..models import IPAMNetwork
network_id = deploy_data.ipam_network_id
if ipam_allocation and hasattr(ipam_allocation, 'network_id'):
network_id = ipam_allocation.network_id
if network_id:
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if network:
# Extract mask from CIDR (e.g., "10.10.10.0/24" -> "24")
if network.network and '/' in network.network:
network_mask = network.network.split('/')[1]
logger.info(f"Using network mask /{network_mask} from IPAM network {network.name}")
# Get gateway if not already specified
if not gateway and network.gateway:
gateway = network.gateway
logger.info(f"Using gateway {gateway} from IPAM network {network.name}")
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if net:
if net.network and '/' in net.network:
network_mask = net.network.split('/')[1]
if not gateway and net.gateway:
gateway = net.gateway
except Exception as e:
logger.warning(f"Could not get network info from IPAM: {e}")
# Configure VM
ip_config = None
if allocated_ip:
logger.info(f"Configuring VM {new_vmid} with IP {allocated_ip}/{network_mask}, gateway: {gateway}")
if gateway:
ip_config = f"ip={allocated_ip}/{network_mask},gw={gateway}"
else:
ip_config = f"ip={allocated_ip}/{network_mask}"
ip_config = f"ip={allocated_ip}/{network_mask},gw={gateway}" if gateway else f"ip={allocated_ip}/{network_mask}"
else:
ip_config = "ip=dhcp"
logger.info(f"Configuring VM {new_vmid} with DHCP")
# Combine user's SSH key with deploy request SSH keys
# Configure VM
_update_deploy_task(task_id, 'running', 'Настройка VM (CPU, RAM, Cloud-Init)...', 72, vmid=new_vmid, node=deploy_node)
ssh_keys = deploy_data.ssh_keys or ""
if current_user.ssh_public_key:
if ssh_keys:
ssh_keys = f"{ssh_keys}\n{current_user.ssh_public_key}"
else:
ssh_keys = current_user.ssh_public_key
if user_ssh_key:
ssh_keys = f"{ssh_keys}\n{user_ssh_key}".strip()
config_success = client.configure_vm(
node=deploy_node,
vmid=new_vmid,
cores=cores,
memory=memory,
disk_size=disk, # configure_vm will compare with current disk size
disk_size=disk,
network_bridge=deploy_data.network_bridge,
cloud_init_user=deploy_data.cloud_init_user,
cloud_init_password=deploy_data.cloud_init_password,
@@ -868,31 +881,25 @@ async def deploy_vm_from_template(
ip_config=ip_config,
onboot=deploy_data.onboot
)
if not config_success:
logger.warning(f"VM {new_vmid} created but configuration may be incomplete")
# Start VM if requested
# Start
if deploy_data.start_after_create:
_update_deploy_task(task_id, 'running', 'Запуск VM...', 88, vmid=new_vmid, node=deploy_node)
client.start_vm(deploy_node, new_vmid)
# Enable High Availability if requested (cluster only)
ha_enabled = False
# HA
if deploy_data.enable_ha:
try:
if client.is_cluster():
ha_result = client.add_to_ha(vmid=new_vmid, vm_type='vm', max_restart=3, max_relocate=3)
if ha_result.get('success'):
ha_enabled = True
logger.info(f"HA enabled for VM {new_vmid}")
else:
logger.warning(f"Failed to enable HA for VM {new_vmid}: {ha_result.get('error')}")
else:
logger.warning(f"HA requested but server {server.name} is not in a cluster")
client.add_to_ha(vmid=new_vmid, vm_type='vm', max_restart=3, max_relocate=3)
except Exception as ha_error:
logger.error(f"Error enabling HA for VM {new_vmid}: {ha_error}")
logger.error(f"HA error for VM {new_vmid}: {ha_error}")
# Save VM configuration to database with owner
# Save to DB
_update_deploy_task(task_id, 'running', 'Сохранение в базе данных...', 95, vmid=new_vmid, node=deploy_node)
user = db.query(User).filter(User.id == user_id).first()
save_vm_instance(
db=db,
server_id=server.id,
@@ -904,76 +911,137 @@ async def deploy_vm_from_template(
memory=memory,
disk_size=disk,
ip_address=allocated_ip,
ip_prefix=24,
gateway=deploy_data.gateway,
ip_prefix=int(network_mask),
gateway=gateway,
cloud_init_user=deploy_data.cloud_init_user,
cloud_init_password=deploy_data.cloud_init_password,
ssh_keys=deploy_data.ssh_keys,
template_id=template.id,
template_name=template.name,
description=f"Deployed from template {template.name}",
owner_id=current_user.id # VPS-style user isolation
owner_id=user_id
)
logger.info(f"User {current_user.username} deployed VM {new_vmid} ({deploy_data.name}) from template {template.name}")
# Log VM deployment to audit
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=new_vmid,
username=current_user.username,
username=username,
resource_name=deploy_data.name,
server_id=server.id,
server_name=server.name,
node_name=deploy_node,
details={
"template_id": template.id,
"template_name": template.name,
"source_node": source_node if source_node != deploy_node else None,
"cores": cores,
"memory": memory,
"disk": disk,
"ip_address": allocated_ip,
"ha_enabled": ha_enabled
},
details={"template_id": template.id, "template_name": template.name, "cores": cores,
"memory": memory, "disk": disk, "ip_address": allocated_ip},
success=True
)
return VMDeployResponse(
success=True,
vmid=new_vmid,
name=deploy_data.name,
node=deploy_node,
server_id=server.id,
task_upid=clone_upid,
message=f"VM {deploy_data.name} успешно создана (VMID: {new_vmid})"
)
_update_deploy_task(task_id, 'completed', 'VM успешно создана!', 100, vmid=new_vmid, node=deploy_node)
logger.info(f"[DEPLOY] Task #{task_id}: VM {deploy_data.name} (VMID {new_vmid}) deployed by {username}")
except HTTPException:
raise
except Exception as e:
# Log failed deployment
err = str(e)
logger.error(f"[DEPLOY] Task #{task_id} failed: {err}")
# Log failed
try:
if server and template:
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=deploy_data.vmid,
username=current_user.username,
username=username,
resource_name=deploy_data.name,
server_id=server.id if server else None,
server_name=server.name if server else None,
node_name=deploy_node if 'deploy_node' in locals() else None,
details={
"template_id": template.id if template else None,
"template_name": template.name if template else None
},
node_name=deploy_node,
details={"template_id": template.id if template else None},
success=False,
error_message=str(e)
error_message=err
)
logger.error(f"Error deploying VM from template {template.id}: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка развертывания VM: {str(e)}")
except Exception:
pass
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
finally:
db.close()
@router.post("/api/deploy", response_model=DeployTaskStartResponse, status_code=202)
async def deploy_vm_from_template(
deploy_data: VMDeployRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.create"))
):
"""
Асинхронный деплой VM из шаблона.
Немедленно возвращает task_id, выполнение идёт в фоне.
Статус можно отслеживать через GET /api/deploy/{task_id}.
"""
# Fast validation before queuing
template = db.query(OSTemplate).filter(OSTemplate.id == deploy_data.template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if not template.is_active:
raise HTTPException(status_code=400, detail="Шаблон неактивен")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
cores = deploy_data.cores or template.default_cores
memory = deploy_data.memory or template.default_memory
disk = deploy_data.disk or template.default_disk
if cores < template.min_cores:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_cores} ядер CPU")
if memory < template.min_memory:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_memory} MB памяти")
if disk < template.min_disk:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_disk} GB диска")
# Create deploy task record
task = DeployTask(
status='pending',
step='В очереди...',
progress=0,
name=deploy_data.name,
template_id=template.id,
server_id=server.id,
user_id=current_user.id,
)
db.add(task)
db.commit()
db.refresh(task)
task_id = task.id
# Schedule blocking deploy in thread pool
loop = asyncio.get_event_loop()
loop.run_in_executor(
_deploy_executor,
_do_deploy_sync,
task_id,
deploy_data,
current_user.id,
current_user.username,
current_user.ssh_public_key or ''
)
logger.info(f"[DEPLOY] Task #{task_id} queued: {deploy_data.name} by {current_user.username}")
return DeployTaskStartResponse(task_id=task_id, status='pending', name=deploy_data.name)
@router.get("/api/deploy/{task_id}", response_model=DeployTaskStatusResponse)
async def get_deploy_task_status(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Получить статус задачи деплоя."""
task = db.query(DeployTask).filter(DeployTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Задача не найдена")
if task.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Нет доступа")
return DeployTaskStatusResponse(**task.to_dict())
# ==================== Quick Deploy (for UI) ====================

View File

@@ -193,7 +193,7 @@
"console": "Console",
"reinstall": "Reinstall",
"fill_all_fields": "Fill all required fields",
"select_node": "— Select node —",
"select_node": "Select Node",
"no_nodes": "No nodes available",
"login": "Login",
"username": "Username",
@@ -1190,7 +1190,6 @@
"nav_networks": "Networks",
"networks_management_desc": "Manage SDN zones, VNets, subnets and node network interfaces",
"node_networks": "Node Interfaces",
"network_interfaces": "Network Interfaces",
"iface_type": "Type",
"iface_bridge": "Bridge",
"iface_bond": "Bond",
@@ -1202,7 +1201,6 @@
"bond_slaves_hint": "Space-separated list of slave interfaces, e.g. ens3 ens4",
"bond_mode": "Bond Mode",
"autostart": "Autostart",
"active": "Active",
"apply_network_config": "Apply Network Config",
"apply_network_config_confirm": "Apply pending network changes to this node? This will activate the new configuration.",
"revert_network_config": "Revert Network Config",
@@ -1222,7 +1220,6 @@
"ipam_network_will_not_be_deleted": "The linked IPAM network (if any) will not be deleted.",
"synced_with_ipam": "IPAM",
"vlan_aware": "VLAN Aware",
"vlan_id": "VLAN ID",
"vlan_raw_device": "Raw Device",
"vxlan_peer": "Peer Addresses",
"vxlan_peer_hint": "Space-separated list of remote peer IP addresses",
@@ -1230,8 +1227,37 @@
"alias": "Alias",
"subnet": "Subnet",
"netmask": "Netmask",
"select_node": "Select Node",
"iface_bridge_name_hint": "e.g. vmbr0, vmbr1",
"iface_bond_name_hint": "e.g. bond0, bond1",
"iface_vlan_name_hint": "e.g. ens3.100"
"iface_vlan_name_hint": "e.g. ens3.100",
"vmid": "VMID",
"boot_order": "Boot Order",
"qemu_agent": "QEMU Agent",
"primary_ip": "Primary IP",
"os_type": "OS Type",
"vcpu": "vCPU",
"instance_info": "Instance Info",
"interfaces": "Interfaces",
"dns": "DNS",
"my_proxmox_server": "My Proxmox Server",
"qemu_virtual_machine": "QEMU Virtual Machine",
"root": "root",
"ubuntu": "ubuntu",
"my_vm": "my-vm-01",
"notes_saved": "Notes saved",
"before_update": "before-update",
"placeholder_root": "root",
"placeholder_ubuntu": "ubuntu",
"placeholder_vm_name": "my-vm-01",
"placeholder_ssh_key": "ssh-rsa AAAA...",
"placeholder_command": "ls -la /tmp",
"placeholder_disk_size": "e.g. 100",
"placeholder_snapshot_name": "e.g. before-update",
"placeholder_root_pam": "root@pam",
"placeholder_disk": "scsi0",
"placeholder_notes": "Описание / заметки об инстансе...",
"log_level_debug": "Debug",
"log_level_info": "Info",
"log_level_warning": "Warning",
"log_level_error": "Error"
}

View File

@@ -193,7 +193,7 @@
"console": "Консоль",
"reinstall": "Переустановка",
"fill_all_fields": "Заполните все обязательные поля",
"select_node": "— Выберите ноду —",
"select_node": "Выберите узел",
"no_nodes": "Нет доступных нод",
"login": "Войти",
"username": "Пользователь",
@@ -1190,7 +1190,6 @@
"nav_networks": "Сети",
"networks_management_desc": "Управление SDN-зонами, VNet, подсетями и сетевыми интерфейсами узлов",
"node_networks": "Интерфейсы узлов",
"network_interfaces": "Сетевые интерфейсы",
"iface_type": "Тип",
"iface_bridge": "Бридж",
"iface_bond": "Бонд",
@@ -1202,7 +1201,6 @@
"bond_slaves_hint": "Список slave-интерфейсов через пробел, например ens3 ens4",
"bond_mode": "Режим бонда",
"autostart": "Автозапуск",
"active": "Активен",
"apply_network_config": "Применить конфигурацию",
"apply_network_config_confirm": "Применить ожидающие изменения сети на этом узле? Новая конфигурация будет активирована.",
"revert_network_config": "Откатить конфигурацию",
@@ -1222,7 +1220,6 @@
"ipam_network_will_not_be_deleted": "Связанная сеть IPAM (при наличии) удалена не будет.",
"synced_with_ipam": "IPAM",
"vlan_aware": "VLAN Aware",
"vlan_id": "VLAN ID",
"vlan_raw_device": "Raw-устройство",
"vxlan_peer": "Адреса пиров",
"vxlan_peer_hint": "Список IP-адресов удалённых пиров через пробел",
@@ -1230,8 +1227,37 @@
"alias": "Псевдоним",
"subnet": "Подсеть",
"netmask": "Маска",
"select_node": "Выберите узел",
"iface_bridge_name_hint": "например vmbr0, vmbr1",
"iface_bond_name_hint": "например bond0, bond1",
"iface_vlan_name_hint": "например ens3.100"
"iface_vlan_name_hint": "например ens3.100",
"vmid": "VMID",
"boot_order": "Порядок загрузки",
"qemu_agent": "QEMU агент",
"primary_ip": "Основной IP",
"os_type": "Тип ОС",
"vcpu": "vCPU",
"instance_info": "Информация об инстансе",
"interfaces": "Интерфейсы",
"dns": "DNS",
"my_proxmox_server": "Мой Proxmox сервер",
"qemu_virtual_machine": "Виртуальная машина QEMU",
"root": "root",
"ubuntu": "ubuntu",
"my_vm": "my-vm-01",
"notes_saved": "Заметки сохранены",
"before_update": "перед-обновлением",
"placeholder_root": "root",
"placeholder_ubuntu": "ubuntu",
"placeholder_vm_name": "my-vm-01",
"placeholder_ssh_key": "ssh-rsa AAAA...",
"placeholder_command": "ls -la /tmp",
"placeholder_disk_size": "например 100",
"placeholder_snapshot_name": "например перед-обновлением",
"placeholder_root_pam": "root@pam",
"placeholder_disk": "scsi0",
"placeholder_notes": "Описание / заметки об инстансе...",
"log_level_debug": "Отладка",
"log_level_info": "Информация",
"log_level_warning": "Предупреждение",
"log_level_error": "Ошибка"
}

View File

@@ -148,6 +148,14 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Error running admin initialization: {e}")
# Register the main event loop for cross-thread WebSocket broadcasts
try:
import asyncio
from .websocket_manager import set_main_loop
set_main_loop(asyncio.get_event_loop())
except Exception as e:
logger.warning(f"Failed to register main event loop: {e}")
# Start background monitoring worker
try:
from .workers.monitoring_worker import start_monitoring_worker

View File

@@ -1057,3 +1057,63 @@ class WorkspaceUser(Base):
Index('idx_ws_users_user', 'user_id'),
Index('idx_ws_users_unique', 'workspace_id', 'user_id', unique=True),
)
# ==================== Deploy Task ====================
class DeployTask(Base):
"""Tracks async VM deployment tasks from templates"""
__tablename__ = "deploy_tasks"
id = Column(Integer, primary_key=True, index=True)
status = Column(String(20), nullable=False, default='pending', index=True)
# pending / running / completed / failed
# Human-readable current step
step = Column(String(200), nullable=True)
# Progress 0–100
progress = Column(Integer, nullable=False, default=0)
# Input info
name = Column(String(100), nullable=False)
template_id = Column(Integer, nullable=False)
server_id = Column(Integer, nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
# Output info (filled on completion)
vmid = Column(Integer, nullable=True)
node = Column(String(100), nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_deploy_task_user_status', 'user_id', 'status'),
)
def to_dict(self) -> dict:
return {
'id': self.id,
'kind': 'deploy',
'status': self.status,
'step': self.step,
'current_step': self.step,
'progress': self.progress,
'progress_percent': self.progress,
'description': f"Создание {self.name}",
'name': self.name,
'template_id': self.template_id,
'server_id': self.server_id,
'vmid': self.vmid,
'node': self.node,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
def __repr__(self):
return f"<DeployTask(id={self.id}, name='{self.name}', status='{self.status}')>"

View File

@@ -343,6 +343,184 @@ class ProxmoxClient:
logger.error(f"Ошибка перезапуска LXC {vmid} на {node}: {e}")
return None
def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
"""Корректно выключить VM (ACPI). Возвращает UPID задачи."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.shutdown.post(timeout=timeout)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка корректного выключения VM {vmid} на {node}: {e}")
return None
def shutdown_container(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
"""Корректно выключить LXC. Возвращает UPID задачи."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).lxc(vmid).status.shutdown.post(timeout=timeout)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка корректного выключения LXC {vmid} на {node}: {e}")
return None
def clone_vm(self, node: str, vmid: int, new_vmid: int, name: str,
full: bool = True, target_node: Optional[str] = None,
target_storage: Optional[str] = None,
description: Optional[str] = None) -> Optional[str]:
"""Клонировать VM (qemu). Работает и для шаблонов, и для обычных VM."""
if not self.proxmox:
return None
try:
params = {'newid': new_vmid, 'name': name, 'full': 1 if full else 0}
if target_storage:
params['storage'] = target_storage
if target_node and target_node != node:
params['target'] = target_node
if description:
params['description'] = description
upid = self.proxmox.nodes(node).qemu(vmid).clone.post(**params)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка клонирования VM {vmid} -> {new_vmid}: {e}")
return None
def clone_container(self, node: str, vmid: int, new_vmid: int, hostname: str,
full: bool = True, target_node: Optional[str] = None,
target_storage: Optional[str] = None,
description: Optional[str] = None) -> Optional[str]:
"""Клонировать LXC контейнер."""
if not self.proxmox:
return None
try:
params = {'newid': new_vmid, 'hostname': hostname, 'full': 1 if full else 0}
if target_storage:
params['storage'] = target_storage
if target_node and target_node != node:
params['target'] = target_node
if description:
params['description'] = description
upid = self.proxmox.nodes(node).lxc(vmid).clone.post(**params)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка клонирования LXC {vmid} -> {new_vmid}: {e}")
return None
def change_vm_password(self, node: str, vmid: int, username: str, password: str) -> Dict:
"""
Сменить пароль пользователя на VM через QEMU guest agent.
Требует установленный qemu-guest-agent в VM.
"""
if not self.proxmox:
return {'success': False, 'error': 'Proxmox connection not initialized'}
try:
# Proxmox API: POST /nodes/{node}/qemu/{vmid}/agent/set-user-password
self.proxmox.nodes(node).qemu(vmid).agent('set-user-password').post(
username=username,
password=password,
)
return {'success': True}
except Exception as e:
logger.error(f"Не удалось сменить пароль на VM {vmid}: {e}")
return {'success': False, 'error': str(e)}
def change_container_password(self, node: str, vmid: int, username: str, password: str) -> Dict:
"""
Сменить пароль пользователя в LXC через `pct exec` (chpasswd).
Контейнер должен быть запущен.
"""
if not self.proxmox:
return {'success': False, 'error': 'Proxmox connection not initialized'}
try:
# Используем `chpasswd` в формате "user:password"
# Запускаем bash -c "echo 'user:pass' | chpasswd"
cmd_str = f"echo '{username}:{password}' | chpasswd"
result = self.proxmox.nodes(node).lxc(vmid).status.current.get()
if result.get('status') != 'running':
return {'success': False, 'error': 'Container is not running'}
res = self.proxmox.nodes(node).lxc(vmid).exec.post(
command=['bash', '-c', cmd_str]
)
return {'success': True, 'result': res}
except Exception as e:
logger.error(f"Не удалось сменить пароль в LXC {vmid}: {e}")
return {'success': False, 'error': str(e)}
def set_vm_notes(self, node: str, vmid: int, description: str) -> bool:
"""Установить description (заметки) у VM."""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).config.put(description=description or '')
return True
except Exception as e:
logger.error(f"Ошибка обновления заметок VM {vmid}: {e}")
return False
def set_container_notes(self, node: str, vmid: int, description: str) -> bool:
"""Установить description (заметки) у LXC."""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).lxc(vmid).config.put(description=description or '')
return True
except Exception as e:
logger.error(f"Ошибка обновления заметок LXC {vmid}: {e}")
return False
def attach_iso(self, node: str, vmid: int, iso_volid: str, device: str = 'ide2') -> bool:
"""Подключить ISO образ к VM (только KVM)."""
if not self.proxmox:
return False
try:
value = f"{iso_volid},media=cdrom"
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: value})
return True
except Exception as e:
logger.error(f"Ошибка подключения ISO {iso_volid} к VM {vmid}: {e}")
return False
def detach_iso(self, node: str, vmid: int, device: str = 'ide2') -> bool:
"""Отключить ISO образ (CD-ROM) у VM."""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: 'none,media=cdrom'})
return True
except Exception as e:
logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}")
return False
def get_node_isos(self, node: str) -> List[Dict]:
"""Получить список ISO-образов со всех ISO-хранилищ ноды."""
if not self.proxmox:
return []
result: List[Dict] = []
try:
storages = self.proxmox.nodes(node).storage.get()
for s in storages:
content = s.get('content', '')
if 'iso' not in content:
continue
storage_id = s.get('storage')
try:
items = self.proxmox.nodes(node).storage(storage_id).content.get(content='iso')
for it in items:
result.append({
'volid': it.get('volid'),
'storage': storage_id,
'size': it.get('size'),
'format': it.get('format'),
'name': (it.get('volid') or '').split('/')[-1],
})
except Exception as e:
logger.debug(f"Не удалось получить ISO из {storage_id}: {e}")
except Exception as e:
logger.error(f"Ошибка получения списка ISO для ноды {node}: {e}")
return result
def force_stop_vm(self, node: str, vmid: int) -> bool:
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
if not self.proxmox or not self.host:

View File

@@ -126,12 +126,29 @@ class ConnectionManager:
ws_manager = ConnectionManager()
# Stores the main asyncio event loop set at application startup.
# Background threads (APScheduler) use this to schedule coroutines safely.
_main_loop: asyncio.AbstractEventLoop | None = None
def set_main_loop(loop: asyncio.AbstractEventLoop) -> None:
"""Call this once at application startup to register the running event loop."""
global _main_loop
_main_loop = loop
def run_async_safe(coro) -> None:
"""
Schedule an async coroutine from a synchronous background thread.
Used by APScheduler jobs to push WS updates without blocking.
"""
global _main_loop
try:
# Prefer the stored main loop (set at startup) for cross-thread scheduling
if _main_loop is not None and _main_loop.is_running():
asyncio.run_coroutine_threadsafe(coro, _main_loop)
return
# Fallback: try to get/run the current thread's loop
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.run_coroutine_threadsafe(coro, loop)

View File

@@ -688,6 +688,8 @@ class MonitoringWorker:
# Track all seen (server_id, vmid) pairs for cleanup
all_seen = set()
# Track status changes for WebSocket broadcast
status_changes = [] # list of (server_id, vmid, node, old_status, new_status)
# Upsert all VMs to database
for server_id, vm_data, vm_type in all_vms_data:
@@ -713,6 +715,8 @@ class MonitoringWorker:
if os_type:
os_type = os_type.capitalize()
new_status = vm_data.get('status', 'unknown')
# Find existing by (server_id, vmid)
existing = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
@@ -720,10 +724,13 @@ class MonitoringWorker:
).first()
if existing:
# Detect status change for WS broadcast
if existing.status != new_status:
status_changes.append((server_id, vmid, node_name, existing.status, new_status))
# Update existing entry
existing.name = vm_name
existing.node = node_name
existing.status = vm_data.get('status', 'unknown')
existing.status = new_status
existing.cores = vm_data.get('cpus', vm_data.get('maxcpu'))
existing.memory = vm_data.get('maxmem')
existing.disk_size = vm_data.get('maxdisk')
@@ -741,7 +748,7 @@ class MonitoringWorker:
node=node_name,
vm_type=vm_type,
name=vm_name,
status=vm_data.get('status', 'unknown'),
status=new_status,
cores=vm_data.get('cpus', vm_data.get('maxcpu')),
memory=vm_data.get('maxmem'),
disk_size=vm_data.get('maxdisk'),
@@ -803,6 +810,22 @@ class MonitoringWorker:
db.commit()
logger.debug(f"[VM SYNC] Cache sync completed. Processed {len(all_vms_data)} VMs/containers, marked {deleted_count} as deleted, released {released_ips} IPs")
# Broadcast status changes to all connected WebSocket clients
if status_changes:
try:
from app.websocket_manager import ws_manager, run_async_safe
for srv_id, vmid, node_name, old_status, new_status in status_changes:
logger.info(f"[VM SYNC] Status change: server={srv_id} vmid={vmid} {old_status} -> {new_status}")
run_async_safe(ws_manager.broadcast({
"type": "vm_status_update",
"server_id": srv_id,
"vmid": vmid,
"node": node_name,
"status": new_status,
}))
except Exception as _we:
logger.warning(f"[VM SYNC] Failed to broadcast status changes: {_we}")
except Exception as e:
logger.error(f"[VM SYNC] Critical error: {e}", exc_info=True)
db.rollback()
@@ -1034,10 +1057,10 @@ def start_monitoring_worker():
replace_existing=True
)
# VM cache sync - every 30 seconds
# VM cache sync - every 10 seconds
scheduler.add_job(
monitoring_worker.sync_vm_cache,
trigger=IntervalTrigger(seconds=30),
trigger=IntervalTrigger(seconds=10),
id='vm_cache_sync',
name='Sync VM cache from Proxmox',
replace_existing=True

View File

@@ -1256,6 +1256,37 @@ def migrate_user_role_remove_dashboard(conn):
logger.info("✓ user role: dashboard:view already False, skipping")
def migrate_deploy_tasks(conn):
"""Migration 22: Create deploy_tasks table for async VM deployment tracking"""
conn.execute(text("""
CREATE TABLE IF NOT EXISTS deploy_tasks (
id SERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
step VARCHAR(200),
progress INTEGER NOT NULL DEFAULT 0,
name VARCHAR(100) NOT NULL,
template_id INTEGER NOT NULL,
server_id INTEGER,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vmid INTEGER,
node VARCHAR(100),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_deploy_task_user_status
ON deploy_tasks (user_id, status)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_deploy_tasks_created
ON deploy_tasks (created_at)
"""))
logger.info("✓ deploy_tasks table created")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1438,6 +1469,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"User role dashboard migration: {e}")
conn.rollback()
# Migration 22: Deploy Tasks table
try:
migrate_deploy_tasks(conn)
conn.commit()
except Exception as e:
logger.warning(f"Deploy tasks migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)

17
backend/pytest.ini Normal file
View File

@@ -0,0 +1,17 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-ra
--strict-markers
--tb=short
--color=yes
filterwarnings =
ignore::DeprecationWarning:passlib.*
ignore::DeprecationWarning:pkg_resources.*
markers =
smoke: quick smoke tests
unit: pure unit tests
integration: integration tests touching multiple modules

View File

@@ -0,0 +1,5 @@
-r requirements.txt
pytest==8.3.3
pytest-cov==5.0.0
pytest-asyncio==0.24.0
freezegun==1.5.1

87
backend/tests/README.md Normal file
View File

@@ -0,0 +1,87 @@
# Backend Tests
Smoke / unit tests for the FastAPI backend, focused on the security-critical
modules: `app.crypto`, `app.auth`, `app.rbac`.
## Layout
```
backend/
├── pytest.ini # pytest config (testpaths=tests, markers, addopts)
├── requirements-dev.txt # test deps (-r requirements.txt + pytest …)
└── tests/
├── conftest.py # env setup + reusable fixtures
├── test_crypto.py # Fernet field-encryption helpers
├── test_auth.py # password hashing, JWT, IP parsing, perm helpers
└── test_rbac.py # PermissionEngine + registry
```
## How `conftest.py` keeps tests hermetic
The application reads configuration at import time:
- `app.config.Settings` is a `pydantic-settings` model that auto-loads `.env`
from the current working directory.
- `app.db` constructs the SQLAlchemy engine on import.
`tests/conftest.py` therefore, **before any `app.*` import**:
1. Sets `DATABASE_URL`, `SECRET_KEY`, `ADMIN_PASSWORD`, `LOG_LEVEL` to safe
test values via `os.environ.setdefault`.
2. Generates a fresh `FERNET_KEY` so encrypt/decrypt round-trips succeed.
3. Adds `backend/` to `sys.path`.
4. `chdir`s to a temp directory so the developer's local `.env` (which contains
keys not declared on `Settings`) is **not** picked up. Without this step
pydantic raises `extra_forbidden`.
No real Postgres connection is opened — SQLAlchemy connects lazily, and these
tests never issue a query.
## Running locally
There is no system-wide Python on this host, so use the same Python image as CI:
```bash
docker run --rm -v "$PWD/backend:/app" -w /app python:3.12-slim bash -c '
pip install -r requirements-dev.txt &&
python -m pytest -p no:warnings
'
```
Run a single test:
```bash
python -m pytest tests/test_auth.py::TestJWT -v
```
## CI
GitHub Actions runs the suite on every push / PR to `main` that touches
`backend/**` — see [.github/workflows/backend-tests.yml](../.github/workflows/backend-tests.yml).
Coverage is reported to the job log and uploaded as an artifact.
## Adding more tests
- **Pure / domain logic** (no DB): use the `fake_user_factory` /
`fake_role_factory` fixtures in `conftest.py`. They produce
`SimpleNamespace` objects that satisfy duck-typed access in `auth.py` and
`rbac/engine.py`.
- **Touches the DB**: build out a SQLite-backed fixture (override
`app.db.engine` with `sqlite:///:memory:` and `Base.metadata.create_all`).
Not required for the current smoke layer.
- **API endpoints**: spin up `TestClient(app)` once `get_db` and any external
Proxmox / SSH dependencies are dependency-overridden.
## Markers
Defined in `pytest.ini`:
- `@pytest.mark.smoke` — quickest sanity checks.
- `@pytest.mark.unit` — pure unit tests.
- `@pytest.mark.integration` — multi-module integration.
Run only smoke tests:
```bash
python -m pytest -m smoke
```

View File

92
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Shared pytest fixtures.
The DB engine is constructed at import time in ``app.db``. To keep tests
hermetic we set environment variables BEFORE importing the application.
The engine itself is created lazily-connected, so as long as no test issues
a real query the configured URL is irrelevant.
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
# --- Environment must be set BEFORE importing app.* ---------------------------
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
os.environ.setdefault("ADMIN_PASSWORD", "test-admin-password")
os.environ.setdefault("LOG_LEVEL", "WARNING")
# Generate a deterministic Fernet key for tests so encrypt/decrypt are stable.
from cryptography.fernet import Fernet # noqa: E402
os.environ.setdefault("FERNET_KEY", Fernet.generate_key().decode())
# Ensure the backend root is on sys.path so ``import app`` works regardless of
# the working directory pytest is launched from.
BACKEND_ROOT = Path(__file__).resolve().parent.parent
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
# pydantic-settings auto-loads ``.env`` from the current working directory.
# A developer .env may contain operational keys (POSTGRES_PASSWORD, ALLOWED_HOSTS,
# ...) that are not declared on the Settings model and would raise
# ``extra_forbidden``. Run from a clean temp dir so only env vars are honoured.
_TEST_CWD = Path(tempfile.mkdtemp(prefix="pvemanager-tests-"))
os.chdir(_TEST_CWD)
import pytest # noqa: E402
# --- Reusable fixtures --------------------------------------------------------
@pytest.fixture
def fake_role_factory():
"""Build a stand-in Role object with a permissions dict."""
def _make(permissions: dict | None = None, name: str = "tester"):
return SimpleNamespace(
name=name,
permissions=permissions or {},
has_permission=lambda perm: bool((permissions or {}).get(perm)),
)
return _make
@pytest.fixture
def fake_user_factory(fake_role_factory):
"""Build a stand-in User object compatible with auth/rbac code paths."""
def _make(
username: str = "alice",
is_admin: bool = False,
is_active: bool = True,
role=None,
permissions: dict | None = None,
):
if role is None and permissions is not None:
role = fake_role_factory(permissions=permissions)
return SimpleNamespace(
id=1,
username=username,
is_admin=is_admin,
is_active=is_active,
role=role,
locked_until=None,
)
return _make
@pytest.fixture
def mock_db():
"""Bare MagicMock standing in for a SQLAlchemy session."""
return MagicMock(name="db_session")

113
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,113 @@
"""Smoke tests for ``app.auth`` — pure functions only (no DB)."""
from __future__ import annotations
import importlib
from datetime import timedelta
import jwt
import pytest
from fastapi import HTTPException
@pytest.fixture(scope="module")
def auth():
return importlib.import_module("app.auth")
@pytest.fixture(scope="module")
def settings():
from app.config import settings as s
return s
class TestPasswordHashing:
@pytest.mark.smoke
def test_hash_then_verify(self, auth):
pw = "Correct-Horse-Battery-Staple-1!"
hashed = auth.get_password_hash(pw)
assert hashed != pw
assert auth.verify_password(pw, hashed) is True
def test_verify_rejects_wrong_password(self, auth):
hashed = auth.get_password_hash("right-password")
assert auth.verify_password("wrong-password", hashed) is False
def test_hash_is_salted(self, auth):
# Two hashes of the same password must differ (random salt).
a = auth.get_password_hash("same")
b = auth.get_password_hash("same")
assert a != b
class TestJWT:
@pytest.mark.smoke
def test_create_then_decode(self, auth):
token = auth.create_access_token({"sub": "alice"})
payload = auth.decode_access_token(token)
assert payload["sub"] == "alice"
assert "exp" in payload and "iat" in payload
def test_session_binding_round_trips(self, auth):
token = auth.create_access_token({"sub": "bob"}, session_token="sess-xyz")
payload = auth.decode_access_token(token)
assert payload["session"] == "sess-xyz"
def test_expired_token_raises_401(self, auth):
token = auth.create_access_token({"sub": "x"}, expires_delta=timedelta(seconds=-1))
with pytest.raises(HTTPException) as exc:
auth.decode_access_token(token)
assert exc.value.status_code == 401
def test_tampered_token_raises_401(self, auth):
token = auth.create_access_token({"sub": "x"})
tampered = token + "tamper"
with pytest.raises(HTTPException) as exc:
auth.decode_access_token(tampered)
assert exc.value.status_code == 401
def test_token_signed_with_wrong_secret_raises_401(self, auth, settings):
bogus = jwt.encode({"sub": "x"}, "different-secret", algorithm=settings.ALGORITHM)
with pytest.raises(HTTPException) as exc:
auth.decode_access_token(bogus)
assert exc.value.status_code == 401
class TestPermissionHelpers:
def test_check_permission_true_for_admin(self, auth, fake_user_factory):
admin = fake_user_factory(is_admin=True)
assert auth.check_permission(admin, "vm:delete") is True
def test_check_permission_false_when_role_lacks_it(self, auth, fake_user_factory):
user = fake_user_factory(permissions={"vm:view": True})
assert auth.check_permission(user, "vm:delete") is False
def test_require_permission_raises_403(self, auth, fake_user_factory):
user = fake_user_factory(permissions={})
with pytest.raises(HTTPException) as exc:
auth.require_permission(user, "vm:delete")
assert exc.value.status_code == 403
class TestClientIPParsing:
def _request_with_headers(self, headers: dict, client_host: str = "10.0.0.1"):
# Minimal stand-in mimicking starlette.Request surface used by get_client_ip.
from types import SimpleNamespace
# headers must be lower-cased keys; .get is what get_client_ip uses.
return SimpleNamespace(
headers={k.lower(): v for k, v in headers.items()},
client=SimpleNamespace(host=client_host),
)
def test_returns_first_valid_xff(self, auth):
req = self._request_with_headers({"X-Forwarded-For": "203.0.113.5, 10.0.0.1"})
assert auth.get_client_ip(req) == "203.0.113.5"
def test_rejects_spoofed_xff(self, auth):
# Non-IP value must be ignored — fall back to client.host.
req = self._request_with_headers(
{"X-Forwarded-For": "not-an-ip; rm -rf /"},
client_host="10.0.0.99",
)
assert auth.get_client_ip(req) == "10.0.0.99"

View File

@@ -0,0 +1,69 @@
"""Smoke tests for ``app.crypto`` field-level encryption helpers."""
from __future__ import annotations
import importlib
import pytest
@pytest.fixture(scope="module")
def crypto():
return importlib.import_module("app.crypto")
class TestEncryptDecryptRoundtrip:
@pytest.mark.smoke
def test_roundtrip_simple_string(self, crypto):
token = crypto.encrypt_value("hunter2")
assert token != "hunter2"
assert crypto.decrypt_value(token) == "hunter2"
@pytest.mark.smoke
def test_roundtrip_unicode(self, crypto):
secret = "пароль-密码-🔐"
assert crypto.decrypt_value(crypto.encrypt_value(secret)) == secret
def test_empty_value_passthrough(self, crypto):
assert crypto.encrypt_value("") == ""
assert crypto.decrypt_value("") == ""
assert crypto.encrypt_value(None) is None
assert crypto.decrypt_value(None) is None
def test_each_call_produces_unique_ciphertext(self, crypto):
# Fernet embeds a random IV — same plaintext should encrypt differently.
a = crypto.encrypt_value("same")
b = crypto.encrypt_value("same")
assert a != b
assert crypto.decrypt_value(a) == crypto.decrypt_value(b) == "same"
class TestLegacyPlaintextFallback:
def test_decrypt_invalid_token_returns_input(self, crypto):
# Legacy/un-encrypted rows must not blow up — fall through to plaintext.
assert crypto.decrypt_value("plain-value-not-fernet") == "plain-value-not-fernet"
class TestNoFernetKey:
def test_encrypt_passthrough_without_key(self, crypto, monkeypatch):
# ``settings`` is imported lazily inside ``_get_fernet`` — patch the live
# singleton on the config module instead.
from app import config as app_config
monkeypatch.setattr(app_config.settings, "FERNET_KEY", None, raising=False)
monkeypatch.setattr(crypto, "_warned_no_key", False, raising=False)
assert crypto.encrypt_value("plain") == "plain"
assert crypto.decrypt_value("plain") == "plain"
class TestEncryptedStringColumn:
def test_bind_and_result_processors(self, crypto):
col = crypto.EncryptedString(255)
bound = col.process_bind_param("topsecret", dialect=None)
assert bound != "topsecret"
assert col.process_result_value(bound, dialect=None) == "topsecret"
def test_bind_and_result_handle_none(self, crypto):
col = crypto.EncryptedString(255)
assert col.process_bind_param(None, dialect=None) is None
assert col.process_result_value(None, dialect=None) is None

120
backend/tests/test_rbac.py Normal file
View File

@@ -0,0 +1,120 @@
"""Smoke tests for the RBAC permission engine."""
from __future__ import annotations
import importlib
import pytest
from fastapi import HTTPException
@pytest.fixture(scope="module")
def rbac():
return importlib.import_module("app.rbac")
class TestRegistry:
@pytest.mark.smoke
def test_well_known_permissions_registered(self, rbac):
for code in ("vm:view", "vm:create", "vm:delete", "user:create", "role:manage"):
assert rbac.PERMISSIONS.get(code) is not None, f"missing {code}"
def test_legacy_resolution(self, rbac):
assert rbac.resolve_permission("vms.create") == "vm:create"
assert rbac.resolve_permission("vm:create") == "vm:create"
# Unknown codes are returned untouched.
assert rbac.resolve_permission("totally:unknown") == "totally:unknown"
def test_validate_partitions_codes(self, rbac):
# The registry's internal legacy map uses ``resource.action`` form (e.g.
# ``vm.create``). The richer ``LEGACY_PERMISSION_MAP`` (e.g. ``vms.create``)
# is consumed by ``resolve_permission`` / the engine, not by ``validate``.
valid, invalid = rbac.PERMISSIONS.validate(["vm:view", "vm.create", "bogus:perm"])
assert "vm:view" in valid
assert "vm:create" in valid # registry legacy resolved
assert invalid == ["bogus:perm"]
class TestEngineHasPermission:
@pytest.mark.smoke
def test_admin_bypass(self, rbac, fake_user_factory):
admin = fake_user_factory(is_admin=True)
assert rbac.PermissionEngine.has_permission(admin, "vm:delete") is True
assert rbac.PermissionEngine.has_permission(admin, "anything:at:all") is True
def test_direct_grant(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vm:view": True})
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
assert rbac.PermissionEngine.has_permission(user, "vm:delete") is False
def test_legacy_grant_resolves_to_new(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vms.create": True})
assert rbac.PermissionEngine.has_permission(user, "vm:create") is True
def test_wildcard_grants_all_actions_on_resource(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vm:*": True})
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
assert rbac.PermissionEngine.has_permission(user, "vm:delete") is True
# Other resources untouched.
assert rbac.PermissionEngine.has_permission(user, "user:delete") is False
def test_manage_implies_common_actions(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vm:manage": True})
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
assert rbac.PermissionEngine.has_permission(user, "vm:start") is True
# delete is NOT in the implied set.
assert rbac.PermissionEngine.has_permission(user, "vm:delete") is False
def test_user_without_role_has_no_permissions(self, rbac, fake_user_factory):
user = fake_user_factory(role=None)
assert rbac.PermissionEngine.has_permission(user, "vm:view") is False
def test_disabled_permission_not_granted(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vm:view": False})
assert rbac.PermissionEngine.has_permission(user, "vm:view") is False
class TestEngineCheckPermission:
def test_check_permission_raises_403(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={})
with pytest.raises(HTTPException) as exc:
rbac.PermissionEngine.check_permission(user, "vm:delete")
assert exc.value.status_code == 403
assert "vm:delete" in exc.value.detail
def test_check_permission_passes_for_admin(self, rbac, fake_user_factory):
admin = fake_user_factory(is_admin=True)
rbac.PermissionEngine.check_permission(admin, "vm:delete") # no raise
class TestAggregateHelpers:
def test_has_any_permission(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vm:view": True})
assert rbac.PermissionEngine.has_any_permission(user, ["vm:delete", "vm:view"]) is True
assert rbac.PermissionEngine.has_any_permission(user, ["vm:delete", "vm:create"]) is False
def test_has_all_permissions(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vm:view": True, "vm:create": True})
assert rbac.PermissionEngine.has_all_permissions(user, ["vm:view", "vm:create"]) is True
assert rbac.PermissionEngine.has_all_permissions(user, ["vm:view", "vm:delete"]) is False
def test_filter_permissions(self, rbac, fake_user_factory):
user = fake_user_factory(permissions={"vm:view": True})
kept = rbac.PermissionEngine.filter_permissions(user, ["vm:view", "vm:delete"])
assert kept == ["vm:view"]
class TestUserModelIntegration:
"""``User.has_permission`` should defer to PermissionEngine."""
def test_user_has_permission_delegates(self, rbac, fake_user_factory):
# Use the SimpleNamespace user — exercises the same call path as the
# real model thanks to duck typing in PermissionEngine.
user = fake_user_factory(permissions={"vm:view": True})
assert rbac.PermissionEngine.has_permission(user, "vm:view") is True
def test_get_user_permissions_admin_returns_full_set(self, rbac, fake_user_factory):
admin = fake_user_factory(is_admin=True)
perms = rbac.PermissionEngine.get_user_permissions(admin)
# Admin shortcut returns every registered permission code.
assert {"vm:view", "vm:delete", "user:create"}.issubset(perms)

View File

@@ -49,12 +49,27 @@ export default function ConsolePage() {
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
);
// Dynamic import noVNC
// Dynamic import noVNC. The package ships CJS, so depending on bundler
// interop the result can be: { default: RFB }, { default: { default: RFB, __esModule: true } },
// or the raw CJS namespace. Normalise to the actual constructor.
// @ts-expect-error noVNC doesn't have TS declarations
const rfbModule = await import('@novnc/novnc/lib/rfb');
// Handle both ESM default export and CJS module.exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const RFB = (rfbModule.default ?? rfbModule) as any;
const mod: any = rfbModule;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const RFB: any =
typeof mod === 'function'
? mod
: typeof mod.default === 'function'
? mod.default
: typeof mod.default?.default === 'function'
? mod.default.default
: typeof mod.RFB === 'function'
? mod.RFB
: null;
if (typeof RFB !== 'function') {
throw new Error('noVNC RFB constructor not found in module exports');
}
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const params = new URLSearchParams({

View File

@@ -274,7 +274,7 @@ export default function DashboardPage() {
{/* Resource Cards */}
<div className="grid gap-4 sm:grid-cols-3">
<ResourceCard
label="CPU"
label={t('common.cpu')}
percent={cpuPercent}
used={cpuUsedLabel}
total={cpuTotalLabel}

View File

@@ -13,6 +13,7 @@ import { useServers } from '@/hooks/use-nodes';
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
import { useIPAMNetworks } from '@/hooks/use-ipam';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
import type { VMDeployRequest, OSTemplate, ProxmoxServer } from '@/types';
import { toast } from 'sonner';
@@ -26,6 +27,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const [step, setStep] = useState<Step>('server');
const stepIdx = STEPS.indexOf(step);
const addDeployTask = useDeployTasksStore((s) => s.addTask);
// State
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
const [selectedNode, setSelectedNode] = useState('');
@@ -67,10 +70,20 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const { data: networks = [] } = useIPAMNetworks();
const deploy = useMutation({
mutationFn: (data: VMDeployRequest) => apiClient.post<{ success: boolean; vmid: number; name: string }>('/proxmox/api/deploy', data),
onSuccess: () => {
toast.success(t('wizard.deploy_success', { name: config.name }));
handleClose();
mutationFn: (data: VMDeployRequest) =>
apiClient.post<{ task_id: number; status: string; name: string }>('/templates/api/deploy', data),
onSuccess: (res) => {
addDeployTask({
id: res.task_id,
name: res.name,
status: 'pending',
step: t('wizard.deploy_starting'),
progress: 0,
vmid: null,
node: null,
error_message: null,
});
toast.success(t('wizard.deploy_queued', { name: res.name }));
navigate('/instances');
},
onError: (err: Error) => toast.error(err.message),
@@ -222,7 +235,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
<CardContent className="space-y-3">
<div>
<Label>{t('common.name')}</Label>
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder="my-vm-01" />
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder={t('common.placeholder_vm_name')} />
</div>
<div>
<Label>{t('wizard.target_node')}</Label>
@@ -316,7 +329,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.ci_user')}</Label>
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder="ubuntu" />
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder={t('common.placeholder_ubuntu')} />
</div>
<div>
<Label>{t('wizard.ci_password')}</Label>
@@ -328,7 +341,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={config.ssh_keys}
onChange={e => setConfig(p => ({ ...p, ssh_keys: e.target.value }))}
placeholder="ssh-rsa AAAA..."
placeholder={t('common.placeholder_ssh_key')}
/>
</div>
</CardContent>
@@ -346,12 +359,12 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
<Row label={t('wizard.template_label')} value={selectedTemplate?.name || ''} />
<Row label={t('common.name')} value={config.name} />
<Row label="vCPU" value={String(config.cores)} />
<Row label={t('common.vcpu')} value={String(config.cores)} />
<Row label={t('wizard.memory_mb')} value={`${config.memory} MB`} />
<Row label={t('wizard.disk_gb')} value={`${config.disk} GB`} />
<Row label={t('wizard.bridge')} value={config.bridge} />
{config.ipam_network_id && <Row label="IPAM" value={networks.find(n => n.id === config.ipam_network_id)?.name || 'Auto'} />}
{config.ip_address && <Row label="IP" value={config.ip_address} />}
{config.ipam_network_id && <Row label={t('common.ipam')} value={networks.find(n => n.id === config.ipam_network_id)?.name || 'Auto'} />}
{config.ip_address && <Row label={t('common.primary_ip')} value={config.ip_address} />}
{config.cloud_init_user && <Row label={t('wizard.ci_user')} value={config.cloud_init_user} />}
</CardContent>
</Card>

View File

@@ -0,0 +1,498 @@
import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
useCloneInstance,
useReinstallInstance,
useChangePassword,
useUpdateNotes,
useExecuteCommand,
useNodeIsos,
useAttachIso,
useDetachIso,
useVMConfig,
} from '@/hooks/use-instances';
import { toast } from 'sonner';
export type InstanceAction =
| 'clone'
| 'reinstall'
| 'change-password'
| 'notes'
| 'execute'
| 'iso'
| null;
interface Props {
open: InstanceAction;
onOpenChange: (open: InstanceAction) => void;
serverId: number;
vmid: number;
type: string; // 'qemu' | 'lxc'
node: string;
name?: string;
description?: string;
}
export default function InstanceActionDialogs(props: Props) {
const { open, onOpenChange, serverId, vmid, type, node, name, description } = props;
const close = () => onOpenChange(null);
const shared = { serverId, vmid, type, node, name, description };
return (
<>
<CloneDialog open={open === 'clone'} onClose={close} {...shared} />
<ReinstallDialog open={open === 'reinstall'} onClose={close} {...shared} />
<ChangePasswordDialog open={open === 'change-password'} onClose={close} {...shared} />
<NotesDialog open={open === 'notes'} onClose={close} {...shared} />
<ExecuteCommandDialog open={open === 'execute'} onClose={close} {...shared} />
<IsoDialog open={open === 'iso'} onClose={close} {...shared} />
</>
);
}
// ==================== Clone ====================
function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const [newName, setNewName] = useState('');
const [full, setFull] = useState(true);
const clone = useCloneInstance(serverId, vmid, type, node);
const submit = () => {
if (!newName.trim()) return;
clone.mutate(
{ new_name: newName.trim(), full },
{
onSuccess: (data) => {
toast.success(`Cloned to VM #${data.new_vmid}`);
setNewName('');
onClose();
},
onError: (e) => toast.error(e.message),
}
);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Copy className="h-4 w-4" /> Клонировать</DialogTitle>
<DialogDescription>Создать копию <strong>{name ?? `#${vmid}`}</strong></DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="clone-name">Имя новой инстанции</Label>
<Input id="clone-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder={`${name || 'instance'}-clone`} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={full} onChange={(e) => setFull(e.target.checked)} />
<span>Полный клон (full clone)</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button onClick={submit} disabled={!newName.trim() || clone.isPending}>
{clone.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Клонировать
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Reinstall ====================
function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const [confirm, setConfirm] = useState('');
const reinstall = useReinstallInstance(serverId, vmid, node);
const expected = name || String(vmid);
// Reinstall via this endpoint currently supports only saved-template instances.
const submit = () => {
if (confirm !== expected) return;
reinstall.mutate(undefined, {
onSuccess: () => {
toast.success(`Reinstall queued for ${expected}`);
setConfirm('');
onClose();
},
onError: (e) => toast.error(e.message),
});
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Sparkles className="h-4 w-4" /> Переустановить</DialogTitle>
<DialogDescription>
Это пересоздаст инстанс <strong>{expected}</strong> из исходного шаблона. Все данные будут утеряны.
{type === 'lxc' && (
<span className="mt-2 block text-amber-500">Поддержка LXC — экспериментальная.</span>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Введите <strong className="text-foreground">{expected}</strong> для подтверждения</Label>
<Input value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={expected} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button variant="destructive" onClick={submit} disabled={confirm !== expected || reinstall.isPending}>
{reinstall.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Переустановить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Change Password ====================
function ChangePasswordDialog({ open, onClose, serverId, vmid, type, node }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState('root');
const [password, setPassword] = useState('');
const [show, setShow] = useState(false);
const mut = useChangePassword(serverId, vmid, type, node);
const submit = () => {
if (!password || password.length < 4) {
toast.error('Password must be at least 4 chars');
return;
}
mut.mutate(
{ username, password },
{
onSuccess: () => {
toast.success('Password changed');
setPassword('');
onClose();
},
onError: (e) => toast.error(e.message),
}
);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><KeyRound className="h-4 w-4" /> Изменить пароль</DialogTitle>
<DialogDescription>
{type === 'qemu'
? 'Требуется установленный qemu-guest-agent внутри VM.'
: 'Контейнер должен быть запущен.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Пользователь</Label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('common.placeholder_root')} />
</div>
<div>
<Label>Новый пароль</Label>
<div className="relative">
<Input
type={show ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1 h-7 w-7"
onClick={() => setShow((s) => !s)}
>
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button onClick={submit} disabled={!password || mut.isPending}>
{mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Изменить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Notes ====================
function NotesDialog({ open, onClose, serverId, vmid, type, node, description }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const config = useVMConfig(serverId, vmid, type, node, open);
const remote = (config.data?.description as string | undefined) ?? description ?? '';
const [value, setValue] = useState('');
const [touched, setTouched] = useState(false);
// Sync remote → local when dialog opens or config loads, unless user typed
useEffect(() => {
if (open && !touched) setValue(remote);
if (!open) setTouched(false);
}, [open, remote, touched]);
const mut = useUpdateNotes(serverId, vmid, type, node);
const submit = () => {
mut.mutate(
{ description: value },
{
onSuccess: () => {
toast.success('Notes saved');
setTouched(false);
onClose();
},
onError: (e) => toast.error(e.message),
}
);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><FileText className="h-4 w-4" /> Примечание</DialogTitle>
<DialogDescription>Свободный текст / markdown, видимый в Proxmox UI.</DialogDescription>
</DialogHeader>
<textarea
value={value}
onChange={(e) => { setTouched(true); setValue(e.target.value); }}
rows={8}
className="min-h-32 w-full rounded-md border bg-background p-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
placeholder="Описание / заметки об инстансе..."
/>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button onClick={submit} disabled={mut.isPending}>
{mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Сохранить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Execute Command ====================
function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const [command, setCommand] = useState('');
const [timeout, setTimeoutVal] = useState(30);
const exec = useExecuteCommand(serverId, vmid);
const result = exec.data;
const submit = () => {
if (!command.trim()) return;
exec.mutate({ node, command, timeout });
};
if (type !== 'qemu') {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Запуск команд недоступен</DialogTitle>
<DialogDescription>Поддержка через qemu-guest-agent — только для KVM (qemu) инстансов.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={onClose}>OK</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><TerminalIcon className="h-4 w-4" /> Запуск команды (qemu-agent)</DialogTitle>
<DialogDescription>Команда выполняется внутри VM через guest-agent.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-[1fr_120px]">
<div>
<Label>Команда</Label>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={t('common.placeholder_command')}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
/>
</div>
<div>
<Label>Таймаут (с)</Label>
<Input
type="number"
min={1}
max={300}
value={timeout}
onChange={(e) => setTimeoutVal(Number(e.target.value) || 30)}
/>
</div>
</div>
{result && (
<div className="space-y-2">
<div className="text-xs">
exit_code: <span className={result.exit_code === 0 ? 'text-green-500' : 'text-red-500'}>{result.exit_code}</span>
</div>
{result.stdout && (
<div>
<p className="mb-1 text-xs text-muted-foreground">stdout</p>
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs font-mono">{result.stdout}</pre>
</div>
)}
{result.stderr && (
<div>
<p className="mb-1 text-xs text-muted-foreground">stderr</p>
<pre className="max-h-32 overflow-auto rounded-md bg-red-500/10 p-2 text-xs font-mono text-red-500">{result.stderr}</pre>
</div>
)}
{result.error && <p className="text-xs text-red-500">{result.error}</p>}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Закрыть</Button>
<Button onClick={submit} disabled={!command.trim() || exec.isPending}>
{exec.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Выполнить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== ISO mount/unmount ====================
function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const [device, setDevice] = useState('ide2');
const [volid, setVolid] = useState<string>('');
const isQemu = type === 'qemu';
const isos = useNodeIsos(serverId, node, open && isQemu);
const config = useVMConfig(serverId, vmid, type, node, open && isQemu);
const attach = useAttachIso(serverId, vmid, node);
const detach = useDetachIso(serverId, vmid, node);
const currentIso = useMemo(() => {
const v = config.data?.[device];
if (typeof v !== 'string') return null;
if (v.startsWith('none')) return null;
return v;
}, [config.data, device]);
if (!isQemu) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>ISO недоступно для LXC</DialogTitle>
<DialogDescription>Подключение ISO-образов поддерживается только для KVM (qemu) виртуальных машин.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={onClose}>OK</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Disc className="h-4 w-4" /> ISO образ</DialogTitle>
<DialogDescription>Подключите ISO к виртуальному CD-ROM устройству.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Устройство</Label>
<Select value={device} onValueChange={(v) => v && setDevice(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ide0">ide0</SelectItem>
<SelectItem value="ide1">ide1</SelectItem>
<SelectItem value="ide2">ide2</SelectItem>
<SelectItem value="ide3">ide3</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-xs text-muted-foreground self-end">
Сейчас: <span className="font-mono">{currentIso || 'пусто'}</span>
</div>
</div>
<div>
<Label>Образ</Label>
<Select value={volid} onValueChange={(v) => v && setVolid(v)}>
<SelectTrigger>
<SelectValue placeholder={isos.isLoading ? 'Загрузка...' : 'Выберите ISO'} />
</SelectTrigger>
<SelectContent>
{(isos.data?.isos || []).map((iso) => (
<SelectItem key={iso.volid} value={iso.volid}>{iso.name || iso.volid}</SelectItem>
))}
</SelectContent>
</Select>
{isos.data?.isos?.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">На ноде нет загруженных ISO образов.</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => detach.mutate({ device }, {
onSuccess: () => { toast.success('ISO отключён'); onClose(); },
onError: (e) => toast.error(e.message),
})}
disabled={detach.isPending || !currentIso}
>
{detach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Отключить
</Button>
<Button
onClick={() => attach.mutate({ volid, device }, {
onSuccess: () => { toast.success('ISO подключён'); onClose(); },
onError: (e) => toast.error(e.message),
})}
disabled={!volid || attach.isPending}
>
{attach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Подключить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useParams, useSearchParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
@@ -9,12 +10,27 @@ import {
Monitor,
Container,
Loader2,
Power,
MoreHorizontal,
Copy,
Sparkles,
KeyRound,
FileText,
Disc,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
import { vmTypeLabel } from '@/lib/format';
import { toast } from 'sonner';
@@ -43,7 +59,9 @@ export default function InstanceDetailPage() {
const power = usePowerAction(sid, vid, type, node);
const isRunning = vm?.status === 'running';
const isQemu = type === 'qemu';
const TypeIcon = type === 'qemu' ? Monitor : Container;
const [dialog, setDialog] = useState<InstanceAction>(null);
const handlePower = (action: string) => {
power.mutate(
@@ -131,6 +149,15 @@ export default function InstanceDetailPage() {
<RotateCcw className="mr-1.5 h-4 w-4" />
{t('common.restart', 'Restart')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePower('shutdown')}
disabled={power.isPending}
>
<Power className="mr-1.5 h-4 w-4" />
{t('common.shutdown', 'Shutdown')}
</Button>
<Button
variant="destructive"
size="sm"
@@ -142,6 +169,36 @@ export default function InstanceDetailPage() {
</Button>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setDialog('clone')}>
<Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')}
</DropdownMenuItem>
{isQemu && (
<DropdownMenuItem onClick={() => setDialog('iso')}>
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setDialog('change-password')}>
<KeyRound className="mr-2 h-4 w-4" /> {t('common.changePassword', 'Изменить пароль')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDialog('notes')}>
<FileText className="mr-2 h-4 w-4" /> {t('common.notes', 'Примечание')}
</DropdownMenuItem>
{isQemu && (
<DropdownMenuItem onClick={() => setDialog('execute')}>
<Terminal className="mr-2 h-4 w-4" /> {t('common.runCommand', 'Запуск команд')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDialog('reinstall')}>
<Sparkles className="mr-2 h-4 w-4" /> {t('common.reinstall', 'Переустановить')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
@@ -178,6 +235,17 @@ export default function InstanceDetailPage() {
<DestroyTab serverId={sid} vmid={vid} type={type} node={node} name={vm?.name} />
</TabsContent>
</Tabs>
<InstanceActionDialogs
open={dialog}
onOpenChange={setDialog}
serverId={sid}
vmid={vid}
type={type}
node={node}
name={vm?.name}
description={vm?.description}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
@@ -28,7 +28,14 @@ import {
Monitor,
Container,
Loader2,
Power,
Copy,
Sparkles,
KeyRound,
FileText,
Disc,
} from 'lucide-react';
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -58,8 +65,10 @@ import {
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, useBulkOperation, usePowerAction } from '@/hooks/use-instances';
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync } from '@/hooks/use-instances';
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
import type { VMInstance } from '@/types';
import { toast } from 'sonner';
@@ -77,11 +86,33 @@ function InlineProgress({ value, max, className }: { value: number; max: number;
);
}
// ==================== Circular Progress ====================
function CircularProgress({ value }: { value: number }) {
const r = 10;
const circ = 2 * Math.PI * r;
const offset = circ - (value / 100) * circ;
return (
<svg width="28" height="28" viewBox="0 0 28 28" className="shrink-0">
<circle cx="14" cy="14" r={r} fill="none" stroke="currentColor" strokeWidth="3" className="text-muted" />
<circle
cx="14" cy="14" r={r} fill="none"
stroke="currentColor" strokeWidth="3"
strokeDasharray={circ} strokeDashoffset={offset}
strokeLinecap="round"
className="text-blue-500 transition-all duration-500"
transform="rotate(-90 14 14)"
/>
</svg>
);
}
// ==================== Row Action Menu ====================
function RowActionMenu({ vm }: { vm: VMInstance }) {
const { t } = useTranslation();
const power = usePowerAction(vm.server_id!, vm.vmid, vm.type, vm.node);
const isRunning = vm.status === 'running';
const isQemu = vm.type === 'qemu';
const [dialog, setDialog] = useState<InstanceAction>(null);
const handleAction = (action: string) => {
power.mutate(
@@ -94,11 +125,12 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="h-8 w-8" />}>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} target="_blank" rel="noopener noreferrer" />}>
<Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
</DropdownMenuItem>
@@ -112,17 +144,54 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
<DropdownMenuItem onClick={() => handleAction('restart')}>
<RotateCcw className="mr-2 h-4 w-4" /> {t('common.restart', 'Restart')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('shutdown')}>
<Power className="mr-2 h-4 w-4" /> {t('common.shutdown', 'Shutdown')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('stop')}>
<Square className="mr-2 h-4 w-4" /> {t('common.stop', 'Stop')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDialog('clone')}>
<Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')}
</DropdownMenuItem>
{isQemu && (
<DropdownMenuItem onClick={() => setDialog('iso')}>
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setDialog('change-password')}>
<KeyRound className="mr-2 h-4 w-4" /> {t('common.changePassword', 'Изменить пароль')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDialog('notes')}>
<FileText className="mr-2 h-4 w-4" /> {t('common.notes', 'Примечание')}
</DropdownMenuItem>
{isQemu && (
<DropdownMenuItem onClick={() => setDialog('execute')}>
<Terminal className="mr-2 h-4 w-4" /> {t('common.runCommand', 'Запуск команд')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=snapshots`} />}>
<Camera className="mr-2 h-4 w-4" /> {t('common.snapshots', 'Snapshots')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDialog('reinstall')}>
<Sparkles className="mr-2 h-4 w-4" /> {t('common.reinstall', 'Переустановить')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<InstanceActionDialogs
open={dialog}
onOpenChange={setDialog}
serverId={vm.server_id!}
vmid={vm.vmid}
type={vm.type}
node={vm.node}
name={vm.name}
description={vm.description}
/>
</>
);
}
@@ -130,8 +199,42 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
export default function InstancesPage() {
const { t } = useTranslation();
const { data: vms, isLoading } = useVirtualMachines();
useVMStatusSync();
const bulkOp = useBulkOperation();
// ── Deploy task polling ──────────────────────────────────────────
const { tasks: deployTasks, updateTask, removeTask } = useDeployTasksStore();
const activeTasks = deployTasks.filter((t) => t.status === 'pending' || t.status === 'running');
useEffect(() => {
if (activeTasks.length === 0) return;
const poll = async () => {
for (const task of activeTasks) {
try {
const data = await apiClient.get<{
id: number; status: string; step: string | null; progress: number;
vmid: number | null; node: string | null; error_message: string | null;
}>(`/templates/api/deploy/${task.id}`);
updateTask(task.id, {
status: data.status as 'pending' | 'running' | 'completed' | 'failed',
step: data.step,
progress: data.progress,
vmid: data.vmid ?? undefined,
node: data.node ?? undefined,
error_message: data.error_message ?? undefined,
});
if (data.status === 'completed') {
setTimeout(() => removeTask(task.id), 5000);
}
} catch { /* ignore */ }
}
};
poll();
const timer = setInterval(poll, 2000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTasks.length, activeTasks.map((t) => t.id).join(',')]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [nodeFilter, setNodeFilter] = useState<string>('all');
@@ -139,21 +242,14 @@ export default function InstancesPage() {
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Filter data
// Filter real VMs
const filteredVMs = useMemo(() => {
if (!vms) return [];
return vms.filter((vm) => {
// Type tab filter
if (typeTab === 'vm' && vm.type !== 'qemu') return false;
if (typeTab === 'lxc' && vm.type !== 'lxc') return false;
// Status filter
if (statusFilter !== 'all' && vm.status !== statusFilter) return false;
// Node filter
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false;
// Search filter
if (search) {
const q = search.toLowerCase();
const name = (vm.name || '').toLowerCase();
@@ -161,18 +257,34 @@ export default function InstancesPage() {
const vmid = String(vm.vmid);
if (!name.includes(q) && !ip.includes(q) && !vmid.includes(q)) return false;
}
return true;
});
}, [vms, typeTab, statusFilter, nodeFilter, search]);
// Ghost rows from active deploy tasks (prepended before real VMs)
const ghostRows = useMemo<VMInstance[]>(() => {
return deployTasks
.filter((t) => t.status === 'pending' || t.status === 'running')
.map((t) => ({
vmid: t.vmid ?? 0,
name: t.name,
status: 'creating' as const,
type: 'qemu' as const,
node: t.node ?? '...',
server_id: undefined,
_deployTaskId: t.id,
}));
}, [deployTasks]);
const allRows = useMemo<VMInstance[]>(() => [...ghostRows, ...filteredVMs], [ghostRows, filteredVMs]);
// Unique nodes for filter
const uniqueNodes = useMemo(() => {
if (!vms) return [];
return [...new Set(vms.map((v) => v.node))].sort();
}, [vms]);
// Counts for tabs
// Counts (ghosts not counted in tabs)
const counts = useMemo(() => {
if (!vms) return { all: 0, vm: 0, lxc: 0 };
return {
@@ -182,15 +294,14 @@ export default function InstancesPage() {
};
}, [vms]);
// Selected VMs
// Selected real VMs (skip ghost rows by checking _deployTaskId)
const selectedVMs = useMemo(() => {
return Object.keys(rowSelection)
.filter((k) => rowSelection[k])
.map((idx) => filteredVMs[Number(idx)])
.filter(Boolean);
}, [rowSelection, filteredVMs]);
.map((idx) => allRows[Number(idx)])
.filter((vm) => vm && !vm._deployTaskId);
}, [rowSelection, allRows]);
// Bulk action handler
const handleBulk = (action: string) => {
if (!selectedVMs.length) return;
bulkOp.mutate(
@@ -205,10 +316,7 @@ export default function InstancesPage() {
})),
},
{
onSuccess: () => {
toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`);
setRowSelection({});
},
onSuccess: () => { toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`); setRowSelection({}); },
onError: (err) => toast.error(err.message),
}
);
@@ -227,14 +335,18 @@ export default function InstancesPage() {
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
cell: ({ row }) => {
const isGhost = !!row.original._deployTaskId;
if (isGhost) return null;
return (
<input
type="checkbox"
className="rounded border-border"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
);
},
size: 32,
enableSorting: false,
},
@@ -253,6 +365,21 @@ export default function InstancesPage() {
),
cell: ({ row }) => {
const vm = row.original;
const isGhost = !!vm._deployTaskId;
if (isGhost) {
const task = deployTasks.find((t) => t.id === vm._deployTaskId);
return (
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
<div>
<span className="font-medium text-muted-foreground">{vm.name || 'New VM'}</span>
<span className="ml-2 text-xs text-muted-foreground">
{task?.node ? `· ${task.node}` : ''}
</span>
</div>
</div>
);
}
return (
<Link
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
@@ -272,22 +399,33 @@ export default function InstancesPage() {
{
accessorKey: 'status',
header: t('common.status', 'Status'),
cell: ({ getValue }) => {
const status = getValue<string>();
cell: ({ row }) => {
const vm = row.original;
const isGhost = !!vm._deployTaskId;
if (isGhost) {
const task = deployTasks.find((t) => t.id === vm._deployTaskId);
const progress = task?.progress ?? 0;
return (
<div className="flex items-center gap-2">
<CircularProgress value={progress} />
<Badge variant="secondary" className="bg-blue-500/10 text-blue-600 text-[10px]">
{t('instances.status_creating', 'Creating')}
{progress > 0 ? ` ${progress}%` : ''}
</Badge>
</div>
);
}
const status = vm.status;
return (
<Badge
variant="secondary"
className={
status === 'running'
? 'bg-green-500/10 text-green-500'
: 'bg-muted text-muted-foreground'
}
className={status === 'running' ? 'bg-green-500/10 text-green-500' : 'bg-muted text-muted-foreground'}
>
{status}
</Badge>
);
},
size: 100,
size: 140,
},
{
accessorKey: 'node',
@@ -336,6 +474,7 @@ export default function InstancesPage() {
header: 'CPU',
cell: ({ row }) => {
const vm = row.original;
if (vm._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
if (!vm.cpu && vm.cpu !== 0) return <span className="text-xs text-muted-foreground">—</span>;
return <InlineProgress value={vm.cpu * 100} max={100} />;
},
@@ -346,15 +485,13 @@ export default function InstancesPage() {
header: t('dashboard.memory', 'Memory'),
cell: ({ row }) => {
const vm = row.original;
if (!vm.mem || !vm.maxmem) return <span className="text-xs text-muted-foreground">—</span>;
if (vm._deployTaskId || !vm.mem || !vm.maxmem) return <span className="text-xs text-muted-foreground">—</span>;
return (
<Tooltip>
<TooltipTrigger>
<InlineProgress value={vm.mem} max={vm.maxmem} />
</TooltipTrigger>
<TooltipContent>
{formatBytes(vm.mem)} / {formatBytes(vm.maxmem)}
</TooltipContent>
<TooltipContent>{formatBytes(vm.mem)} / {formatBytes(vm.maxmem)}</TooltipContent>
</Tooltip>
);
},
@@ -365,15 +502,13 @@ export default function InstancesPage() {
header: 'Disk',
cell: ({ row }) => {
const vm = row.original;
if (!vm.disk && !vm.maxdisk) return <span className="text-xs text-muted-foreground">—</span>;
if (vm._deployTaskId || (!vm.disk && !vm.maxdisk)) return <span className="text-xs text-muted-foreground">—</span>;
return (
<Tooltip>
<TooltipTrigger>
<InlineProgress value={vm.disk || 0} max={vm.maxdisk || 0} />
</TooltipTrigger>
<TooltipContent>
{formatBytes(vm.disk || 0)} / {formatBytes(vm.maxdisk || 0)}
</TooltipContent>
<TooltipContent>{formatBytes(vm.disk || 0)} / {formatBytes(vm.maxdisk || 0)}</TooltipContent>
</Tooltip>
);
},
@@ -383,6 +518,7 @@ export default function InstancesPage() {
id: 'uptime',
header: 'Uptime',
cell: ({ row }) => {
if (row.original._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
const up = row.original.uptime;
return <span className="text-xs text-muted-foreground">{up ? formatUptime(up) : '—'}</span>;
},
@@ -392,15 +528,14 @@ export default function InstancesPage() {
id: 'tags',
accessorKey: 'tags',
header: 'Tags',
cell: ({ getValue }) => {
cell: ({ getValue, row }) => {
if (row.original._deployTaskId) return null;
const tags = getValue<string>();
if (!tags) return null;
return (
<div className="flex gap-1 flex-wrap">
{tags.split(/[;,]/).filter(Boolean).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] leading-tight">
{tag.trim()}
</Badge>
<Badge key={tag} variant="secondary" className="text-[10px] leading-tight">{tag.trim()}</Badge>
))}
</div>
);
@@ -409,17 +544,21 @@ export default function InstancesPage() {
},
{
id: 'actions',
cell: ({ row }) => <RowActionMenu vm={row.original} />,
cell: ({ row }) => {
if (row.original._deployTaskId) return null;
return <RowActionMenu vm={row.original} />;
},
size: 48,
enableSorting: false,
},
],
[t]
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, deployTasks]
);
// ==================== TanStack Table ====================
const table = useReactTable({
data: filteredVMs,
data: allRows,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
@@ -522,7 +661,7 @@ export default function InstancesPage() {
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredVMs.length === 0 ? (
) : allRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Monitor className="mb-3 h-10 w-10 opacity-40" />
<p className="text-sm font-medium">{t('instances.no_instances', 'No instances found')}</p>
@@ -542,15 +681,22 @@ export default function InstancesPage() {
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{table.getRowModel().rows.map((row) => {
const isGhost = !!row.original._deployTaskId;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={isGhost ? 'opacity-70 bg-blue-500/5 border-l-2 border-l-blue-500' : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
);
})}
</TableBody>
</Table>
)}
@@ -558,15 +704,15 @@ export default function InstancesPage() {
</Card>
{/* Pagination */}
{filteredVMs.length > 0 && (
{allRows.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{t('instances.showing', 'Showing')} {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
filteredVMs.length
allRows.length
)}{' '}
{t('instances.of', 'of')} {filteredVMs.length}
{t('instances.of', 'of')} {allRows.length}
</span>
<div className="flex items-center gap-1">
<Button
@@ -593,6 +739,7 @@ export default function InstancesPage() {
</div>
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server } from 'lucide-
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useVMStatus, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
import { useTranslation } from 'react-i18next';
interface Props {
serverId: number;
@@ -64,6 +65,7 @@ function InfoRow({ label, value }: { label: string; value?: string | number | nu
}
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: status } = useVMStatus(serverId, vmid, type, node);
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
@@ -81,28 +83,28 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
{/* Metric cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="CPU"
label={t('common.cpu')}
value={formatPercent(cpuPercent)}
subtitle={config?.cores ? `${config.cores} cores` : undefined}
subtitle={config?.cores ? `${config.cores} ${t('common.cores')}` : undefined}
percent={cpuPercent}
icon={Cpu}
/>
<MetricCard
label="Memory"
label={t('common.memory')}
value={status ? formatBytes(status.mem) : '—'}
subtitle={status ? `of ${formatBytes(status.maxmem)}` : undefined}
subtitle={status ? `${t('common.of')} ${formatBytes(status.maxmem)}` : undefined}
percent={memPercent}
icon={MemoryStick}
/>
<MetricCard
label="Disk"
label={t('common.disk')}
value={status ? formatBytes(status.disk) : '—'}
subtitle={status ? `of ${formatBytes(status.maxdisk)}` : undefined}
subtitle={status ? `${t('common.of')} ${formatBytes(status.maxdisk)}` : undefined}
percent={diskPercent}
icon={HardDrive}
/>
<MetricCard
label="Uptime"
label={t('common.uptime')}
value={status?.uptime ? formatUptime(status.uptime) : '—'}
icon={Clock}
/>
@@ -114,19 +116,19 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
Instance Info
{t('common.instance_info')}
</CardTitle>
</CardHeader>
<CardContent>
<InfoRow label="VMID" value={vmid} />
<InfoRow label="Node" value={node} />
<InfoRow label="Type" value={type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'} />
<InfoRow label="vCPU" value={config?.cores} />
<InfoRow label="Memory" value={config?.memory ? `${config.memory} MB` : undefined} />
<InfoRow label="OS Type" value={config?.ostype as string} />
<InfoRow label="Boot Order" value={config?.boot as string} />
<InfoRow label="QEMU Agent" value={config?.agent ? 'Enabled' : 'Disabled'} />
<InfoRow label="Start on Boot" value={config?.onboot ? 'Yes' : 'No'} />
<InfoRow label={t('common.vmid')} value={vmid} />
<InfoRow label={t('common.node')} value={node} />
<InfoRow label={t('common.type')} value={type === 'qemu' ? t('common.qemu_virtual_machine') : t('common.lxc_container')} />
<InfoRow label={t('common.vcpu')} value={config?.cores} />
<InfoRow label={t('common.memory')} value={config?.memory ? `${config.memory} MB` : undefined} />
<InfoRow label={t('common.os_type')} value={config?.ostype as string} />
<InfoRow label={t('common.boot_order')} value={config?.boot as string} />
<InfoRow label={t('common.qemu_agent')} value={config?.agent ? t('common.enabled') : t('common.disabled')} />
<InfoRow label={t('common.start_on_boot')} value={config?.onboot ? t('common.yes') : t('common.no')} />
</CardContent>
</Card>
@@ -135,22 +137,22 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
Network
{t('common.network')}
</CardTitle>
</CardHeader>
<CardContent>
{primaryIP && <InfoRow label="Primary IP" value={primaryIP.address} />}
{primaryIP && <InfoRow label={t('common.primary_ip')} value={primaryIP.address} />}
{status?.netin !== undefined && (
<InfoRow label="Network In" value={formatBytes(status.netin)} />
<InfoRow label={t('common.network_in')} value={formatBytes(status.netin)} />
)}
{status?.netout !== undefined && (
<InfoRow label="Network Out" value={formatBytes(status.netout)} />
<InfoRow label={t('common.network_out')} value={formatBytes(status.netout)} />
)}
{config?.net0 && <InfoRow label="net0" value={String(config.net0)} />}
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Interfaces</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('common.interfaces')}</p>
{ifaces.interfaces.map((iface) => (
<div key={iface.name} className="rounded-md border p-2 text-xs">
<div className="flex items-center gap-2 font-medium">

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -15,6 +16,7 @@ interface Props {
}
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type);
const resizeDisk = useResizeDisk(serverId, vmid, type);
@@ -124,7 +126,7 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
id="disk-device"
value={diskDevice}
onChange={(e) => setDiskDevice(e.target.value)}
placeholder="scsi0"
placeholder={t('common.placeholder_disk')}
/>
</div>
<div>
@@ -135,7 +137,7 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
min={1}
value={diskSize}
onChange={(e) => setDiskSize(e.target.value)}
placeholder="e.g. 100"
placeholder={t('common.placeholder_disk_size')}
/>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, RotateCcw, Loader2, Camera } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -36,6 +37,7 @@ interface Props {
}
export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data, isLoading } = useSnapshots(serverId, vmid, type, node);
const createSnap = useCreateSnapshot(serverId, vmid, type);
const deleteSnap = useDeleteSnapshot(serverId, vmid, type, node);
@@ -110,17 +112,17 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
) : snapshots.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Camera className="mb-2 h-8 w-8 opacity-40" />
<p className="text-sm">No snapshots</p>
<p className="text-sm">{t('instances.no_snapshots')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Date</TableHead>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.description')}</TableHead>
<TableHead>{t('common.date')}</TableHead>
<TableHead>RAM</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
<TableHead className="w-[100px]">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -149,7 +151,7 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
size="icon"
className="h-7 w-7"
onClick={() => setRollbackTarget(snap.name)}
title="Rollback"
title={t('instances.rollback')}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
@@ -158,7 +160,7 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(snap.name)}
title="Delete"
title={t('instances.delete_snapshot')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -185,16 +187,16 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
id="snap-name"
value={snapName}
onChange={(e) => setSnapName(e.target.value)}
placeholder="e.g. before-update"
placeholder={t('common.placeholder_snapshot_name')}
/>
</div>
<div>
<Label htmlFor="snap-desc">Description (optional)</Label>
<Label htmlFor="snap-desc">{t('common.description')} (optional)</Label>
<Input
id="snap-desc"
value={snapDesc}
onChange={(e) => setSnapDesc(e.target.value)}
placeholder="Description"
placeholder={t('common.description')}
/>
</div>
{type === 'qemu' && (

View File

@@ -57,9 +57,9 @@ export default function IPAMNetworkDetailPage() {
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.network_info')}</CardTitle></CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<InfoRow label="Gateway" value={network.gateway || '—'} />
<InfoRow label="VLAN" value={network.vlan_id ? String(network.vlan_id) : '—'} />
<InfoRow label="DNS" value={network.dns_primary || '—'} />
<InfoRow label={t('common.gateway')} value={network.gateway || '—'} />
<InfoRow label={t('common.vlan')} value={network.vlan_id ? String(network.vlan_id) : '—'} />
<InfoRow label={t('common.dns')} value={network.dns_primary || '—'} />
<InfoRow label={t('ipam.server')} value={network.server_name || '—'} />
<InfoRow label={t('ipam.bridge')} value={network.proxmox_bridge || '—'} />
{network.description && <InfoRow label={t('ipam.description')} value={network.description} />}

View File

@@ -110,7 +110,7 @@ function ServerFormDialog({
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<Label>{t('common.name')}</Label>
<Input value={form.name} onChange={e => set('name', e.target.value)} className="mt-1" placeholder="My Proxmox Server" />
<Input value={form.name} onChange={e => set('name', e.target.value)} className="mt-1" placeholder={t('common.my_proxmox_server')} />
</div>
<div>
<Label>{t('nodes.hostname')}</Label>
@@ -122,7 +122,7 @@ function ServerFormDialog({
</div>
<div>
<Label>{t('nodes.api_user')}</Label>
<Input value={form.api_user} onChange={e => set('api_user', e.target.value)} className="mt-1" placeholder="root@pam" />
<Input value={form.api_user} onChange={e => set('api_user', e.target.value)} className="mt-1" placeholder={t('common.placeholder_root_pam')} />
</div>
</div>

View File

@@ -1,17 +1,18 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, X, ClipboardList, RefreshCw } from 'lucide-react';
import { Trash2, X, ClipboardList, RefreshCw, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAllTasks, useCancelTask, useClearCompletedTasks } from '@/hooks/use-tasks';
export default function TasksPage() {
const { t } = useTranslation();
const [statusFilter, setStatusFilter] = useState('all');
const { data: tasksData, refetch } = useAllTasks();
const { data: tasksData, refetch, isLoading } = useAllTasks();
const tasks = tasksData?.tasks ?? [];
const cancelTask = useCancelTask();
const clearCompleted = useClearCompletedTasks();
@@ -31,6 +32,9 @@ export default function TasksPage() {
}
};
const isActive = (status: string) =>
status === 'running' || status === 'queued' || status === 'in_progress' || status === 'pending';
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -39,8 +43,8 @@ export default function TasksPage() {
<h1 className="text-2xl font-bold">{t('nav.tasks')}</h1>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />{t('common.refresh')}
</Button>
<Button variant="outline" size="sm" onClick={() => clearCompleted.mutate()}>
<Trash2 className="h-4 w-4 mr-1" />{t('tasks.clear_completed')}
@@ -68,48 +72,88 @@ export default function TasksPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.type')}</TableHead>
<TableHead className="w-[110px]">{t('common.status')}</TableHead>
<TableHead className="w-[110px]">{t('common.type')}</TableHead>
<TableHead>{t('tasks.description')}</TableHead>
<TableHead>{t('tasks.server')}</TableHead>
<TableHead>{t('tasks.node')}</TableHead>
<TableHead>{t('common.user')}</TableHead>
<TableHead>{t('tasks.started')}</TableHead>
<TableHead>{t('tasks.progress')}</TableHead>
<TableHead />
<TableHead className="w-[200px]">{t('tasks.step_message')}</TableHead>
<TableHead className="w-[100px]">{t('tasks.server')}</TableHead>
<TableHead className="w-[90px]">{t('tasks.node')}</TableHead>
<TableHead className="w-[80px]">{t('common.user')}</TableHead>
<TableHead className="w-[140px]">{t('tasks.started')}</TableHead>
<TableHead className="w-[120px]">{t('tasks.progress')}</TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((task, i) => (
<TableRow key={task.id ?? task.upid ?? i}>
<TableCell><Badge variant={statusColor(task.status) as any}>{task.status}</Badge></TableCell>
{filtered.map((task, i) => {
const active = isActive(task.status);
const stepMsg = String(task.step ?? task.current_step ?? task.message ?? '');
const errMsg = String(task.error_message ?? task.exitstatus ?? '');
const displayMsg = task.status === 'failed' ? (errMsg || stepMsg) : (active ? stepMsg : '');
return (
<TableRow key={task.id ?? task.upid ?? i} className={active ? 'bg-blue-500/5' : ''}>
<TableCell>
<div className="flex items-center gap-1.5">
{active && <Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />}
<Badge variant={statusColor(task.status) as any}>{task.status}</Badge>
</div>
</TableCell>
<TableCell className="text-xs font-mono">{task.type || String(task.task_type ?? '') || '—'}</TableCell>
<TableCell className="text-xs max-w-[250px] truncate">{String(task.description ?? '') || task.upid || '—'}</TableCell>
<TableCell className="text-xs max-w-[200px]">
<span className="line-clamp-2">{String(task.description ?? '') || task.upid || '—'}</span>
</TableCell>
<TableCell className="text-xs">
{displayMsg ? (
<Tooltip>
<TooltipTrigger>
<span className={`line-clamp-2 ${task.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}`}>
{displayMsg}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-sm break-all">{displayMsg}</TooltipContent>
</Tooltip>
) : '—'}
</TableCell>
<TableCell className="text-xs">{task.server_name || '—'}</TableCell>
<TableCell className="text-xs">{task.node || '—'}</TableCell>
<TableCell className="text-xs">{task.user || '—'}</TableCell>
<TableCell className="text-xs whitespace-nowrap">{task.starttime ? new Date(task.starttime * 1000).toLocaleString() : task.created_at ? new Date(task.created_at).toLocaleString() : '—'}</TableCell>
<TableCell className="text-xs whitespace-nowrap">{task.starttime ? new Date(task.starttime * 1000).toLocaleString() : task.created_at ? new Date(String(task.created_at)).toLocaleString() : '—'}</TableCell>
<TableCell className="text-xs">
{task.progress != null ? (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${task.progress}%` }} />
<div
className={`h-full rounded-full transition-all ${task.status === 'failed' ? 'bg-destructive' : 'bg-blue-500'}`}
style={{ width: `${task.progress}%` }}
/>
</div>
<span>{Number(task.progress ?? 0)}%</span>
</div>
) : '—'}
</TableCell>
<TableCell>
{(task.status === 'running' || task.status === 'queued' || task.status === 'in_progress') && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => cancelTask.mutate(Number(task.id ?? 0))}>
{active && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => cancelTask.mutate(Number(task.id ?? 0))}
disabled={cancelTask.isPending}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
);
})}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
<TableRow>
<TableCell colSpan={10} className="text-center text-muted-foreground py-8">
{t('common.no_data')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>

View File

@@ -1,5 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { apiClient } from '@/lib/api-client';
import { getTasksWebSocket } from '@/lib/websocket';
import type { VMInstance, Snapshot, VMConfig } from '@/types';
// ==================== Query Keys ====================
@@ -24,6 +26,26 @@ export function useVirtualMachines() {
});
}
/** Subscribes to WebSocket vm_status_update events and patches the VM list cache in real time. */
export function useVMStatusSync() {
const qc = useQueryClient();
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const unsub = ws.onType('vm_status_update', (data: unknown) => {
const msg = data as { vmid: number; server_id: number; status: string };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
vm.server_id === msg.server_id && vm.vmid === msg.vmid
? { ...vm, status: msg.status as VMInstance['status'] }
: vm
)
);
});
return () => unsub();
}, [qc]);
}
export function useAllResources() {
return useQuery({
queryKey: vmKeys.resourcesAll,
@@ -153,16 +175,55 @@ interface PowerActionResult {
export function usePowerAction(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }>({
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }, { previousVMs: VMInstance[] | undefined }>({
mutationFn: ({ action, force }) => {
const params = new URLSearchParams({ node });
if (force) params.set('force', '1');
return apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/${action}?${params}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
onMutate: async ({ action }) => {
await qc.cancelQueries({ queryKey: vmKeys.all });
const previousVMs = qc.getQueryData<VMInstance[]>(vmKeys.all);
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
vm.server_id === serverId && vm.vmid === vmid
? { ...vm, status: expectedStatus as VMInstance['status'] }
: vm
)
);
return { previousVMs };
},
onError: (_err, _vars, context) => {
if (context?.previousVMs) {
qc.setQueryData(vmKeys.all, context.previousVMs);
}
},
onSuccess: (_data, { action }) => {
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
let attempts = 0;
const maxAttempts = 12; // 12 * 3s = 36s max
const poll = () => {
attempts++;
apiClient
.get<VMStatusResponse>(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`)
.then((liveStatus) => {
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
vm.server_id === serverId && vm.vmid === vmid
? { ...vm, status: liveStatus.status as VMInstance['status'] }
: vm
)
);
if (liveStatus.status !== expectedStatus && attempts < maxAttempts) {
setTimeout(poll, 3000);
}
})
.catch(() => {
if (attempts < maxAttempts) setTimeout(poll, 3000);
});
};
setTimeout(poll, 3000);
},
});
}
@@ -227,3 +288,118 @@ export function useUpdateConfig(serverId: number, vmid: number, type: string) {
},
});
}
// ==================== Clone / Reinstall ====================
interface CloneRequest {
new_name: string;
full?: boolean;
target_node?: string;
target_storage?: string;
description?: string;
}
export function useCloneInstance(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<{ status: string; new_vmid: number; upid: string }, Error, CloneRequest>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/clone?node=${node}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
export function useReinstallInstance(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string; vmid: number; upid: string }, Error, void>({
mutationFn: () =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/reinstall?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
},
});
}
// ==================== Change Password ====================
export function useChangePassword(serverId: number, vmid: number, type: string, node: string) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<{ status: string }, Error, { username: string; password: string }>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/change-password?node=${node}`, body),
});
}
// ==================== Notes ====================
export function useUpdateNotes(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<{ status: string }, Error, { description: string }>({
mutationFn: (body) =>
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/notes?node=${node}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.all });
},
});
}
// ==================== Execute Command (qemu-agent) ====================
interface ExecResult {
success?: boolean;
stdout?: string;
stderr?: string;
exit_code?: number;
error?: string;
}
export function useExecuteCommand(serverId: number, vmid: number) {
return useMutation<ExecResult, Error, { node: string; command: string; timeout?: number }>({
mutationFn: ({ node, command, timeout = 30 }) => {
const params = new URLSearchParams({ node, command, timeout: String(timeout) });
return apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/execute?${params}`);
},
});
}
// ==================== ISO mount/unmount (KVM only) ====================
export interface IsoItem {
volid: string;
storage: string;
size?: number;
format?: string;
name?: string;
}
export function useNodeIsos(serverId: number, node: string, enabled = true) {
return useQuery<{ isos: IsoItem[] }>({
queryKey: ['isos', serverId, node],
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/node/${node}/isos`),
enabled: enabled && !!node,
});
}
export function useAttachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string }, Error, { volid: string; device?: string }>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/attach?node=${node}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
},
});
}
export function useDetachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string }, Error, { device?: string }>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/detach?node=${node}`, body || {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
},
});
}

View File

@@ -7,12 +7,17 @@ export const taskKeys = {
bulk: (limit?: number) => ['bulk-tasks', limit] as const,
};
interface TaskItem {
export interface TaskItem {
id?: number;
upid?: string;
type: string;
kind?: 'bulk' | 'proxmox' | 'deploy';
status: string;
action?: string;
description?: string;
progress_percent?: number;
current_step?: string;
name?: string;
items_count?: number;
completed_count?: number;
failed_count?: number;
@@ -22,7 +27,10 @@ interface TaskItem {
node?: string;
server_id?: number;
server_name?: string;
vmid?: number;
vm_type?: string;
exitstatus?: string;
exit_status?: string;
starttime?: number;
endtime?: number;
user?: string;

View File

@@ -88,8 +88,7 @@ function SidebarContent() {
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-14 items-center gap-2 px-4">
<img src="/logo.png" alt="PVEmanager" className="h-8 w-auto" />
<span className="text-base font-semibold tracking-tight">PVEmanager</span>
<img src="/logo.png" alt="PVEmanager" className="h-12 w-auto" />
</div>
<Separator />

View File

@@ -18,6 +18,7 @@ export class WebSocketManager {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private subscribers = new Map<string, Set<MessageHandler>>();
private typeListeners = new Map<string, Set<MessageHandler>>();
private _isConnected = false;
constructor(options: WSOptions) {
@@ -56,6 +57,12 @@ export class WebSocketManager {
const data = JSON.parse(event.data);
this.options.onMessage?.(data);
// Route to type listeners (for direct user messages without channel)
if (data.type) {
const typeHandlers = this.typeListeners.get(data.type);
typeHandlers?.forEach((handler) => handler(data));
}
// Route to channel subscribers
if (data.channel) {
const handlers = this.subscribers.get(data.channel);
@@ -112,6 +119,17 @@ export class WebSocketManager {
};
}
/** Listen to messages by their `type` field (for direct user messages without a channel). */
onType(type: string, handler: MessageHandler) {
if (!this.typeListeners.has(type)) {
this.typeListeners.set(type, new Set());
}
this.typeListeners.get(type)!.add(handler);
return () => {
this.typeListeners.get(type)?.delete(handler);
};
}
private tryReconnect() {
if (
!this.options.reconnect ||

View File

@@ -70,7 +70,50 @@
"stop": "Stop",
"restart": "Restart",
"snapshots": "Snapshots",
"suspend": "Suspend"
"suspend": "Suspend",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"uptime": "Uptime",
"vmid": "VMID",
"boot_order": "Boot Order",
"qemu_agent": "QEMU Agent",
"start_on_boot": "Start on Boot",
"primary_ip": "Primary IP",
"network_in": "Network In",
"network_out": "Network Out",
"rollback": "Rollback",
"description": "Description",
"os_type": "OS Type",
"vcpu": "vCPU",
"instance_info": "Instance Info",
"network": "Network",
"interfaces": "Interfaces",
"dns": "DNS",
"gateway": "Gateway",
"vlan": "VLAN",
"my_proxmox_server": "My Proxmox Server",
"enabled": "Enabled",
"disabled": "Disabled",
"yes": "Yes",
"no": "No",
"qemu_virtual_machine": "QEMU Virtual Machine",
"lxc_container": "LXC Container",
"root": "root",
"ubuntu": "ubuntu",
"my_vm": "my-vm-01",
"cores": "cores",
"of": "of",
"placeholder_root": "root",
"placeholder_ubuntu": "ubuntu",
"placeholder_vm_name": "my-vm-01",
"placeholder_ssh_key": "ssh-rsa AAAA...",
"placeholder_command": "ls -la /tmp",
"placeholder_disk_size": "e.g. 100",
"placeholder_snapshot_name": "e.g. before-update",
"placeholder_root_pam": "root@pam",
"placeholder_disk": "scsi0",
"placeholder_notes": "Описание / заметки об инстансе..."
},
"instances": {
"create": "Create Instance",
@@ -85,7 +128,15 @@
"overview": "Overview",
"graphs": "Graphs",
"networking": "Networking",
"destroy": "Destroy"
"destroy": "Destroy",
"task_activity": "Task Activity",
"status_creating": "Creating",
"vmid_pending": "pending",
"no_snapshots": "No snapshots",
"notes_saved": "Notes saved",
"before_update": "before-update",
"rollback": "Rollback",
"delete_snapshot": "Delete"
},
"wizard": {
"title": "Create Instance",
@@ -95,6 +146,25 @@
"step_confirm": "Confirm",
"select_server": "Select server",
"select_template": "Select template",
"no_templates": "No templates available",
"configure": "Configure",
"basic": "Basic",
"target_node": "Target Node",
"auto_select": "Auto select",
"start_after_create": "Start after creation",
"resources": "Resources",
"memory_mb": "Memory (MB)",
"disk_gb": "Disk (GB)",
"bridge": "Network Bridge",
"manual_ip": "Manual IP",
"ci_user": "Cloud-Init User",
"ci_password": "Cloud-Init Password",
"confirm": "Confirm",
"server_label": "Server",
"template_label": "Template",
"back": "Back",
"next": "Next",
"storage": "Storage",
"hostname": "Hostname",
"cpu": "CPU Cores",
"memory": "Memory (MB)",
@@ -103,7 +173,20 @@
"password": "Password",
"ssh_keys": "SSH Keys",
"deploy": "Deploy",
"deploy_queued": "Deploy task for \"{{name}}\" has been queued",
"deploy_success": "Instance \"{{name}}\" deployed successfully",
"deploy_failed": "Deployment failed",
"deploy_done": "Instance created successfully",
"deploy_wait": "Please wait, deployment in progress...",
"deploy_starting": "Starting...",
"deploying_title": "Deploying Instance",
"deploying": "Deploying...",
"status_pending": "Queued",
"status_running": "Running",
"status_completed": "Completed",
"status_failed": "Failed",
"go_to_instances": "Go to Instances",
"retry": "Retry",
"os_group": "OS Group",
"ipam_network": "IPAM Network",
"auto_allocate_ip": "Auto-allocate IP"
@@ -276,6 +359,7 @@
"failed": "Failed",
"tasks_count": "tasks",
"description": "Description",
"step_message": "Step / Message",
"server": "Server",
"node": "Node",
"started": "Started",

View File

@@ -70,7 +70,50 @@
"stop": "Остановить",
"restart": "Перезагрузить",
"snapshots": "Снимки",
"suspend": "Приостановить"
"suspend": "Приостановить",
"cpu": "CPU",
"memory": "Память",
"disk": "Диск",
"uptime": "Аптайм",
"vmid": "VMID",
"boot_order": "Порядок загрузки",
"qemu_agent": "QEMU агент",
"start_on_boot": "Запуск при загрузке",
"primary_ip": "Основной IP",
"network_in": "Входящий трафик",
"network_out": "Исходящий трафик",
"rollback": "Откатить",
"description": "Описание",
"os_type": "Тип ОС",
"vcpu": "vCPU",
"instance_info": "Информация об инстансе",
"network": "Сеть",
"interfaces": "Интерфейсы",
"dns": "DNS",
"gateway": "Шлюз",
"vlan": "VLAN",
"my_proxmox_server": "Мой Proxmox сервер",
"enabled": "Включено",
"disabled": "Выключено",
"yes": "Да",
"no": "Нет",
"qemu_virtual_machine": "Виртуальная машина QEMU",
"lxc_container": "LXC контейнер",
"root": "root",
"ubuntu": "ubuntu",
"my_vm": "my-vm-01",
"cores": "ядер",
"of": "из",
"placeholder_root": "root",
"placeholder_ubuntu": "ubuntu",
"placeholder_vm_name": "my-vm-01",
"placeholder_ssh_key": "ssh-rsa AAAA...",
"placeholder_command": "ls -la /tmp",
"placeholder_disk_size": "например 100",
"placeholder_snapshot_name": "например перед-обновлением",
"placeholder_root_pam": "root@pam",
"placeholder_disk": "scsi0",
"placeholder_notes": "Описание / заметки об инстансе..."
},
"instances": {
"create": "Создать инстанс",
@@ -85,7 +128,15 @@
"overview": "Обзор",
"graphs": "Графики",
"networking": "Сеть",
"destroy": "Уничтожить"
"destroy": "Уничтожить",
"task_activity": "Активность задач",
"status_creating": "Создание",
"vmid_pending": "ожидание",
"no_snapshots": "Нет снимков",
"notes_saved": "Заметки сохранены",
"before_update": "перед-обновлением",
"rollback": "Откатить",
"delete_snapshot": "Удалить"
},
"wizard": {
"title": "Создание инстанса",
@@ -95,6 +146,25 @@
"step_confirm": "Подтверждение",
"select_server": "Выберите сервер",
"select_template": "Выберите шаблон",
"no_templates": "Шаблоны не найдены",
"configure": "Настройка",
"basic": "Основное",
"target_node": "Целевой узел",
"auto_select": "Авто выбор",
"start_after_create": "Запустить после создания",
"resources": "Ресурсы",
"memory_mb": "Память (МБ)",
"disk_gb": "Диск (ГБ)",
"bridge": "Сетевой мост",
"manual_ip": "Ручной ввод IP",
"ci_user": "Cloud-Init пользователь",
"ci_password": "Cloud-Init пароль",
"confirm": "Подтверждение",
"server_label": "Сервер",
"template_label": "Шаблон",
"back": "Назад",
"next": "Далее",
"storage": "Хранилище",
"hostname": "Имя хоста",
"cpu": "Ядра CPU",
"memory": "Память (МБ)",
@@ -103,7 +173,20 @@
"password": "Пароль",
"ssh_keys": "SSH ключи",
"deploy": "Развернуть",
"deploy_queued": "Задача создания \"{{name}}\" добавлена в очередь",
"deploy_success": "Инстанс \"{{name}}\" успешно развёрнут",
"deploy_failed": "Ошибка развёртывания",
"deploy_done": "Инстанс успешно создан",
"deploy_wait": "Пожалуйста, подождите — идёт развёртывание...",
"deploy_starting": "Запуск...",
"deploying_title": "Развёртывание инстанса",
"deploying": "Развертывание...",
"status_pending": "В очереди",
"status_running": "Выполняется",
"status_completed": "Завершено",
"status_failed": "Ошибка",
"go_to_instances": "Перейти к инстансам",
"retry": "Повторить",
"os_group": "Группа ОС",
"ipam_network": "Сеть IPAM",
"auto_allocate_ip": "Авто-выделение IP"
@@ -276,6 +359,7 @@
"failed": "Ошибка",
"tasks_count": "задач",
"description": "Описание",
"step_message": "Шаг / Сообщение",
"server": "Сервер",
"node": "Узел",
"started": "Запущено",

View File

@@ -0,0 +1,58 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface DeployTask {
id: number;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed';
step: string | null;
progress: number;
vmid: number | null;
node: string | null;
error_message: string | null;
created_at: number;
}
interface DeployTasksState {
tasks: DeployTask[];
addTask: (task: Omit<DeployTask, 'created_at'>) => void;
updateTask: (id: number, update: Partial<DeployTask>) => void;
removeTask: (id: number) => void;
clearCompleted: () => void;
}
export const useDeployTasksStore = create<DeployTasksState>()(
persist(
(set) => ({
tasks: [],
addTask: (task) =>
set((state) => ({
tasks: [
...state.tasks.filter((t) => t.id !== task.id),
{ ...task, created_at: Date.now() },
],
})),
updateTask: (id, update) =>
set((state) => ({
tasks: state.tasks.map((t) => (t.id === id ? { ...t, ...update } : t)),
})),
removeTask: (id) =>
set((state) => ({
tasks: state.tasks.filter((t) => t.id !== id),
})),
clearCompleted: () =>
set((state) => ({
tasks: state.tasks.filter(
(t) => t.status !== 'completed' && t.status !== 'failed',
),
})),
}),
{
name: 'pve-deploy-tasks',
partialize: (state) => ({
// Don't persist completed tasks
tasks: state.tasks.filter((t) => t.status !== 'completed'),
}),
},
),
);

View File

@@ -61,7 +61,7 @@ export interface ProxmoxServerCreate {
// ==================== VM / Container Types ====================
export type VMType = 'qemu' | 'lxc';
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown';
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown' | 'creating';
export interface VMInstance {
vmid: number;
@@ -82,6 +82,9 @@ export interface VMInstance {
ip_address?: string;
tags?: string;
template?: boolean;
description?: string;
/** Ghost row marker — present only for in-progress deploy tasks */
_deployTaskId?: number;
}
export interface VMConfig {