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:
57
.github/workflows/backend-tests.yml
vendored
Normal file
57
.github/workflows/backend-tests.yml
vendored
Normal 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
|
||||
147
BUGFIX_REPORT.md
147
BUGFIX_REPORT.md
@@ -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
103
CHANGELOG.ru.md
Normal 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
163
README.ru.md
Normal file
@@ -0,0 +1,163 @@
|
||||
<div align="center">
|
||||
|
||||
# PVEmanager
|
||||
|
||||
**Современная веб-панель для управления серверами Proxmox, виртуальными машинами и контейнерами LXC**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
[](backend/)
|
||||
[](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>
|
||||
@@ -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 - выполнение команды
|
||||
|
||||
Все операции полностью интегрированы и готовы к использованию.
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) ====================
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Ошибка"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}')>"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
17
backend/pytest.ini
Normal 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
|
||||
5
backend/requirements-dev.txt
Normal file
5
backend/requirements-dev.txt
Normal 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
87
backend/tests/README.md
Normal 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
|
||||
```
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
92
backend/tests/conftest.py
Normal file
92
backend/tests/conftest.py
Normal 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
113
backend/tests/test_auth.py
Normal 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"
|
||||
69
backend/tests/test_crypto.py
Normal file
69
backend/tests/test_crypto.py
Normal 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
120
backend/tests/test_rbac.py
Normal 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)
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
498
frontend/src/features/instances/InstanceActionDialogs.tsx
Normal file
498
frontend/src/features/instances/InstanceActionDialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Запущено",
|
||||
|
||||
58
frontend/src/stores/deploy-tasks-store.ts
Normal file
58
frontend/src/stores/deploy-tasks-store.ts
Normal 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'),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user