Compare commits
32 Commits
v1.16.1
...
6796903712
| Author | SHA1 | Date | |
|---|---|---|---|
| 6796903712 | |||
| c9d8e8207f | |||
| f9425082f7 | |||
| dc53c415db | |||
| 7d60afb9fa | |||
| 30e92da8b5 | |||
|
|
4f290b92fb | ||
|
|
295d3fc06e | ||
|
|
31d17fca61 | ||
|
|
c768da93ee | ||
|
|
f0ae1770f7 | ||
|
|
5a4dfcdaea | ||
|
|
851e5854b2 | ||
|
|
06bb127387 | ||
|
|
943207b6b7 | ||
|
|
619d08b674 | ||
|
|
3aa37991f7 | ||
|
|
917cc0a5bb | ||
|
|
da8bbce62a | ||
|
|
e7291bf23a | ||
|
|
932fcce768 | ||
|
|
d99e67a60e | ||
| f6d9932849 | |||
| 0db2f340f6 | |||
| 5bb349564f | |||
| 67fe01e9b1 | |||
|
|
3225214e43 | ||
|
|
e4c921022b | ||
|
|
73493d7ef2 | ||
|
|
9d57e388cd | ||
|
|
5f0a38e03d | ||
| 8cbba05dac |
66
.github/workflows/sonarcloud.yml
vendored
Normal file
66
.github/workflows/sonarcloud.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
name: SonarCloud Analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# SonarCloud needs full history for accurate blame/new-code detection
|
||||
fetch-depth: 0
|
||||
|
||||
# --- Backend: generate coverage.xml so Sonar can report Python coverage ---
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-dev.txt
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run pytest with coverage
|
||||
working-directory: backend
|
||||
run: |
|
||||
python -m pytest \
|
||||
--cov=app \
|
||||
--cov-report=xml \
|
||||
-p no:warnings
|
||||
# Don't fail the whole Sonar run just because a test flaked;
|
||||
# Sonar will simply show stale/no coverage for this run instead.
|
||||
continue-on-error: true
|
||||
|
||||
# --- Frontend: optional ESLint report for Sonar's JS/TS rules ---
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint (JSON report for Sonar)
|
||||
working-directory: frontend
|
||||
run: npx eslint . --format json --output-file eslint-report.json
|
||||
continue-on-error: true
|
||||
|
||||
# --- SonarCloud scan ---
|
||||
- name: SonarCloud Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -87,3 +87,6 @@ docs/superpowers/
|
||||
|
||||
# Планы разработки не коммитим в репозиторий
|
||||
docs/plans/
|
||||
|
||||
# Сводки сессий (локальные, не для репозитория)
|
||||
docs/summaries/
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -4,6 +4,46 @@ All notable changes to PVEmanager will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🖥️ LXC console — failed termproxy tasks and the black screen
|
||||
|
||||
- **⚠️ Breaking change: `GET /api/{server_id}/container/{vmid}/terminal` has been removed.** The WebSocket endpoint `/proxmox/ws/terminal/{server_id}/{node}/{vmid}` creates the termproxy session itself, so the REST call was unused by the panel and left an orphaned session on the node on every invocation. External clients should open the WebSocket directly
|
||||
- **Opening an LXC console no longer leaves a failed task on the node** — `failed waiting for client: timed out` / `TASK ERROR: command '/usr/bin/termproxy 5900 ... vzctlconsole<id>' failed`. The console page created a termproxy session over REST (using only the `node` field of the response and discarding port and ticket), then a second one over the WebSocket endpoint and attached to that one. Nobody ever attached to the first, so Proxmox waited ~10 s for a client and failed the task
|
||||
- **Console no longer shows a black screen with a "connected" badge in dev** — React StrictMode mounts the effect twice and the connect helpers are async, so effect cleanup ran before they had assigned `wsRef`/`termRef` and cleaned up nothing. Two xterm instances ended up in the same container: the one filling the viewport was orphaned while the live one was pushed out of the overflow-hidden area. Sessions now carry a cancellation guard, and both mount and the reconnect button go through a single entry point that tears the previous session down first. The same guard was applied to the node shell page
|
||||
|
||||
---
|
||||
|
||||
### 🔍 NFS storage dialog — scan for available exports
|
||||
|
||||
- **Added a "scan exports" button** next to the server field when adding an NFS storage — calls Proxmox's `GET /nodes/{node}/scan/nfs` and turns the export path field from a free-text input into a select populated with the exports actually found on the target server, instead of requiring the path to be typed by hand and guessed
|
||||
- New endpoint `GET /api/backups/scan-nfs/{server_id}?server=<ip>` on the backend, running the scan through the server's first available node
|
||||
|
||||
---
|
||||
|
||||
## [v1.17.0] - 2026-08-20
|
||||
|
||||
### 💾 Move LXC container disks to another storage
|
||||
|
||||
- **Disk move now works for LXC**, not just QEMU VMs — the Move disk action was previously hardcoded to the VM `move_disk` API and unreachable for containers (no Hardware tab, and the disk-row detector only matched VM device names). `rootfs`/`mp\d+` are now recognized as LXC disk devices, and a **Move disk** card was added to the Settings tab for LXC instances
|
||||
- New endpoint `POST /api/{server_id}/container/{vmid}/disk/move` calls Proxmox's `lxc/{vmid}/move_volume`; the target storage list is filtered by supported content type (`rootdir` for LXC, `images` for VM) instead of showing every storage
|
||||
- **Blocked on running containers with a guided flow** — Proxmox rejects `move_volume` on a running LXC (`move_disk` works live for QEMU, `move_volume` does not for LXC). Attempting it now opens a confirmation dialog that stops the container, moves the disk, and restarts it, showing progress at each step; the container is always restarted even if the move itself fails. VMs and already-stopped containers still get a plain confirm dialog
|
||||
|
||||
### 📊 Instance list — collapsible metrics panel
|
||||
|
||||
- **CPU/RAM/Disk/Net/Disk I/O/Uptime moved out of the always-visible table columns** into a per-row panel that expands when you click the instance name, matching the disclosure pattern from VMmanager. The panel adds a **Parameters** link straight into the instance's Settings tab and clearer stat cards for network/disk throughput
|
||||
- vCPU/RAM bars widened and enlarged (bar width and label/detail font size) for easier at-a-glance reading in the collapsed panel
|
||||
|
||||
### ✨ Network topology page
|
||||
|
||||
- **New `/topology` page** — an interactive map of the infrastructure: panel → cluster → Proxmox node → VM/LXC, rendered with React Flow. Node cards carry live CPU/RAM meters, guests show status, IP, bridge/VLAN and tags
|
||||
- **Filters** — narrow the graph to one connection, filter guests by status (all / running / stopped), and collapse a node's guests into a single badge above a configurable threshold (remembered per browser); expand a collapsed node individually
|
||||
- **Canvas tools** — zoom, pan, fit-to-screen, fullscreen, minimap, legend and PNG/SVG export of the whole graph
|
||||
- **New API `GET /proxmox/api/network/topology`** — one aggregated request instead of fanning out over servers/nodes/SDN/VMs; gated on `network:view`, honours workspace scoping and per-owner VM isolation. Unreachable connections degrade to cached data plus a warning instead of failing the page
|
||||
- **`netN` parser** — guest NIC strings are now parsed (bridge, VLAN tag, MAC, model, firewall, rate, MTU) for both the QEMU and LXC dialects, available via `include_nics=true`
|
||||
|
||||
---
|
||||
|
||||
## [v1.16.1] - 2026-08-17
|
||||
|
||||
### 🎨 UI
|
||||
|
||||
@@ -4,6 +4,46 @@
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🖥️ Консоль LXC — ошибки termproxy и чёрный экран
|
||||
|
||||
- **⚠️ Ломающее изменение: эндпоинт `GET /api/{server_id}/container/{vmid}/terminal` удалён.** WebSocket-эндпоинт `/proxmox/ws/terminal/{server_id}/{node}/{vmid}` создаёт termproxy-сессию сам, поэтому REST-вызов не использовался панелью и при каждом вызове оставлял на ноде висячую сессию. Внешним клиентам следует открывать WebSocket напрямую
|
||||
- **Открытие консоли LXC больше не оставляет на ноде задачу с ошибкой** — `failed waiting for client: timed out` / `TASK ERROR: command '/usr/bin/termproxy 5900 ... vzctlconsole<id>' failed`. Страница консоли создавала termproxy-сессию через REST (используя из ответа только поле `node` и выбрасывая порт и тикет), затем вторую через WebSocket-эндпоинт и подключалась ко второй. К первой не подключался никто — Proxmox ждал клиента ~10 секунд и завершал задачу ошибкой
|
||||
- **Консоль больше не показывает чёрный экран при статусе «connected» в dev-режиме** — React StrictMode монтирует эффект дважды, а connect-функции асинхронны, поэтому cleanup эффекта срабатывал до того, как они записывали `wsRef`/`termRef`, и ничего не чистил. В одном контейнере оказывалось два xterm: видимый был осиротевшим, а живой уходил за границу области с `overflow-hidden`. Теперь у сессии есть флаг отмены, а монтирование и кнопка «Переподключиться» идут через единую точку входа, которая сначала гасит предыдущую сессию. Тот же флаг применён к странице shell ноды
|
||||
|
||||
---
|
||||
|
||||
### 🔍 Диалог NFS-хранилища — поиск доступных экспортов
|
||||
|
||||
- **Добавлена кнопка поиска экспортов** рядом с полем «Сервер» при добавлении NFS-хранилища — вызывает `GET /nodes/{node}/scan/nfs` Proxmox и превращает поле пути экспорта из текстового ввода в выпадающий список с реальными экспортами, найденными на целевом сервере, вместо ручного набора пути наугад
|
||||
- Новый эндпоинт `GET /api/backups/scan-nfs/{server_id}?server=<ip>` на бэкенде — сканирование выполняется через первую доступную ноду сервера
|
||||
|
||||
---
|
||||
|
||||
## [v1.17.0] - 2026-08-20
|
||||
|
||||
### 💾 Перенос дисков LXC-контейнера на другое хранилище
|
||||
|
||||
- **Перенос диска теперь работает для LXC**, а не только для QEMU-ВМ — раньше действие было жёстко привязано к VM-эндпоинту `move_disk` и было недостижимо для контейнеров (нет вкладки «Оборудование», а детектор строк диска распознавал только имена устройств ВМ). `rootfs`/`mp\d+` теперь распознаются как дисковые устройства LXC, на вкладку «Настройки» для LXC-инстансов добавлена карточка **«Перенести диск»**
|
||||
- Новый эндпоинт `POST /api/{server_id}/container/{vmid}/disk/move` вызывает `lxc/{vmid}/move_volume` Proxmox; список целевых хранилищ теперь фильтруется по поддерживаемому типу содержимого (`rootdir` для LXC, `images` для ВМ) вместо показа всех хранилищ подряд
|
||||
- **Перенос заблокирован на запущенных контейнерах — с пошаговым сценарием вместо ошибки** — Proxmox отклоняет `move_volume` на работающем LXC (`move_disk` у QEMU работает «на живую», `move_volume` у LXC — нет). Теперь такая попытка открывает диалог подтверждения, который останавливает контейнер, переносит диск и запускает его обратно, показывая прогресс на каждом шаге; контейнер перезапускается в любом случае, даже если сам перенос завершился ошибкой. Для ВМ и уже остановленных контейнеров показывается обычный диалог подтверждения
|
||||
|
||||
### 📊 Список инстансов — сворачиваемая панель метрик
|
||||
|
||||
- **CPU/RAM/Диск/Сеть/Диск I/O/Аптайм убраны из всегда видимых колонок таблицы** в раскрывающуюся панель под строкой, которая открывается кликом по имени инстанса — по образцу дисклоужер-паттерна из VMmanager. В панели появилась ссылка **«Параметры»**, ведущая прямо на вкладку настроек инстанса, и более понятные карточки для скорости сети/диска
|
||||
- Полосы vCPU/RAM расширены и увеличены (ширина полосы и размер шрифта подписи/деталей) для более быстрого визуального восприятия в свёрнутой панели
|
||||
|
||||
### ✨ Страница «Топология сети»
|
||||
|
||||
- **Новая страница `/topology`** — интерактивная карта инфраструктуры: панель → кластер → нода Proxmox → VM/LXC на базе React Flow. На карточках нод — живые индикаторы CPU/RAM, у гостей видны статус, IP, bridge/VLAN и теги
|
||||
- **Фильтры** — сужение графа до одного подключения, фильтр гостей по статусу (все / запущенные / остановленные) и схлопывание гостей ноды в один узел при превышении настраиваемого порога (запоминается в браузере); свернувшуюся ноду можно раскрыть по отдельности
|
||||
- **Инструменты холста** — зум, панорама, вписать в экран, полноэкранный режим, миникарта, легенда и экспорт всего графа в PNG/SVG
|
||||
- **Новый API `GET /proxmox/api/network/topology`** — один агрегированный запрос вместо обхода серверов/нод/SDN/VM; требует право `network:view`, учитывает рабочие области и изоляцию VM по владельцу. Недоступное подключение отдаёт данные из кэша с предупреждением, а не ломает страницу
|
||||
- **Парсер `netN`** — строки сетевых карт гостей теперь разбираются (bridge, VLAN-тег, MAC, модель, firewall, rate, MTU) для обоих диалектов, QEMU и LXC; доступно через `include_nics=true`
|
||||
|
||||
---
|
||||
|
||||
## [v1.16.1] - 2026-08-17
|
||||
|
||||
### 🎨 Интерфейс
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Современная веб-панель для управления серверами Proxmox, виртуальными машинами и контейнерами LXC**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
|
||||
2
WIKI.md
2
WIKI.md
@@ -262,6 +262,7 @@ This section summarizes key API endpoints. All require JWT authentication.
|
||||
### Backups & Storages
|
||||
|
||||
- `GET /api/backups/storages/{server_id}` — list storages
|
||||
- `GET /api/backups/scan-nfs/{server_id}?server=<ip>` — scan an NFS server for available exports
|
||||
- `POST /api/backups/storages/{server_id}` — add storage
|
||||
- `PUT /api/backups/storages/{server_id}/{storage_id}` — update storage
|
||||
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — delete storage
|
||||
@@ -2173,6 +2174,7 @@ curl -H "Authorization: Bearer eyJ..." \
|
||||
| GET | `/logs/api/logs` | Get audit logs |
|
||||
| GET | `/logs/api/stats` | Get log statistics |
|
||||
| GET | `/api/backups/storages/{server_id}` | List Proxmox storages |
|
||||
| GET | `/api/backups/scan-nfs/{server_id}` | Scan NFS server for exports |
|
||||
| POST | `/api/backups/create` | Create VM/LXC backup |
|
||||
| POST | `/api/backups/restore` | Restore VM/LXC from backup |
|
||||
| GET | `/api/backups/list/{server_id}` | List backup files |
|
||||
|
||||
@@ -261,6 +261,7 @@ CORS_ORIGINS=*
|
||||
### Резервные копии и хранилища
|
||||
|
||||
- `GET /api/backups/storages/{server_id}` — список хранилищ
|
||||
- `GET /api/backups/scan-nfs/{server_id}?server=<ip>` — сканировать NFS-сервер на доступные экспорты
|
||||
- `POST /api/backups/storages/{server_id}` — добавить хранилище
|
||||
- `PUT /api/backups/storages/{server_id}/{storage_id}` — обновить хранилище
|
||||
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — удалить хранилище
|
||||
@@ -2086,6 +2087,7 @@ curl -H "Authorization: Bearer eyJ..." \
|
||||
| GET | `/logs/api/logs` | Получить логи аудита |
|
||||
| GET | `/logs/api/stats` | Статистика логов |
|
||||
| GET | `/api/backups/storages/{server_id}` | Список хранилищ Proxmox |
|
||||
| GET | `/api/backups/scan-nfs/{server_id}` | Сканировать NFS-сервер на экспорты |
|
||||
| POST | `/api/backups/create` | Создать копию VM/LXC |
|
||||
| POST | `/api/backups/restore` | Восстановить VM/LXC из копии |
|
||||
| GET | `/api/backups/list/{server_id}` | Список файлов копий |
|
||||
|
||||
@@ -5,7 +5,7 @@ IP Address Management endpoints for networks, pools, and allocations.
|
||||
|
||||
import logging
|
||||
import ipaddress as ipaddress_module
|
||||
from typing import List, Optional
|
||||
from typing import List, NamedTuple, Optional
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import or_
|
||||
@@ -21,6 +21,8 @@ from ..schemas import (
|
||||
IPAMHistoryResponse, IPAMAutoAllocateRequest
|
||||
)
|
||||
from ..ipam_service import IPAMService
|
||||
from ..services import guest_ip_service
|
||||
from .proxmox._helpers import require_vm_access
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -382,6 +384,8 @@ def get_allocations(
|
||||
pool_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
proxmox_server_id: Optional[int] = None,
|
||||
proxmox_vmid: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = Query(default=100, le=1000),
|
||||
offset: int = 0,
|
||||
@@ -402,6 +406,13 @@ def get_allocations(
|
||||
|
||||
if resource_type:
|
||||
query = query.filter(IPAMAllocation.resource_type == resource_type)
|
||||
|
||||
# Фильтр по конкретному гостю: у него может быть несколько адресов
|
||||
if proxmox_server_id is not None:
|
||||
query = query.filter(IPAMAllocation.proxmox_server_id == proxmox_server_id)
|
||||
|
||||
if proxmox_vmid is not None:
|
||||
query = query.filter(IPAMAllocation.proxmox_vmid == proxmox_vmid)
|
||||
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
@@ -789,13 +800,29 @@ async def cleanup_orphan_allocations(
|
||||
}
|
||||
|
||||
|
||||
def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
|
||||
"""Extract first IPv4 that belongs to any IPAM network from interface list."""
|
||||
class DiscoveredAddress(NamedTuple):
|
||||
"""Адрес гостя, найденный в одной из известных IPAM-сетей."""
|
||||
ip: str
|
||||
network: object # IPAMNetwork
|
||||
mac: Optional[str] # MAC интерфейса, если он известен
|
||||
interface: Optional[str] # имя интерфейса гостя (eth0, ens18, ...)
|
||||
|
||||
|
||||
def _extract_ipv4s(interfaces: list, parsed_networks: list) -> List[DiscoveredAddress]:
|
||||
"""Все IPv4 гостя, попадающие в известные IPAM-сети, в порядке обнаружения.
|
||||
|
||||
Гость может держать несколько адресов, поэтому возвращается список, а не
|
||||
первое совпадение. Loopback и link-local отбрасываются.
|
||||
"""
|
||||
found: List[DiscoveredAddress] = []
|
||||
seen = set()
|
||||
for iface in interfaces:
|
||||
for ip_info in iface.get('ips', []):
|
||||
if ip_info.get('type') != 'ipv4':
|
||||
continue
|
||||
addr = ip_info.get('address', '')
|
||||
if not addr or addr in seen:
|
||||
continue
|
||||
try:
|
||||
ip_obj = ipaddress_module.ip_address(addr)
|
||||
except ValueError:
|
||||
@@ -804,8 +831,15 @@ def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
|
||||
continue
|
||||
for net, net_obj in parsed_networks:
|
||||
if ip_obj in net_obj:
|
||||
return addr, net
|
||||
return None, None
|
||||
seen.add(addr)
|
||||
found.append(DiscoveredAddress(
|
||||
ip=addr,
|
||||
network=net,
|
||||
mac=(iface.get('hardware_address') or '').upper() or None,
|
||||
interface=iface.get('name') or None,
|
||||
))
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
@router.get("/api/unlinked")
|
||||
@@ -814,21 +848,26 @@ def get_unlinked_allocations(
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""
|
||||
Return VM instances that have no IPAM allocation linked to them (by vmid+server_id).
|
||||
Only VMs whose cached ip_address falls within an active IPAM network are included.
|
||||
Return VM instances whose current address is not registered in IPAM.
|
||||
|
||||
У гостя может быть несколько адресов, поэтому «связан» определяется не по
|
||||
наличию хоть какой-то аллокации, а по конкретному адресу: если в кэше
|
||||
инстанса стоит IP, которого нет среди его аллокаций, гость показывается
|
||||
как несвязанный. Учитываются только адреса из активных IPAM-сетей.
|
||||
"""
|
||||
unlinked = []
|
||||
|
||||
# All live VMs
|
||||
vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
|
||||
|
||||
# Pre-load all linked (proxmox_server_id, proxmox_vmid) pairs for fast lookup
|
||||
linked_pairs = set(
|
||||
(a.proxmox_server_id, a.proxmox_vmid)
|
||||
for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
|
||||
IPAMAllocation.proxmox_vmid.isnot(None)
|
||||
).all()
|
||||
)
|
||||
# (server_id, vmid) -> множество уже учтённых адресов гостя
|
||||
linked_ips = {}
|
||||
for alloc in db.query(
|
||||
IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid, IPAMAllocation.ip_address
|
||||
).filter(IPAMAllocation.proxmox_vmid.isnot(None)).all():
|
||||
linked_ips.setdefault((alloc.proxmox_server_id, alloc.proxmox_vmid), set()).add(
|
||||
alloc.ip_address
|
||||
)
|
||||
|
||||
servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
|
||||
|
||||
@@ -841,12 +880,11 @@ def get_unlinked_allocations(
|
||||
pass
|
||||
|
||||
for vm in vms:
|
||||
if (vm.server_id, vm.vmid) in linked_pairs:
|
||||
continue
|
||||
|
||||
# Skip VMs with no IP or an IP outside all IPAM networks
|
||||
if not vm.ip_address:
|
||||
continue
|
||||
if vm.ip_address in linked_ips.get((vm.server_id, vm.vmid), ()):
|
||||
continue
|
||||
try:
|
||||
vm_ip = ipaddress_module.ip_address(vm.ip_address)
|
||||
if not any(vm_ip in net for net in ipam_nets):
|
||||
@@ -972,9 +1010,9 @@ def link_allocations_to_vms(
|
||||
continue
|
||||
|
||||
for vm in server_vms:
|
||||
# Skip already linked
|
||||
if (server_id, vm.vmid) in linked_pairs:
|
||||
continue
|
||||
# Уже связанных больше не пропускаем: у гостя может появиться
|
||||
# второй адрес или смениться существующий — и то, и другое раньше
|
||||
# оставалось незамеченным навсегда.
|
||||
|
||||
# For this VM, further filter networks by proxmox_node if set
|
||||
# (e.g. network tagged to pve1 should not match VMs on pve2)
|
||||
@@ -997,40 +1035,59 @@ def link_allocations_to_vms(
|
||||
logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}")
|
||||
interfaces = []
|
||||
|
||||
# Find first IPv4 in one of this VM's applicable IPAM networks
|
||||
ip_found, matching_network = _extract_primary_ipv4(interfaces, vm_networks)
|
||||
# Все IPv4 гостя, попадающие в его IPAM-сети
|
||||
discovered = _extract_ipv4s(interfaces, vm_networks)
|
||||
|
||||
if not ip_found:
|
||||
if not discovered:
|
||||
not_found.append({'ip_address': '-', 'resource_name': vm.name,
|
||||
'reason': 'no_ip_in_ipam_network'})
|
||||
continue
|
||||
|
||||
alloc, error = service.sync_from_proxmox_vm(
|
||||
network_id=matching_network.id,
|
||||
proxmox_server_id=server_id,
|
||||
vmid=vm.vmid,
|
||||
vm_name=vm.name,
|
||||
vm_type=vm.vm_type,
|
||||
ip_address=ip_found,
|
||||
node=vm.node,
|
||||
synced_by=synced_by
|
||||
)
|
||||
already_linked = {
|
||||
a.ip_address for a in
|
||||
service.find_allocations_by_resource(server_id, vm.vmid)
|
||||
}
|
||||
|
||||
if alloc:
|
||||
# Update the cached ip_address in vm_instances as well
|
||||
vm.ip_address = ip_found
|
||||
linked_pairs.add((server_id, vm.vmid)) # avoid double-linking
|
||||
linked.append({
|
||||
'ip_address': ip_found,
|
||||
'resource_name': vm.name,
|
||||
'vmid': vm.vmid,
|
||||
'server_id': server_id
|
||||
})
|
||||
logger.info(f"[IPAM link] Linked {ip_found} -> VM {vm.vmid} ({vm.name}) on {server.name}")
|
||||
else:
|
||||
not_found.append({'ip_address': ip_found, 'resource_name': vm.name,
|
||||
'reason': error or 'sync_error'})
|
||||
logger.warning(f"[IPAM link] Failed {ip_found} for {vm.name}: {error}")
|
||||
for found in discovered:
|
||||
if found.ip in already_linked:
|
||||
continue # этот адрес гостя уже учтён
|
||||
|
||||
alloc, error = service.sync_from_proxmox_vm(
|
||||
network_id=found.network.id,
|
||||
proxmox_server_id=server_id,
|
||||
vmid=vm.vmid,
|
||||
vm_name=vm.name,
|
||||
vm_type=vm.vm_type,
|
||||
ip_address=found.ip,
|
||||
node=vm.node,
|
||||
mac_address=found.mac,
|
||||
synced_by=synced_by,
|
||||
target_interface=found.interface,
|
||||
# Адреса сверх первого — алиасы: их поднимают внутри
|
||||
# гостя, а не через net0/ipconfig0.
|
||||
assignment_kind='alias',
|
||||
)
|
||||
|
||||
if alloc:
|
||||
already_linked.add(found.ip)
|
||||
linked_pairs.add((server_id, vm.vmid))
|
||||
# Кэш инстанса показывает основной адрес
|
||||
if alloc.is_primary:
|
||||
vm.ip_address = found.ip
|
||||
linked.append({
|
||||
'ip_address': found.ip,
|
||||
'resource_name': vm.name,
|
||||
'vmid': vm.vmid,
|
||||
'server_id': server_id,
|
||||
'is_primary': bool(alloc.is_primary),
|
||||
})
|
||||
logger.info(
|
||||
f"[IPAM link] Linked {found.ip} -> VM {vm.vmid} ({vm.name}) on {server.name}"
|
||||
)
|
||||
else:
|
||||
not_found.append({'ip_address': found.ip, 'resource_name': vm.name,
|
||||
'reason': error or 'sync_error'})
|
||||
logger.warning(f"[IPAM link] Failed {found.ip} for {vm.name}: {error}")
|
||||
|
||||
if linked:
|
||||
try:
|
||||
@@ -1047,3 +1104,261 @@ def link_allocations_to_vms(
|
||||
'not_found_count': len(not_found)
|
||||
}
|
||||
|
||||
|
||||
# ==================== Guest addresses (несколько IP на инстанс) ====================
|
||||
#
|
||||
# Основной адрес гостя задаётся конфигом Proxmox (net0 / ipconfig0) и здесь
|
||||
# только помечается флагом. Дополнительные навешиваются алиасами внутри гостя
|
||||
# силами services/guest_ip_service.py.
|
||||
|
||||
|
||||
class GuestAddressCreate(BaseModel):
|
||||
network_id: int
|
||||
pool_id: Optional[int] = None
|
||||
ip_address: Optional[str] = None # пусто -> следующий свободный в сети/пуле
|
||||
target_interface: Optional[str] = None # пусто -> интерфейс основного адреса
|
||||
make_primary: bool = False
|
||||
hostname: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
def _allocation_payload(db: Session, alloc: IPAMAllocation) -> dict:
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == alloc.network_id).first()
|
||||
prefix = None
|
||||
if network and network.network and '/' in network.network:
|
||||
prefix = network.network.split('/')[1]
|
||||
return {
|
||||
'id': alloc.id,
|
||||
'ip_address': alloc.ip_address,
|
||||
'prefix': prefix,
|
||||
'network_id': alloc.network_id,
|
||||
'network_name': network.name if network else None,
|
||||
'gateway': network.gateway if network else None,
|
||||
'mac_address': alloc.mac_address,
|
||||
'status': alloc.status,
|
||||
'is_primary': bool(alloc.is_primary),
|
||||
'assignment_kind': alloc.assignment_kind,
|
||||
'target_interface': alloc.target_interface,
|
||||
'apply_status': alloc.apply_status,
|
||||
'apply_error': alloc.apply_error,
|
||||
'applied_at': alloc.applied_at,
|
||||
'hostname': alloc.hostname,
|
||||
'notes': alloc.notes,
|
||||
}
|
||||
|
||||
|
||||
def _guest_or_404(db: Session, server_id: int, vmid: int) -> VMInstance:
|
||||
guest = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first()
|
||||
if not guest:
|
||||
raise HTTPException(status_code=404, detail="Instance not found")
|
||||
return guest
|
||||
|
||||
|
||||
def _guest_allocation_or_404(db: Session, server_id: int, vmid: int,
|
||||
allocation_id: int) -> IPAMAllocation:
|
||||
allocation = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.id == allocation_id,
|
||||
IPAMAllocation.proxmox_server_id == server_id,
|
||||
IPAMAllocation.proxmox_vmid == vmid
|
||||
).first()
|
||||
if not allocation:
|
||||
raise HTTPException(status_code=404, detail="Address not found for this instance")
|
||||
return allocation
|
||||
|
||||
|
||||
def _refresh_primary_cache(db: Session, service: IPAMService, guest: VMInstance) -> None:
|
||||
"""Кэш инстанса показывает основной адрес — он же в колонке IP списка."""
|
||||
primary = service.find_allocation_by_resource(guest.server_id, guest.vmid)
|
||||
if primary:
|
||||
guest.ip_address = primary.ip_address
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/api/guests/{server_id}/{vmid}/addresses")
|
||||
def list_guest_addresses(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:view"))
|
||||
):
|
||||
"""Все IP-адреса инстанса: основной первым, затем алиасы."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
service = IPAMService(db)
|
||||
rows = service.find_allocations_by_resource(server_id, vmid)
|
||||
return {
|
||||
'addresses': [_allocation_payload(db, a) for a in rows],
|
||||
'count': len(rows),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/guests/{server_id}/{vmid}/addresses")
|
||||
def add_guest_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
body: GuestAddressCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Выдать инстансу дополнительный адрес и навесить его на интерфейс.
|
||||
|
||||
Адрес резервируется в IPAM в любом случае: если гость недоступен, запись
|
||||
остаётся со статусом pending, и её можно применить позже.
|
||||
"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
guest = _guest_or_404(db, server_id, vmid)
|
||||
service = IPAMService(db)
|
||||
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == body.network_id).first()
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="IPAM network not found")
|
||||
|
||||
ip_address = body.ip_address
|
||||
if not ip_address:
|
||||
# Доп. адрес выдаётся точечно, поэтому при отсутствии auto-assign пула
|
||||
# ищем свободный по всей подсети, а не отказываем.
|
||||
ip_address = service.get_next_available_ip(
|
||||
body.network_id, body.pool_id, allow_network_scan=True
|
||||
)
|
||||
if not ip_address:
|
||||
raise HTTPException(status_code=409, detail="No free IP left in this network/pool")
|
||||
|
||||
interface = body.target_interface
|
||||
if not interface:
|
||||
primary = service.find_allocation_by_resource(server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
try:
|
||||
client = ProxmoxClient.from_server(server)
|
||||
interface = guest_ip_service.resolve_interface(
|
||||
client, guest.vm_type, guest.node, vmid,
|
||||
primary_ip=primary.ip_address if primary else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[guest-ip] fallback interface for {vmid}: {e}")
|
||||
interface = guest_ip_service.DEFAULT_INTERFACE
|
||||
|
||||
allocation, error = service.allocate_ip(
|
||||
ip_address=ip_address,
|
||||
network_id=body.network_id,
|
||||
pool_id=body.pool_id,
|
||||
resource_type=guest.vm_type,
|
||||
resource_name=guest.name,
|
||||
hostname=body.hostname,
|
||||
proxmox_server_id=server_id,
|
||||
proxmox_vmid=vmid,
|
||||
proxmox_node=guest.node,
|
||||
allocated_by=current_user.username,
|
||||
notes=body.notes,
|
||||
assignment_kind="alias",
|
||||
target_interface=interface,
|
||||
apply_status=guest_ip_service.PENDING,
|
||||
)
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
apply_result = guest_ip_service.apply_address(db, allocation)
|
||||
|
||||
if body.make_primary:
|
||||
service.set_primary(server_id, vmid, allocation.id, changed_by=current_user.username)
|
||||
|
||||
_refresh_primary_cache(db, service, guest)
|
||||
db.refresh(allocation)
|
||||
|
||||
return {
|
||||
'address': _allocation_payload(db, allocation),
|
||||
'applied': apply_result.success,
|
||||
'apply_status': apply_result.status,
|
||||
'persist': apply_result.persist,
|
||||
'error': apply_result.error,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/apply")
|
||||
def apply_guest_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
allocation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Повторить применение адреса — например, после включения гостя."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
|
||||
|
||||
result = guest_ip_service.apply_address(db, allocation)
|
||||
db.refresh(allocation)
|
||||
return {
|
||||
'address': _allocation_payload(db, allocation),
|
||||
'applied': result.success,
|
||||
'apply_status': result.status,
|
||||
'persist': result.persist,
|
||||
'error': result.error,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/primary")
|
||||
def set_guest_primary_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
allocation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Пометить адрес основным — он показывается в колонке IP списка инстансов."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
guest = _guest_or_404(db, server_id, vmid)
|
||||
_guest_allocation_or_404(db, server_id, vmid, allocation_id)
|
||||
|
||||
service = IPAMService(db)
|
||||
allocation, error = service.set_primary(
|
||||
server_id, vmid, allocation_id, changed_by=current_user.username
|
||||
)
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
|
||||
_refresh_primary_cache(db, service, guest)
|
||||
return {'address': _allocation_payload(db, allocation)}
|
||||
|
||||
|
||||
@router.delete("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}")
|
||||
def remove_guest_address(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
allocation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:manage"))
|
||||
):
|
||||
"""Снять адрес с гостя и освободить его в IPAM."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
guest = _guest_or_404(db, server_id, vmid)
|
||||
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
|
||||
|
||||
if allocation.assignment_kind != "alias":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Primary address comes from the NIC config — change it on the Network tab",
|
||||
)
|
||||
|
||||
ip_address = allocation.ip_address
|
||||
interface = allocation.target_interface or guest_ip_service.DEFAULT_INTERFACE
|
||||
|
||||
service = IPAMService(db)
|
||||
released, error = service.release_ip(
|
||||
ip_address, released_by=current_user.username,
|
||||
reason=f"Removed from {guest.name} ({vmid})",
|
||||
)
|
||||
if not released:
|
||||
raise HTTPException(status_code=400, detail=error or "Failed to release IP")
|
||||
|
||||
# Аллокации уже нет — синхронизация снимет адрес с интерфейса гостя.
|
||||
result = guest_ip_service.sync_interface(db, server_id, vmid, interface)
|
||||
|
||||
_refresh_primary_cache(db, service, guest)
|
||||
return {
|
||||
'released': ip_address,
|
||||
'removed_from_guest': result.success,
|
||||
'error': result.error,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from .pools import router as pools_router
|
||||
from .disks import router as disks_router
|
||||
from .access import router as access_router
|
||||
from .node_admin import router as node_admin_router
|
||||
from .topology import router as topology_router
|
||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
|
||||
router = APIRouter()
|
||||
@@ -34,3 +35,4 @@ router.include_router(pools_router)
|
||||
router.include_router(disks_router)
|
||||
router.include_router(access_router)
|
||||
router.include_router(node_admin_router)
|
||||
router.include_router(topology_router)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
from typing import List, Optional
|
||||
import re
|
||||
import ssl
|
||||
import asyncio
|
||||
import httpx
|
||||
@@ -18,6 +19,76 @@ from ...logging_service import LoggingService
|
||||
from ...ipam_service import IPAMService
|
||||
|
||||
|
||||
# ==================== OS name derivation ====================
|
||||
|
||||
# Known LXC distro slugs → human-readable display name.
|
||||
_LXC_DISTRO_NAMES = {
|
||||
"debian": "Debian",
|
||||
"ubuntu": "Ubuntu",
|
||||
"alpine": "Alpine",
|
||||
"fedora": "Fedora",
|
||||
"centos": "CentOS",
|
||||
"rockylinux": "Rocky Linux",
|
||||
"rocky": "Rocky Linux",
|
||||
"almalinux": "AlmaLinux",
|
||||
"alma": "AlmaLinux",
|
||||
"archlinux": "Arch Linux",
|
||||
"arch": "Arch Linux",
|
||||
"opensuse": "openSUSE",
|
||||
"gentoo": "Gentoo",
|
||||
"devuan": "Devuan",
|
||||
"nixos": "NixOS",
|
||||
"oracle": "Oracle Linux",
|
||||
"oraclelinux": "Oracle Linux",
|
||||
"kali": "Kali Linux",
|
||||
"mint": "Linux Mint",
|
||||
"slackware": "Slackware",
|
||||
"void": "Void Linux",
|
||||
}
|
||||
|
||||
|
||||
def pretty_os_from_template(template_name: Optional[str]) -> Optional[str]:
|
||||
"""Derive a friendly OS name (e.g. "Debian 13", "Ubuntu 24.04") from an
|
||||
LXC CT template volid/filename such as
|
||||
``local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst``.
|
||||
|
||||
Returns ``None`` if the input doesn't look like a CT template path, so the
|
||||
caller can fall back to its previous value.
|
||||
"""
|
||||
if not template_name:
|
||||
return None
|
||||
|
||||
# Only vztmpl-style paths carry a distro/version in the filename. VM clone
|
||||
# templates keep their own descriptive names — leave those untouched.
|
||||
if "vztmpl" not in template_name and "/" not in template_name and ".tar" not in template_name:
|
||||
return None
|
||||
|
||||
# Strip storage prefix and directory: keep just the filename.
|
||||
base = template_name.rsplit("/", 1)[-1]
|
||||
# Head is everything before the first underscore, e.g.
|
||||
# "debian-13-standard", "ubuntu-24.04-standard", "centos-9-stream-default".
|
||||
head = base.split("_", 1)[0]
|
||||
tokens = head.split("-")
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
distro = _LXC_DISTRO_NAMES.get(tokens[0].lower())
|
||||
if not distro:
|
||||
return None
|
||||
|
||||
# First token that looks like a version number (digits, optionally dotted).
|
||||
version = next((t for t in tokens[1:] if re.match(r"^\d+(\.\d+)*$", t)), None)
|
||||
# Preserve edition qualifiers like "stream" (CentOS Stream) when present.
|
||||
edition = "Stream" if "stream" in (t.lower() for t in tokens[1:]) else None
|
||||
|
||||
parts = [distro]
|
||||
if version:
|
||||
parts.append(version)
|
||||
if edition:
|
||||
parts.append(edition)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# ==================== Helper Functions for User Isolation ====================
|
||||
|
||||
def can_view_all_instances(current_user: User) -> bool:
|
||||
|
||||
@@ -53,6 +53,35 @@ def get_storages(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/backups/scan-nfs/{server_id}")
|
||||
def scan_nfs(
|
||||
server_id: int,
|
||||
server: str,
|
||||
node: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("backup:manage")),
|
||||
):
|
||||
"""Scan an NFS server for available exports, to populate the storage-add dialog."""
|
||||
pve_server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not pve_server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
try:
|
||||
client = _get_proxmox_client(pve_server)
|
||||
target_node = node
|
||||
if not target_node:
|
||||
nodes = client.get_nodes()
|
||||
if not nodes:
|
||||
raise HTTPException(status_code=400, detail="No nodes available to scan from")
|
||||
target_node = nodes[0]["node"]
|
||||
exports = client.scan_nfs_exports(target_node, server)
|
||||
return JSONResponse(content={"exports": exports})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning NFS exports on {server} for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/backups/storages/{server_id}")
|
||||
async def create_storage(
|
||||
server_id: int,
|
||||
|
||||
@@ -316,6 +316,11 @@ async def create_vm_from_iso(
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
if not req.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Укажите имя ВМ")
|
||||
from ...schemas import validate_instance_name
|
||||
try:
|
||||
req.name = validate_instance_name(req.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not req.node:
|
||||
raise HTTPException(status_code=400, detail="Укажите ноду для размещения ВМ")
|
||||
if not req.iso:
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
@@ -53,6 +53,13 @@ class LXCDeployRequest(BaseModel):
|
||||
ipam_pool_id: Optional[int] = None
|
||||
password: Optional[str] = None
|
||||
ssh_keys: Optional[str] = None
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_min_length(cls, v):
|
||||
if v is not None and len(v) < 5:
|
||||
raise ValueError("Пароль должен содержать не менее 5 символов (требование Proxmox)")
|
||||
return v
|
||||
ssh_key_ids: Optional[List[int]] = None
|
||||
owner_id: Optional[int] = None
|
||||
nameserver: Optional[str] = None
|
||||
@@ -225,26 +232,31 @@ def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username:
|
||||
_update_task(task_id, "running", f"Создание LXC контейнера VMID {new_vmid}...", 35,
|
||||
vmid=new_vmid, node=deploy_node)
|
||||
net0_str = f"name=eth0,bridge={req.bridge},{ip_config},firewall=1"
|
||||
upid = client.create_lxc_container(
|
||||
node=deploy_node,
|
||||
vmid=new_vmid,
|
||||
ostemplate=req.ostemplate,
|
||||
hostname=req.name,
|
||||
cores=req.cores,
|
||||
memory=req.memory,
|
||||
swap=req.swap,
|
||||
storage=req.storage,
|
||||
rootfs_size=req.disk,
|
||||
net0=net0_str,
|
||||
nameserver=req.nameserver,
|
||||
searchdomain=req.searchdomain,
|
||||
password=req.password,
|
||||
ssh_public_keys=req.ssh_keys,
|
||||
unprivileged=req.unprivileged,
|
||||
start_after_create=False, # we start manually after config
|
||||
onboot=req.onboot,
|
||||
description=f"Created from {req.ostemplate} by {username}",
|
||||
)
|
||||
try:
|
||||
upid = client.create_lxc_container(
|
||||
node=deploy_node,
|
||||
vmid=new_vmid,
|
||||
ostemplate=req.ostemplate,
|
||||
hostname=req.name,
|
||||
cores=req.cores,
|
||||
memory=req.memory,
|
||||
swap=req.swap,
|
||||
storage=req.storage,
|
||||
rootfs_size=req.disk,
|
||||
net0=net0_str,
|
||||
nameserver=req.nameserver,
|
||||
searchdomain=req.searchdomain,
|
||||
password=req.password,
|
||||
ssh_public_keys=req.ssh_keys,
|
||||
unprivileged=req.unprivileged,
|
||||
start_after_create=False, # we start manually after config
|
||||
onboot=req.onboot,
|
||||
description=f"Created from {req.ostemplate} by {username}",
|
||||
)
|
||||
except Exception as e:
|
||||
_update_task(task_id, "failed", "Ошибка создания контейнера", 35,
|
||||
error=f"Proxmox: {e}")
|
||||
return
|
||||
if not upid:
|
||||
_update_task(task_id, "failed", "Ошибка создания контейнера", 35,
|
||||
error="Proxmox не вернул UPID задачи создания контейнера")
|
||||
@@ -447,6 +459,11 @@ async def deploy_lxc_from_template(
|
||||
raise HTTPException(status_code=400, detail="Необходимо указать ostemplate")
|
||||
if not req.name or len(req.name) < 1:
|
||||
raise HTTPException(status_code=400, detail="Укажите имя контейнера")
|
||||
from ...schemas import validate_instance_name
|
||||
try:
|
||||
req.name = validate_instance_name(req.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Resolve owner / SSH keys
|
||||
is_admin = current_user.has_permission("user:manage") or current_user.is_admin
|
||||
|
||||
486
backend/app/api/proxmox/topology.py
Normal file
486
backend/app/api/proxmox/topology.py
Normal file
@@ -0,0 +1,486 @@
|
||||
"""Aggregated network topology: panel -> cluster -> node -> guest.
|
||||
|
||||
One endpoint feeds the whole /topology page so the frontend makes a single
|
||||
request instead of fanning out over servers/nodes/sdn/vms. Everything the graph
|
||||
draws - plus the bridge/VLAN/MAC data the planned "group by VLAN" view will
|
||||
need - is collected here.
|
||||
|
||||
Kept separate from ``cluster.py``'s ``/api/cluster/topology``, which answers a
|
||||
different question (which servers form which cluster) and is consumed by the
|
||||
Cluster page.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time as time_lib
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
from ...db import get_db
|
||||
from ...models import IPAMAllocation, IPAMNetwork, ProxmoxServer, User, VMInstance
|
||||
from ...proxmox import _run_in_executor, parse_guest_nics
|
||||
from ...schemas import (NetworkTopologyResponse, TopologyBridge, TopologyCluster,
|
||||
TopologyGuest, TopologyNic, TopologyNode,
|
||||
TopologyPanelSummary, TopologyWarning)
|
||||
from ._helpers import _get_proxmox_client, can_view_all_instances, get_visible_server_ids
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Live cluster/resources + node networks, keyed by server id. Short TTL: the
|
||||
# page auto-refreshes every 30s and several users may watch it at once.
|
||||
_topo_cache: Dict[int, Dict[str, Any]] = {}
|
||||
_topo_lock = threading.Lock()
|
||||
TOPO_TTL = 15
|
||||
|
||||
# Guest configs change rarely, and reading them costs one HTTP call per guest.
|
||||
_nic_cache: Dict[Tuple[int, int], Dict[str, Any]] = {}
|
||||
_nic_lock = threading.Lock()
|
||||
NIC_TTL = 300
|
||||
NIC_MAX_WORKERS = 8
|
||||
NIC_MAX_FETCHES = 300 # hard ceiling so a huge cluster cannot hang the request
|
||||
|
||||
|
||||
def _truthy(value: Any) -> bool:
|
||||
"""Proxmox returns flags as 1/0, "1"/"0" or bools depending on the field."""
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# ==================== Live collection (runs off the request thread) ====================
|
||||
|
||||
def _collect_server(server: ProxmoxServer, include_bridges: bool) -> Dict[str, Any]:
|
||||
"""Everything one Proxmox connection can tell us, in as few calls as possible."""
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
# A single /cluster/resources call carries both node and guest metrics.
|
||||
resources = client.get_cluster_resources() or []
|
||||
nodes = [r for r in resources if r.get("type") == "node"]
|
||||
guests = [r for r in resources if r.get("type") in ("qemu", "lxc")]
|
||||
|
||||
bridges: Dict[str, List[Dict[str, Any]]] = {}
|
||||
if include_bridges:
|
||||
# Zones/vnets are cluster-wide; fetch once and filter per node below.
|
||||
try:
|
||||
zones = {z.get("zone"): z for z in (client.get_sdn_zones() or [])}
|
||||
vnets = client.get_sdn_vnets() or []
|
||||
except Exception as exc: # SDN is optional and older PVE lacks it
|
||||
logger.debug(f"SDN unavailable on {server.name}: {exc}")
|
||||
zones, vnets = {}, []
|
||||
|
||||
for node_res in nodes:
|
||||
node_name = node_res.get("node")
|
||||
if not node_name:
|
||||
continue
|
||||
entries: List[Dict[str, Any]] = []
|
||||
try:
|
||||
for iface in client.get_node_networks(node_name) or []:
|
||||
name = iface.get("iface") or iface.get("name")
|
||||
if not name:
|
||||
continue
|
||||
cidr = iface.get("cidr")
|
||||
if not cidr and iface.get("address") and iface.get("netmask"):
|
||||
cidr = "{0}/{1}".format(iface["address"], iface["netmask"])
|
||||
entries.append({
|
||||
"name": name,
|
||||
"type": iface.get("type"),
|
||||
"cidr": cidr,
|
||||
"vlan_aware": _truthy(iface.get("bridge_vlan_aware")),
|
||||
"ports": iface.get("bridge_ports") or iface.get("slaves"),
|
||||
"active": _truthy(iface.get("active")),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.debug(f"Could not read networks of {server.name}/{node_name}: {exc}")
|
||||
|
||||
# SDN vnets are attachable as bridges but never appear in /network.
|
||||
for vnet in vnets:
|
||||
vnet_name = vnet.get("vnet")
|
||||
if not vnet_name:
|
||||
continue
|
||||
zone_name = vnet.get("zone", "")
|
||||
zone_nodes = (zones.get(zone_name, {}).get("nodes") or "").replace(" ", "")
|
||||
if zone_nodes and node_name not in zone_nodes.split(","):
|
||||
continue
|
||||
entries.append({
|
||||
"name": vnet_name,
|
||||
"type": "vnet",
|
||||
"zone": zone_name,
|
||||
"active": True,
|
||||
})
|
||||
bridges[node_name] = entries
|
||||
|
||||
return {"nodes": nodes, "guests": guests, "bridges": bridges}
|
||||
|
||||
|
||||
def _fetch_nics(server: ProxmoxServer, targets: List[Tuple[str, int, str]]) -> Dict[int, List[Dict[str, Any]]]:
|
||||
"""``netN`` cards for the given guests. ``targets`` = [(node, vmid, type)]."""
|
||||
if not targets:
|
||||
return {}
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
def _one(node: str, vmid: int, vm_type: str) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
getter = client.get_vm_config if vm_type == "qemu" else client.get_container_config
|
||||
config = getter(node, vmid) or {}
|
||||
return vmid, [nic.to_dict() for nic in parse_guest_nics(config)]
|
||||
|
||||
result: Dict[int, List[Dict[str, Any]]] = {}
|
||||
with ThreadPoolExecutor(max_workers=min(NIC_MAX_WORKERS, len(targets))) as pool:
|
||||
futures = [pool.submit(_one, *target) for target in targets]
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
vmid, nics = future.result()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Could not read guest config on {server.name}: {exc}")
|
||||
continue
|
||||
result[vmid] = nics
|
||||
return result
|
||||
|
||||
|
||||
# ==================== Helpers ====================
|
||||
|
||||
def _split_tags(raw: Optional[str]) -> List[str]:
|
||||
"""Proxmox stores tags as a ``;``-separated string; the graph wants a list."""
|
||||
if not raw:
|
||||
return []
|
||||
return [t.strip() for t in str(raw).replace(",", ";").split(";") if t.strip()]
|
||||
|
||||
|
||||
def _to_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _empty_response() -> NetworkTopologyResponse:
|
||||
return NetworkTopologyResponse(
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
panel=TopologyPanelSummary(),
|
||||
clusters=[],
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
|
||||
# ==================== Endpoint ====================
|
||||
|
||||
@router.get("/api/network/topology", response_model=NetworkTopologyResponse)
|
||||
async def get_network_topology(
|
||||
request: Request,
|
||||
server_id: Optional[int] = Query(None, description="Limit to a single connection"),
|
||||
include_stopped: bool = Query(True),
|
||||
include_templates: bool = Query(False),
|
||||
include_bridges: bool = Query(True),
|
||||
include_nics: bool = Query(False, description="Read every guest config for bridge/VLAN/MAC - one call per guest"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("network:view")),
|
||||
):
|
||||
"""Infrastructure tree for the topology page.
|
||||
|
||||
Always answers 200 (bar a permission denial): an unreachable server
|
||||
degrades to cached rows plus a warning rather than failing the page.
|
||||
"""
|
||||
warnings: List[TopologyWarning] = []
|
||||
|
||||
# -- Visibility ----------------------------------------------------------
|
||||
# None means "no restriction" and is NOT the same as an empty set.
|
||||
visible_ids = get_visible_server_ids(request, db, current_user)
|
||||
if visible_ids is not None and not visible_ids:
|
||||
return _empty_response()
|
||||
|
||||
servers_q = db.query(ProxmoxServer)
|
||||
if visible_ids is not None:
|
||||
servers_q = servers_q.filter(ProxmoxServer.id.in_(visible_ids))
|
||||
if server_id is not None:
|
||||
if visible_ids is not None and server_id not in visible_ids:
|
||||
# 404 rather than 403 so the response cannot confirm the server exists.
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
servers_q = servers_q.filter(ProxmoxServer.id == server_id)
|
||||
servers = servers_q.order_by(ProxmoxServer.name).all()
|
||||
if not servers:
|
||||
return _empty_response()
|
||||
server_map = {s.id: s for s in servers}
|
||||
|
||||
# -- Ownership: live Proxmox data carries no owner, the DB does ----------
|
||||
see_all = can_view_all_instances(current_user)
|
||||
|
||||
db_q = (
|
||||
db.query(VMInstance)
|
||||
.options(joinedload(VMInstance.owner))
|
||||
.filter(VMInstance.deleted_at.is_(None), VMInstance.server_id.in_(server_map.keys()))
|
||||
)
|
||||
if not see_all:
|
||||
db_q = db_q.filter(VMInstance.owner_id == current_user.id)
|
||||
db_guests = db_q.all()
|
||||
db_by_key: Dict[Tuple[int, int], VMInstance] = {(g.server_id, g.vmid): g for g in db_guests}
|
||||
# Pair-scoped, not vmid-scoped: the same vmid on two servers is two guests.
|
||||
owned_keys = set(db_by_key.keys())
|
||||
|
||||
# -- IP addresses from IPAM, falling back to the cached instance row ------
|
||||
ipam_by_key: Dict[Tuple[int, int], str] = {}
|
||||
for alloc in (
|
||||
db.query(IPAMAllocation)
|
||||
.filter(IPAMAllocation.status.in_(["allocated", "reserved"]))
|
||||
.all()
|
||||
):
|
||||
if alloc.proxmox_server_id and alloc.proxmox_vmid and alloc.ip_address:
|
||||
ipam_by_key[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc.ip_address
|
||||
|
||||
ipam_nets = db.query(IPAMNetwork).filter(
|
||||
IPAMNetwork.proxmox_server_id.in_(server_map.keys())
|
||||
).all()
|
||||
ipam_by_bridge: Dict[Tuple[int, str], IPAMNetwork] = {
|
||||
(n.proxmox_server_id, n.proxmox_bridge): n for n in ipam_nets if n.proxmox_bridge
|
||||
}
|
||||
|
||||
# -- Live data, in parallel: one dead cluster must not stall the rest -----
|
||||
live: Dict[int, Dict[str, Any]] = {}
|
||||
stale_servers: List[ProxmoxServer] = []
|
||||
now = time_lib.time()
|
||||
with _topo_lock: # guards the dict only, never a network call
|
||||
for server in servers:
|
||||
cached = _topo_cache.get(server.id)
|
||||
if cached and (now - cached["time"]) < TOPO_TTL:
|
||||
live[server.id] = cached["data"]
|
||||
elif server.is_online is False:
|
||||
# Offline servers contribute nothing but a connection timeout.
|
||||
continue
|
||||
else:
|
||||
stale_servers.append(server)
|
||||
|
||||
if stale_servers:
|
||||
tasks = [_run_in_executor(_collect_server, s, include_bridges) for s in stale_servers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for server, result in zip(stale_servers, results):
|
||||
if isinstance(result, HTTPException):
|
||||
warnings.append(TopologyWarning(
|
||||
server_id=server.id, server_name=server.name,
|
||||
code="no_credentials", message=str(result.detail),
|
||||
))
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning(f"Topology collection failed for {server.name}: {result}")
|
||||
warnings.append(TopologyWarning(
|
||||
server_id=server.id, server_name=server.name,
|
||||
code="error", message=str(result),
|
||||
))
|
||||
continue
|
||||
live[server.id] = result
|
||||
with _topo_lock:
|
||||
_topo_cache[server.id] = {"time": time_lib.time(), "data": result}
|
||||
|
||||
for server in servers:
|
||||
if server.id not in live and not any(w.server_id == server.id for w in warnings):
|
||||
warnings.append(TopologyWarning(
|
||||
server_id=server.id, server_name=server.name,
|
||||
code="offline", message=f"'{server.name}' is unreachable - showing cached data",
|
||||
))
|
||||
|
||||
# -- NICs (opt-in): bridge / VLAN / MAC straight from the guest configs ---
|
||||
nics_by_key: Dict[Tuple[int, int], List[Dict[str, Any]]] = {}
|
||||
if include_nics:
|
||||
pending: Dict[int, List[Tuple[str, int, str]]] = {}
|
||||
budget = NIC_MAX_FETCHES
|
||||
truncated = False
|
||||
now = time_lib.time()
|
||||
for sid, data in live.items():
|
||||
for guest in data["guests"]:
|
||||
vmid = _to_int(guest.get("vmid"))
|
||||
node = guest.get("node")
|
||||
if vmid is None or not node:
|
||||
continue
|
||||
if not see_all and (sid, vmid) not in owned_keys:
|
||||
continue
|
||||
with _nic_lock:
|
||||
cached = _nic_cache.get((sid, vmid))
|
||||
if cached and (now - cached["time"]) < NIC_TTL:
|
||||
nics_by_key[(sid, vmid)] = cached["data"]
|
||||
continue
|
||||
if budget <= 0:
|
||||
truncated = True
|
||||
continue
|
||||
budget -= 1
|
||||
pending.setdefault(sid, []).append((node, vmid, guest.get("type", "qemu")))
|
||||
|
||||
if truncated:
|
||||
warnings.append(TopologyWarning(
|
||||
code="nics_truncated",
|
||||
message=f"Network card details limited to {NIC_MAX_FETCHES} guests per request",
|
||||
))
|
||||
|
||||
if pending:
|
||||
server_ids = list(pending.keys())
|
||||
tasks = [_run_in_executor(_fetch_nics, server_map[sid], pending[sid]) for sid in server_ids]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
stamp = time_lib.time()
|
||||
for sid, result in zip(server_ids, results):
|
||||
if isinstance(result, BaseException):
|
||||
logger.debug(f"NIC collection failed for server {sid}: {result}")
|
||||
continue
|
||||
for vmid, nics in result.items():
|
||||
nics_by_key[(sid, vmid)] = nics
|
||||
with _nic_lock:
|
||||
_nic_cache[(sid, vmid)] = {"time": stamp, "data": nics}
|
||||
|
||||
# -- Assemble ------------------------------------------------------------
|
||||
def _build_guest(sid: int, node_name: str, res: Optional[Dict[str, Any]],
|
||||
row: Optional[VMInstance]) -> Optional[TopologyGuest]:
|
||||
res = res or {}
|
||||
vmid = _to_int(res.get("vmid"))
|
||||
if vmid is None and row is not None:
|
||||
vmid = row.vmid
|
||||
if vmid is None:
|
||||
return None
|
||||
|
||||
vm_type = res.get("type") or (row.vm_type if row else None) or "qemu"
|
||||
is_template = _truthy(res.get("template")) or bool(row and row.is_template)
|
||||
if is_template and not include_templates:
|
||||
return None
|
||||
status = res.get("status") or (row.status if row else None) or "unknown"
|
||||
if not include_stopped and status != "running":
|
||||
return None
|
||||
|
||||
tags = _split_tags(res.get("tags")) or _split_tags(row.tags if row else None)
|
||||
name = res.get("name") or (row.name if row else None) or f"vm-{vmid}"
|
||||
|
||||
return TopologyGuest(
|
||||
id=f"guest:{sid}:{vmid}",
|
||||
server_id=sid,
|
||||
node=node_name,
|
||||
vmid=vmid,
|
||||
type=vm_type,
|
||||
name=name,
|
||||
status=status,
|
||||
is_template=is_template,
|
||||
lock=res.get("lock"),
|
||||
cpu=_to_float(res.get("cpu")),
|
||||
cores=_to_int(res.get("maxcpu")) or (row.cores if row else None),
|
||||
mem=_to_int(res.get("mem")),
|
||||
maxmem=_to_int(res.get("maxmem")) or (row.memory if row else None),
|
||||
disk=_to_int(res.get("disk")),
|
||||
maxdisk=_to_int(res.get("maxdisk")),
|
||||
uptime=_to_int(res.get("uptime")),
|
||||
tags=tags,
|
||||
owner_id=row.owner_id if row else None,
|
||||
owner_username=(row.owner.username if row and row.owner else None),
|
||||
ip=ipam_by_key.get((sid, vmid)) or (row.ip_address if row else None),
|
||||
nics=[
|
||||
TopologyNic(**{k: v for k, v in nic.items() if k in TopologyNic.model_fields})
|
||||
for nic in nics_by_key.get((sid, vmid), [])
|
||||
],
|
||||
)
|
||||
|
||||
# cluster_name groups connections; a server without one stands alone.
|
||||
cluster_buckets: Dict[str, Dict[str, Any]] = {}
|
||||
for server in servers:
|
||||
key = f"cluster:{server.cluster_name}" if server.cluster_name else f"cluster:server:{server.id}"
|
||||
bucket = cluster_buckets.setdefault(key, {
|
||||
"id": key,
|
||||
"name": server.cluster_name or server.name,
|
||||
"kind": "cluster" if server.cluster_name else "standalone",
|
||||
"server_ids": [],
|
||||
"online": False,
|
||||
"nodes": {}, # node name -> TopologyNode, deduplicated per cluster
|
||||
})
|
||||
bucket["server_ids"].append(server.id)
|
||||
if server.id in live:
|
||||
bucket["online"] = True
|
||||
|
||||
data = live.get(server.id)
|
||||
if data is None:
|
||||
# Rebuild from the 10s VM cache so an offline server still shows up.
|
||||
by_node: Dict[str, List[VMInstance]] = {}
|
||||
for row in db_guests:
|
||||
if row.server_id != server.id:
|
||||
continue
|
||||
by_node.setdefault(row.node or server.name, []).append(row)
|
||||
for node_name, node_rows in by_node.items():
|
||||
if node_name in bucket["nodes"]:
|
||||
continue
|
||||
guests = [g for g in (_build_guest(server.id, node_name, None, r) for r in node_rows) if g]
|
||||
guests.sort(key=lambda g: g.vmid)
|
||||
bucket["nodes"][node_name] = TopologyNode(
|
||||
id=f"node:{server.id}:{node_name}",
|
||||
server_id=server.id, server_name=server.name, node=node_name,
|
||||
status="unknown", stale=True, guests=guests,
|
||||
)
|
||||
continue
|
||||
|
||||
guests_by_node: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for res in data["guests"]:
|
||||
node_name = res.get("node")
|
||||
vmid = _to_int(res.get("vmid"))
|
||||
if not node_name or vmid is None:
|
||||
continue
|
||||
if not see_all and (server.id, vmid) not in owned_keys:
|
||||
continue
|
||||
guests_by_node.setdefault(node_name, []).append(res)
|
||||
|
||||
for node_res in data["nodes"]:
|
||||
node_name = node_res.get("node")
|
||||
if not node_name or node_name in bucket["nodes"]:
|
||||
continue # same PVE cluster reachable through two connections
|
||||
bridges = []
|
||||
for entry in data["bridges"].get(node_name, []):
|
||||
ipam_net = ipam_by_bridge.get((server.id, entry["name"]))
|
||||
bridges.append(TopologyBridge(
|
||||
**entry,
|
||||
ipam_network_id=ipam_net.id if ipam_net else None,
|
||||
ipam_cidr=ipam_net.network if ipam_net else None,
|
||||
ipam_name=ipam_net.name if ipam_net else None,
|
||||
))
|
||||
guests = [
|
||||
g for g in (
|
||||
_build_guest(server.id, node_name, res,
|
||||
db_by_key.get((server.id, _to_int(res.get("vmid")))))
|
||||
for res in guests_by_node.get(node_name, [])
|
||||
) if g
|
||||
]
|
||||
guests.sort(key=lambda g: g.vmid)
|
||||
bucket["nodes"][node_name] = TopologyNode(
|
||||
id=f"node:{server.id}:{node_name}",
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
node=node_name,
|
||||
status=node_res.get("status") or "unknown",
|
||||
stale=False,
|
||||
cpu=_to_float(node_res.get("cpu")),
|
||||
maxcpu=_to_int(node_res.get("maxcpu")),
|
||||
mem=_to_int(node_res.get("mem")),
|
||||
maxmem=_to_int(node_res.get("maxmem")),
|
||||
uptime=_to_int(node_res.get("uptime")),
|
||||
bridges=bridges,
|
||||
guests=guests,
|
||||
)
|
||||
|
||||
clusters = [
|
||||
TopologyCluster(
|
||||
id=b["id"], name=b["name"], kind=b["kind"],
|
||||
server_ids=b["server_ids"], online=b["online"],
|
||||
nodes=sorted(b["nodes"].values(), key=lambda n: n.node),
|
||||
)
|
||||
for b in cluster_buckets.values()
|
||||
]
|
||||
clusters.sort(key=lambda c: (c.kind != "cluster", c.name.lower()))
|
||||
|
||||
node_count = sum(len(c.nodes) for c in clusters)
|
||||
guest_count = sum(len(n.guests) for c in clusters for n in c.nodes)
|
||||
|
||||
return NetworkTopologyResponse(
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
panel=TopologyPanelSummary(
|
||||
cluster_count=len(clusters), node_count=node_count, guest_count=guest_count,
|
||||
),
|
||||
clusters=clusters,
|
||||
warnings=warnings,
|
||||
)
|
||||
@@ -22,7 +22,7 @@ from ...ipam_service import IPAMService
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
can_view_all_instances)
|
||||
can_view_all_instances, pretty_os_from_template)
|
||||
from ...services.metrics_history import query_instance_metrics, timeframe_to_range
|
||||
|
||||
router = APIRouter()
|
||||
@@ -164,20 +164,26 @@ def get_all_virtual_machines(
|
||||
ipam_networks = db.query(IPAMNetwork).all()
|
||||
network_map = {n.id: n for n in ipam_networks}
|
||||
|
||||
# Build IPAM lookups
|
||||
# Build IPAM lookups.
|
||||
# Только по идентификатору гостя: сопоставление по имени давало чужой адрес
|
||||
# одноимённым гостям на разных серверах (два разных "wireguard").
|
||||
# У гостя может быть несколько адресов: в ipam_by_vmid кладём основной
|
||||
# (он идёт в колонку IP), в ipam_all_by_vmid — весь список для поля ips.
|
||||
ipam_by_vmid = {}
|
||||
ipam_by_name = {}
|
||||
|
||||
ipam_all_by_vmid = {}
|
||||
|
||||
for alloc in ipam_allocations:
|
||||
if alloc.proxmox_server_id and alloc.proxmox_vmid:
|
||||
ipam_by_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc
|
||||
key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
|
||||
ipam_all_by_vmid.setdefault(key, []).append(alloc)
|
||||
current = ipam_by_vmid.get(key)
|
||||
# Основной — тот, у кого стоит флаг; иначе самая ранняя запись
|
||||
# (аллокации старше миграции multi-IP флага не имеют).
|
||||
if current is None or (alloc.is_primary and not current.is_primary):
|
||||
ipam_by_vmid[key] = alloc
|
||||
elif alloc.resource_id:
|
||||
ipam_by_vmid[(None, alloc.resource_id)] = alloc
|
||||
if alloc.hostname:
|
||||
ipam_by_name[alloc.hostname.lower()] = alloc
|
||||
if alloc.resource_name:
|
||||
ipam_by_name[alloc.resource_name.lower()] = alloc
|
||||
|
||||
ipam_by_vmid.setdefault((None, alloc.resource_id), alloc)
|
||||
|
||||
# Build base query for cached VMs (not deleted, not templates)
|
||||
# joinedload(owner) avoids an N+1 query when rendering the owner column.
|
||||
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
|
||||
@@ -267,14 +273,29 @@ def get_all_virtual_machines(
|
||||
|
||||
# Get IP from IPAM or cache
|
||||
ipam_alloc = (
|
||||
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
|
||||
ipam_by_vmid.get((None, vm.vmid)) or
|
||||
ipam_by_name.get(vm.name.lower())
|
||||
ipam_by_vmid.get((vm.server_id, vm.vmid)) or
|
||||
ipam_by_vmid.get((None, vm.vmid))
|
||||
)
|
||||
|
||||
ip_address = ipam_alloc.ip_address if ipam_alloc else (vm.ip_address or "")
|
||||
ip_hostname = ipam_alloc.hostname if ipam_alloc else ""
|
||||
owner = ipam_alloc.allocated_by if ipam_alloc else ""
|
||||
|
||||
# Полный список адресов гостя: основной первым, дальше алиасы
|
||||
guest_allocs = sorted(
|
||||
ipam_all_by_vmid.get((vm.server_id, vm.vmid), []),
|
||||
key=lambda a: (not bool(a.is_primary), a.id),
|
||||
)
|
||||
ip_list = [
|
||||
{
|
||||
"ip": a.ip_address,
|
||||
"is_primary": bool(a.is_primary),
|
||||
"assignment_kind": a.assignment_kind,
|
||||
"target_interface": a.target_interface,
|
||||
"apply_status": a.apply_status,
|
||||
}
|
||||
for a in guest_allocs
|
||||
]
|
||||
|
||||
# Get network name from IPAM
|
||||
ip_network_name = ""
|
||||
@@ -283,8 +304,15 @@ def get_all_virtual_machines(
|
||||
if network:
|
||||
ip_network_name = network.name
|
||||
|
||||
# OS type - prefer template_name, fallback to os_type
|
||||
os_template = vm.template_name or vm.os_type or ("QEMU/KVM" if vm.vm_type == "qemu" else "Linux")
|
||||
# OS type - prefer a friendly name derived from the CT template file
|
||||
# (e.g. "Debian 13" instead of the raw "local:vztmpl/debian-13-...tar.zst"),
|
||||
# then fall back to the raw template_name / os_type.
|
||||
os_template = (
|
||||
pretty_os_from_template(vm.template_name)
|
||||
or vm.template_name
|
||||
or vm.os_type
|
||||
or ("QEMU/KVM" if vm.vm_type == "qemu" else "Linux")
|
||||
)
|
||||
if not vm.template_name and vm.vm_type == "lxc" and os_template:
|
||||
os_template = os_template.capitalize()
|
||||
|
||||
@@ -305,8 +333,11 @@ def get_all_virtual_machines(
|
||||
"cores": vm.cores or 0,
|
||||
"memory": vm.memory or 0,
|
||||
"disk": live.get('disk') or 0,
|
||||
# ip/ip_address — основной адрес (обратная совместимость),
|
||||
# ips — все адреса гостя
|
||||
"ip": ip_address,
|
||||
"ip_address": ip_address,
|
||||
"ips": ip_list,
|
||||
"ip_hostname": ip_network_name,
|
||||
"os": os_template,
|
||||
"os_template": os_template,
|
||||
@@ -1923,6 +1954,46 @@ async def resize_container_disk(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/{server_id}/container/{vmid}/disk/move")
|
||||
async def move_container_disk(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Переместить том контейнера в другое хранилище (move_volume)."""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
data = await request.json()
|
||||
disk = data.get('disk')
|
||||
target_storage = data.get('target_storage')
|
||||
if not disk or not target_storage:
|
||||
raise HTTPException(status_code=400, detail="Требуются параметры disk и target_storage")
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||
|
||||
result = client.move_container_disk(
|
||||
node, vmid, disk, target_storage,
|
||||
delete=bool(data.get('delete', True)),
|
||||
)
|
||||
if result.get('success'):
|
||||
logger.info(f"User {current_user.username} moved volume {disk} of container {vmid} to {target_storage}")
|
||||
return JSONResponse(content={"status": "success", "upid": result.get('upid')})
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving container {vmid} disk: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/container/{vmid}/config")
|
||||
def get_container_config(
|
||||
server_id: int,
|
||||
@@ -2837,79 +2908,6 @@ def get_vm_terminal(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/container/{vmid}/terminal")
|
||||
def get_container_terminal(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:console"))
|
||||
):
|
||||
"""Получить данные для xterm.js терминального подключения к LXC контейнеру"""
|
||||
import requests
|
||||
|
||||
# VPS-style user isolation: check container ownership for limited users
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
try:
|
||||
password_to_use = server.password if server.password else None
|
||||
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
|
||||
|
||||
if not password_to_use:
|
||||
raise HTTPException(status_code=400, detail="Terminal requires password authentication")
|
||||
|
||||
# 1. Получаем auth ticket
|
||||
auth_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
||||
data={"username": auth_username, "password": password_to_use},
|
||||
verify=server.verify_ssl,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if auth_response.status_code != 200:
|
||||
raise HTTPException(status_code=401, detail="Failed to authenticate to Proxmox")
|
||||
|
||||
auth_data = auth_response.json().get("data", {})
|
||||
auth_ticket = auth_data.get("ticket")
|
||||
csrf_token = auth_data.get("CSRFPreventionToken")
|
||||
|
||||
# 2. Создаём terminal proxy
|
||||
term_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/termproxy",
|
||||
headers={"CSRFPreventionToken": csrf_token},
|
||||
cookies={"PVEAuthCookie": auth_ticket},
|
||||
verify=server.verify_ssl,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if term_response.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create terminal proxy: {term_response.text}")
|
||||
|
||||
term_data = term_response.json().get("data", {})
|
||||
|
||||
response_data = {
|
||||
'port': term_data.get('port'),
|
||||
'ticket': term_data.get('ticket'),
|
||||
'host': server.ip_address,
|
||||
'node': node,
|
||||
'vmid': vmid,
|
||||
'type': 'lxc',
|
||||
'auth_ticket': auth_ticket
|
||||
}
|
||||
|
||||
logger.info(f"User {current_user.username} opened terminal for LXC {vmid}")
|
||||
return JSONResponse(content=response_data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting terminal for LXC {vmid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/{server_id}/container/{vmid}/exec")
|
||||
async def exec_in_container(
|
||||
server_id: int,
|
||||
@@ -3942,16 +3940,17 @@ def control_vm(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Управление VM (start/stop/restart/shutdown)"""
|
||||
if action not in ['start', 'stop', 'restart', 'shutdown']:
|
||||
"""Управление VM (start/stop/restart/shutdown/reset)"""
|
||||
if action not in ['start', 'stop', 'restart', 'shutdown', 'reset']:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
|
||||
# Проверка прав в зависимости от действия
|
||||
permission_map = {
|
||||
'start': 'vm:start',
|
||||
'stop': 'vm:stop',
|
||||
'shutdown': 'vm:stop',
|
||||
'restart': 'vm:restart',
|
||||
'reset': 'vm:restart',
|
||||
}
|
||||
if not current_user.has_permission(permission_map[action]):
|
||||
raise HTTPException(
|
||||
@@ -3993,6 +3992,9 @@ def control_vm(
|
||||
elif action == 'shutdown':
|
||||
upid = client.shutdown_vm(node, vmid)
|
||||
success = bool(upid)
|
||||
elif action == 'reset':
|
||||
upid = client.reset_vm(node, vmid)
|
||||
success = bool(upid)
|
||||
else: # restart
|
||||
upid = client.restart_vm(node, vmid)
|
||||
success = bool(upid)
|
||||
@@ -4000,7 +4002,7 @@ def control_vm(
|
||||
action_name = 'kill' if action == 'stop' and force else action
|
||||
if success:
|
||||
# Immediately update vm_instances cache so page refresh returns correct status
|
||||
expected_status = 'running' if action in ('start', 'restart') else 'stopped'
|
||||
expected_status = 'running' if action in ('start', 'restart', 'reset') else 'stopped'
|
||||
try:
|
||||
cached_vm = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
@@ -4019,6 +4021,7 @@ def control_vm(
|
||||
'start': f"Запуск VM {vm_name or vmid}",
|
||||
'stop': f"Остановка VM {vm_name or vmid}",
|
||||
'restart': f"Перезапуск VM {vm_name or vmid}",
|
||||
'reset': f"Жёсткий сброс VM {vm_name or vmid}",
|
||||
}
|
||||
try:
|
||||
ProxmoxTaskService.register(
|
||||
|
||||
@@ -1072,6 +1072,12 @@ async def deploy_vm_from_template(
|
||||
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||
instance_owner_id = owner.id
|
||||
|
||||
from ..schemas import validate_instance_name
|
||||
try:
|
||||
deploy_data.name = validate_instance_name(deploy_data.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Enforce per-user resource quota (no-op if owner has no quota set)
|
||||
from ..services.quota_service import check_quota
|
||||
check_quota(db, instance_owner_id, add_cores=cores, add_memory_mb=memory, add_disk_gb=disk)
|
||||
|
||||
@@ -721,23 +721,26 @@ def poc_install(db: Session, spec: InstallSpec, on_step: StepCb = _noop) -> Inst
|
||||
on_step(f"Создание LXC VMID {vmid} на ноде {node}...", 20)
|
||||
ip_cfg = spec.ip_config or "ip=dhcp"
|
||||
net0 = f"name=eth0,bridge={spec.bridge},{ip_cfg},firewall=1"
|
||||
upid = client.create_lxc_container(
|
||||
node=node,
|
||||
vmid=vmid,
|
||||
ostemplate=spec.ostemplate,
|
||||
hostname=spec.name,
|
||||
cores=spec.cores,
|
||||
memory=spec.memory,
|
||||
swap=spec.swap,
|
||||
storage=spec.storage,
|
||||
rootfs_size=spec.disk,
|
||||
net0=net0,
|
||||
features=spec.features, # nesting=1,keyctl=1 — критично для Docker
|
||||
unprivileged=True,
|
||||
start_after_create=False,
|
||||
description=f"pvemanager-appstore: {spec.name}",
|
||||
nameserver=spec.nameserver or settings.APPSTORE_NAMESERVER,
|
||||
)
|
||||
try:
|
||||
upid = client.create_lxc_container(
|
||||
node=node,
|
||||
vmid=vmid,
|
||||
ostemplate=spec.ostemplate,
|
||||
hostname=spec.name,
|
||||
cores=spec.cores,
|
||||
memory=spec.memory,
|
||||
swap=spec.swap,
|
||||
storage=spec.storage,
|
||||
rootfs_size=spec.disk,
|
||||
net0=net0,
|
||||
features=spec.features, # nesting=1,keyctl=1 — критично для Docker
|
||||
unprivileged=True,
|
||||
start_after_create=False,
|
||||
description=f"pvemanager-appstore: {spec.name}",
|
||||
nameserver=spec.nameserver or settings.APPSTORE_NAMESERVER,
|
||||
)
|
||||
except Exception as e:
|
||||
raise PocError(f"Ошибка создания контейнера: {e}") from e
|
||||
if not upid:
|
||||
raise PocError("Proxmox не вернул UPID создания контейнера")
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ LXC-шаблоны (vztmpl) здесь НЕ хардкодятся: их спи
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
_GH = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-14'
|
||||
_GH2 = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-18'
|
||||
|
||||
# (id_base, os, version, name, icon, {arch: (url, checksum_or_None)})
|
||||
# amd64 → собственное GitHub-зеркало + sha256; arm64 → upstream без checksum
|
||||
@@ -67,6 +68,46 @@ _CLOUD_IMAGES = [
|
||||
'arm64': ('https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'alma-10-cloud', 'almalinux', '10', 'AlmaLinux 10 GenericCloud', 'almalinux',
|
||||
{
|
||||
'amd64': (f'{_GH2}/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2',
|
||||
'd0bdbc4fabb365b27e8d4a77118c1100ebdab42341312338e63724a2f9ee217f'),
|
||||
'arm64': ('https://repo.almalinux.org/almalinux/10/cloud/aarch64/images/AlmaLinux-10-GenericCloud-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'centos-stream-9-cloud', 'centos', '9-stream', 'CentOS Stream 9 GenericCloud', 'centos',
|
||||
{
|
||||
'amd64': (f'{_GH2}/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2',
|
||||
'63c13542e94678b0a41d318e7758f1cf23609564b1cbaa9051616457ce771903'),
|
||||
'arm64': ('https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'debian-13-cloud', 'debian', '13', 'Debian 13 (Trixie) GenericCloud', 'debian',
|
||||
{
|
||||
'amd64': (f'{_GH2}/debian-13-genericcloud-amd64.qcow2',
|
||||
'42a4931fc94840d75c9a58ba4cde0bc98365d3998645872c9f4c7244f682198e'),
|
||||
'arm64': ('https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'fedora-41-cloud', 'fedora', '41', 'Fedora 41 Cloud', 'fedora',
|
||||
{
|
||||
'amd64': (f'{_GH2}/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2',
|
||||
'6c12ad70a8b5058f032de10d463bda8c93391162e5a72a90c79dd548ecc8e378'),
|
||||
'arm64': ('https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-41-1.4.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'ubuntu-26.04-cloud', 'ubuntu', '26.04', 'Ubuntu 26.04 LTS (Resolute) Cloud', 'ubuntu',
|
||||
{
|
||||
'amd64': (f'{_GH2}/resolute-server-cloudimg-amd64.img',
|
||||
'b9cd03eb2b9b2ddd59c68c7d577a56835ba7da3d713e085336f493290daec9c8'),
|
||||
'arm64': ('https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img', None),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, text
|
||||
|
||||
from .models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
|
||||
from .models import (
|
||||
IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, VMInstance, ProxmoxServer
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
@@ -18,6 +20,21 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
"""Привести значение к JSON-сериализуемому виду для колонок истории.
|
||||
|
||||
old_value/new_value — JSON-колонки, а в diff попадают и datetime-поля
|
||||
(last_seen, expires_at): без конвертации INSERT падает на json.dumps.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {key: _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -142,7 +159,8 @@ class IPAMService:
|
||||
self,
|
||||
network_id: int,
|
||||
pool_id: Optional[int] = None,
|
||||
prefer_sequential: bool = True
|
||||
prefer_sequential: bool = True,
|
||||
allow_network_scan: bool = False
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get the next available IP address from a network or specific pool.
|
||||
@@ -151,6 +169,9 @@ class IPAMService:
|
||||
network_id: Network ID
|
||||
pool_id: Optional specific pool ID
|
||||
prefer_sequential: If True, prefer sequential IPs; if False, can use any gap
|
||||
allow_network_scan: искать по всей подсети, когда auto-assign пулов
|
||||
нет. Нужно там, где адрес выдаётся точечно (доп. IP инстанса),
|
||||
а не по политике пула.
|
||||
|
||||
Returns:
|
||||
Available IP address string or None if no IPs available
|
||||
@@ -179,7 +200,7 @@ class IPAMService:
|
||||
IPAMPool.auto_assign == True
|
||||
).order_by(IPAMPool.id).all()
|
||||
|
||||
if not pools:
|
||||
if not pools and not allow_network_scan:
|
||||
logger.warning(f"No active auto-assign pools found for network {network_id}")
|
||||
return None
|
||||
|
||||
@@ -213,7 +234,34 @@ class IPAMService:
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid IP range in pool {pool.id}: {e}")
|
||||
continue
|
||||
|
||||
|
||||
# Пулов нет (или в них всё занято) — при явном разрешении идём по всей
|
||||
# подсети: адрес и сеть/broadcast исключает сам ip_network.hosts().
|
||||
if allow_network_scan and not pool_id:
|
||||
# Вне пула в подсети живут адреса, которых нет в IPAM: сами ноды
|
||||
# Proxmox и гости, чей адрес известен панели только из кэша.
|
||||
# Без этого скан спокойно предложил бы адрес работающей ноды.
|
||||
known = set(occupied)
|
||||
known.update(
|
||||
row[0] for row in self.db.query(VMInstance.ip_address).filter(
|
||||
VMInstance.ip_address.isnot(None),
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).all() if row[0]
|
||||
)
|
||||
known.update(
|
||||
row[0] for row in self.db.query(ProxmoxServer.ip_address).filter(
|
||||
ProxmoxServer.ip_address.isnot(None)
|
||||
).all() if row[0]
|
||||
)
|
||||
try:
|
||||
for ip_obj in ipaddress.ip_network(network.network, strict=False).hosts():
|
||||
ip = str(ip_obj)
|
||||
if ip not in known:
|
||||
logger.info(f"Found available IP: {ip} in network {network.network}")
|
||||
return ip
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid network {network.network}: {e}")
|
||||
|
||||
logger.warning(f"No available IPs found in network {network_id}")
|
||||
return None
|
||||
|
||||
@@ -234,7 +282,11 @@ class IPAMService:
|
||||
allocation_type: str = "static",
|
||||
status: str = "allocated",
|
||||
allocated_by: Optional[str] = None,
|
||||
notes: Optional[str] = None
|
||||
notes: Optional[str] = None,
|
||||
is_primary: bool = False,
|
||||
assignment_kind: str = "primary",
|
||||
target_interface: Optional[str] = None,
|
||||
apply_status: Optional[str] = None
|
||||
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||
"""
|
||||
Allocate an IP address.
|
||||
@@ -310,7 +362,11 @@ class IPAMService:
|
||||
hostname=hostname,
|
||||
fqdn=fqdn,
|
||||
allocated_by=allocated_by,
|
||||
notes=notes
|
||||
notes=notes,
|
||||
is_primary=is_primary,
|
||||
assignment_kind=assignment_kind,
|
||||
target_interface=target_interface,
|
||||
apply_status=apply_status
|
||||
)
|
||||
|
||||
self.db.add(allocation)
|
||||
@@ -436,46 +492,51 @@ class IPAMService:
|
||||
reason: Optional[str] = None
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Release IP allocation by Proxmox VM/container ID.
|
||||
Used when VM/container is deleted.
|
||||
|
||||
Args:
|
||||
proxmox_server_id: Proxmox server ID
|
||||
proxmox_vmid: VM/container ID
|
||||
released_by: Username performing the release
|
||||
reason: Reason for release
|
||||
|
||||
Release every IP allocation of a Proxmox VM/container.
|
||||
Used when the VM/container is deleted.
|
||||
|
||||
У гостя может быть несколько адресов (основной + алиасы), поэтому
|
||||
освобождаются все: иначе после удаления гостя часть адресов навсегда
|
||||
оставалась бы занятой.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error message or released IP)
|
||||
Tuple of (success, comma-separated released IPs or error message)
|
||||
"""
|
||||
# First try to find by proxmox_server_id and proxmox_vmid
|
||||
allocation = self.find_allocation_by_resource(proxmox_server_id, proxmox_vmid)
|
||||
|
||||
allocations = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
|
||||
|
||||
# Fallback: search by resource_id if proxmox fields are not set.
|
||||
# ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление
|
||||
# VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid
|
||||
# на сервере B (записи с заполненным server_id находит основной поиск).
|
||||
if not allocation:
|
||||
allocation = self.db.query(IPAMAllocation).filter(
|
||||
if not allocations:
|
||||
allocations = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.resource_id == proxmox_vmid,
|
||||
IPAMAllocation.proxmox_server_id.is_(None),
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).first()
|
||||
if allocation:
|
||||
logger.debug(f"Found allocation by resource_id={proxmox_vmid} (legacy)")
|
||||
|
||||
if not allocation:
|
||||
).all()
|
||||
if allocations:
|
||||
logger.debug(f"Found allocation(s) by resource_id={proxmox_vmid} (legacy)")
|
||||
|
||||
if not allocations:
|
||||
logger.debug(f"No IPAM allocation found for server {proxmox_server_id}, vmid {proxmox_vmid}")
|
||||
return False, None # Not an error, just no allocation to release
|
||||
|
||||
ip_address = allocation.ip_address
|
||||
success, error = self.release_ip(ip_address, released_by, reason)
|
||||
|
||||
if success:
|
||||
logger.info(f"Auto-released IP {ip_address} for deleted VM/container {proxmox_vmid}")
|
||||
return True, ip_address
|
||||
|
||||
return False, error
|
||||
|
||||
released, errors = [], []
|
||||
for allocation in allocations:
|
||||
ip_address = allocation.ip_address
|
||||
success, error = self.release_ip(ip_address, released_by, reason)
|
||||
if success:
|
||||
released.append(ip_address)
|
||||
else:
|
||||
errors.append(error or ip_address)
|
||||
|
||||
if released:
|
||||
logger.info(
|
||||
f"Auto-released IP(s) {', '.join(released)} for deleted VM/container {proxmox_vmid}"
|
||||
)
|
||||
return True, ", ".join(released)
|
||||
|
||||
return False, "; ".join(errors) if errors else None
|
||||
|
||||
def update_allocation(
|
||||
self,
|
||||
@@ -537,17 +598,65 @@ class IPAMService:
|
||||
|
||||
return allocation, None
|
||||
|
||||
def find_allocations_by_resource(
|
||||
self,
|
||||
proxmox_server_id: int,
|
||||
proxmox_vmid: int
|
||||
) -> List[IPAMAllocation]:
|
||||
"""Все активные адреса гостя: основной первым, дальше по дате выдачи."""
|
||||
rows = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id == proxmox_server_id,
|
||||
IPAMAllocation.proxmox_vmid == proxmox_vmid,
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
return sorted(rows, key=lambda a: (not bool(a.is_primary), a.id))
|
||||
|
||||
def find_allocation_by_resource(
|
||||
self,
|
||||
proxmox_server_id: int,
|
||||
proxmox_vmid: int
|
||||
) -> Optional[IPAMAllocation]:
|
||||
"""Find active allocation by Proxmox VM/container"""
|
||||
return self.db.query(IPAMAllocation).filter(
|
||||
"""Основной адрес гостя (у гостя их может быть несколько).
|
||||
|
||||
Если флаг is_primary нигде не проставлен — например, записи старше
|
||||
миграции multi-IP, — берём самую раннюю аллокацию.
|
||||
"""
|
||||
rows = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
|
||||
return rows[0] if rows else None
|
||||
|
||||
def set_primary(
|
||||
self,
|
||||
proxmox_server_id: int,
|
||||
proxmox_vmid: int,
|
||||
allocation_id: int,
|
||||
changed_by: Optional[str] = None
|
||||
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||
"""Сделать адрес основным, сняв флаг с прежнего. Основной ровно один."""
|
||||
rows = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id == proxmox_server_id,
|
||||
IPAMAllocation.proxmox_vmid == proxmox_vmid,
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).first()
|
||||
IPAMAllocation.proxmox_vmid == proxmox_vmid
|
||||
).all()
|
||||
target = next((a for a in rows if a.id == allocation_id), None)
|
||||
if not target:
|
||||
return None, "Allocation does not belong to this guest"
|
||||
|
||||
for row in rows:
|
||||
row.is_primary = (row.id == allocation_id)
|
||||
|
||||
self._log_history(
|
||||
ip_address=target.ip_address,
|
||||
network_id=target.network_id,
|
||||
action="modified",
|
||||
new_value={'is_primary': True},
|
||||
resource_type=target.resource_type,
|
||||
resource_id=target.resource_id,
|
||||
resource_name=target.resource_name,
|
||||
performed_by=changed_by,
|
||||
notes="Marked as primary address"
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(target)
|
||||
return target, None
|
||||
|
||||
def release_all_by_server(self, proxmox_server_id: int, released_by: str = None) -> int:
|
||||
"""
|
||||
@@ -675,7 +784,17 @@ class IPAMService:
|
||||
return True, None
|
||||
|
||||
# ==================== Sync & Scan Operations ====================
|
||||
|
||||
|
||||
def _guest_exists(self, server_id: Optional[int], vmid: Optional[int]) -> bool:
|
||||
"""Существует ли ещё гость, за которым числится аллокация."""
|
||||
if server_id is None or vmid is None:
|
||||
return False
|
||||
return self.db.query(VMInstance.id).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first() is not None
|
||||
|
||||
def sync_from_proxmox_vm(
|
||||
self,
|
||||
network_id: int,
|
||||
@@ -686,18 +805,36 @@ class IPAMService:
|
||||
ip_address: str,
|
||||
node: str,
|
||||
mac_address: Optional[str] = None,
|
||||
synced_by: str = "system"
|
||||
synced_by: str = "system",
|
||||
target_interface: Optional[str] = None,
|
||||
assignment_kind: str = "primary"
|
||||
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
|
||||
"""
|
||||
Sync/create allocation for a Proxmox VM.
|
||||
Called when discovering VMs from Proxmox.
|
||||
|
||||
Гость может иметь несколько адресов: первый заведённый становится
|
||||
основным, последующие — обычными записями того же гостя.
|
||||
"""
|
||||
# Check if already allocated
|
||||
existing = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.ip_address == ip_address
|
||||
).first()
|
||||
|
||||
|
||||
if existing:
|
||||
# Два живых гостя с одним адресом (например, устаревший ip= в конфиге
|
||||
# остановленного контейнера) не должны молча отбирать аллокацию друг
|
||||
# у друга: владелец сохраняется, конфликт возвращается вызывающему.
|
||||
belongs_to_other = (
|
||||
existing.proxmox_vmid is not None
|
||||
and (existing.proxmox_server_id, existing.proxmox_vmid) != (proxmox_server_id, vmid)
|
||||
)
|
||||
if belongs_to_other and self._guest_exists(existing.proxmox_server_id, existing.proxmox_vmid):
|
||||
return None, (
|
||||
f"IP {ip_address} is already linked to "
|
||||
f"{existing.resource_name or 'unknown'} (VMID {existing.proxmox_vmid}) — "
|
||||
f"duplicate address, resolve it in Proxmox first"
|
||||
)
|
||||
# Update existing allocation
|
||||
if existing.proxmox_vmid != vmid or existing.proxmox_server_id != proxmox_server_id:
|
||||
return self.update_allocation(
|
||||
@@ -725,7 +862,14 @@ class IPAMService:
|
||||
self.db.commit()
|
||||
return existing, None
|
||||
|
||||
# Create new allocation
|
||||
# Create new allocation. Основным делаем первый адрес гостя —
|
||||
# именно он попадёт в колонку IP списка инстансов.
|
||||
has_primary = self.db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id == proxmox_server_id,
|
||||
IPAMAllocation.proxmox_vmid == vmid,
|
||||
IPAMAllocation.is_primary.is_(True)
|
||||
).first() is not None
|
||||
|
||||
return self.allocate_ip(
|
||||
ip_address=ip_address,
|
||||
network_id=network_id,
|
||||
@@ -736,7 +880,10 @@ class IPAMService:
|
||||
proxmox_node=node,
|
||||
mac_address=mac_address,
|
||||
allocated_by=synced_by,
|
||||
notes="Auto-synced from Proxmox"
|
||||
notes="Auto-synced from Proxmox",
|
||||
is_primary=not has_primary,
|
||||
assignment_kind="primary" if not has_primary else assignment_kind,
|
||||
target_interface=target_interface
|
||||
)
|
||||
|
||||
def detect_conflicts(self, network_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
@@ -801,8 +948,8 @@ class IPAMService:
|
||||
ip_address=ip_address,
|
||||
network_id=network_id,
|
||||
action=action,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
old_value=_json_safe(old_value),
|
||||
new_value=_json_safe(new_value),
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
resource_name=resource_name,
|
||||
|
||||
@@ -87,6 +87,21 @@ class IPAMAllocation(Base):
|
||||
|
||||
status = Column(String(20), default="allocated", nullable=False)
|
||||
allocation_type = Column(String(20), default="static", nullable=False)
|
||||
|
||||
# Гость может держать несколько адресов: ровно один из них основной
|
||||
# (живёт в net0/ipconfig0 и показывается в колонке IP), остальные
|
||||
# навешиваются алиасами на интерфейс уже внутри гостя.
|
||||
is_primary = Column(Boolean, default=False, nullable=False)
|
||||
assignment_kind = Column(String(10), default="primary", nullable=False) # primary | alias
|
||||
target_interface = Column(String(20), nullable=True) # eth0, ens18, ...
|
||||
|
||||
# Состояние применения алиаса к живому гостю.
|
||||
# applied — поднят и закреплён в конфиге ОС; runtime_only — поднят, но
|
||||
# стек не распознан и ребут его не переживёт; pending — гость был
|
||||
# недоступен; failed — скрипт вернул ошибку.
|
||||
apply_status = Column(String(20), nullable=True)
|
||||
apply_error = Column(Text, nullable=True)
|
||||
applied_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
hostname = Column(String(255), nullable=True, index=True)
|
||||
fqdn = Column(String(255), nullable=True)
|
||||
@@ -106,6 +121,7 @@ class IPAMAllocation(Base):
|
||||
Index('idx_ipam_alloc_network', 'network_id'),
|
||||
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
|
||||
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
|
||||
Index('idx_ipam_alloc_primary', 'proxmox_server_id', 'proxmox_vmid', 'is_primary'),
|
||||
Index('idx_ipam_alloc_status', 'status'),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from .client import ProxmoxClient, get_proxmox_resources, _run_in_executor, clear_all_cache, clear_server_cache
|
||||
from .net_parser import ParsedNic, parse_net_string, parse_guest_nics
|
||||
|
||||
__all__ = [
|
||||
'ProxmoxClient',
|
||||
'get_proxmox_resources',
|
||||
'_run_in_executor',
|
||||
'clear_all_cache',
|
||||
'clear_server_cache'
|
||||
'clear_server_cache',
|
||||
'ParsedNic',
|
||||
'parse_net_string',
|
||||
'parse_guest_nics',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import ipaddress
|
||||
import urllib3
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -10,6 +11,7 @@ from functools import lru_cache
|
||||
from contextlib import asynccontextmanager
|
||||
import base64
|
||||
|
||||
from .net_parser import parse_guest_nics
|
||||
from .mixins.vm import VmMixin
|
||||
from .mixins.lxc import LxcMixin
|
||||
from .mixins.cluster import ClusterMixin
|
||||
@@ -1541,6 +1543,34 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.info(f"Диск {disk} LXC {vmid} изменен на {size}")
|
||||
return True
|
||||
|
||||
def move_container_disk(self, node: str, vmid: int, disk: str, target_storage: str,
|
||||
delete: bool = True) -> Dict:
|
||||
"""Переместить том LXC контейнера в другое хранилище (POST lxc/{vmid}/move_volume).
|
||||
|
||||
Args:
|
||||
disk: имя тома (rootfs, mp0, mp1, ...)
|
||||
target_storage: целевое хранилище
|
||||
delete: удалить исходный том после копирования
|
||||
Returns:
|
||||
{"success": bool, "upid"?: str, "error"?: str}
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {"volume": disk, "storage": target_storage, "delete": 1 if delete else 0}
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).move_volume.post(**params)
|
||||
if isinstance(upid, str):
|
||||
if not self.wait_for_task(node, upid, timeout=3600):
|
||||
status = self.get_task_status(node, upid) or {}
|
||||
reason = status.get("exitstatus") or "move_volume task failed"
|
||||
logger.error(f"Move тома {disk} LXC {vmid} провалился: {reason}")
|
||||
return {"success": False, "error": f"Proxmox: {reason}"}
|
||||
logger.info(f"Том {disk} LXC {vmid} перемещён в {target_storage}")
|
||||
return {"success": True, "upid": upid if isinstance(upid, str) else None}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка перемещения тома {disk} LXC {vmid}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_vm_interfaces(self, node: str, vmid: int) -> List[Dict]:
|
||||
"""
|
||||
Получить сетевые интерфейсы VM через QEMU guest agent
|
||||
@@ -1614,62 +1644,89 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.debug(f"netstat unavailable for node {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_container_interfaces(self, node: str, vmid: int) -> List[Dict]:
|
||||
def get_container_interfaces(self, node: str, vmid: int,
|
||||
include_live: bool = True) -> List[Dict]:
|
||||
"""
|
||||
Получить сетевые интерфейсы контейнера
|
||||
|
||||
Получить сетевые интерфейсы контейнера с их IP адресами.
|
||||
|
||||
LXC держит адрес внутри строки netN
|
||||
("name=eth0,bridge=vmbr0,hwaddr=..,ip=10.0.0.5/24"); ключа ipconfigN у
|
||||
контейнеров не бывает — это cloud-init QEMU. При ip=dhcp статики в
|
||||
конфиге нет вовсе, поэтому фактические адреса запущенного контейнера
|
||||
дочитываются из /nodes/{node}/lxc/{vmid}/interfaces.
|
||||
|
||||
Args:
|
||||
node: Имя ноды
|
||||
vmid: ID контейнера
|
||||
|
||||
include_live: опрашивать ли /interfaces (бесполезно для остановленного CT)
|
||||
|
||||
Returns:
|
||||
Список интерфейсов с IP адресами
|
||||
Список интерфейсов: {'name', 'hardware_address', 'ips': [{'address', 'type', 'prefix'}]}
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
|
||||
interfaces: Dict[str, Dict] = {}
|
||||
|
||||
def _slot(name: str, mac: str = '') -> Dict:
|
||||
iface = interfaces.setdefault(
|
||||
name, {'name': name, 'hardware_address': '', 'ips': []}
|
||||
)
|
||||
if mac and not iface['hardware_address']:
|
||||
iface['hardware_address'] = mac
|
||||
return iface
|
||||
|
||||
def _add_ip(iface: Dict, value: str) -> None:
|
||||
"""Принимает '10.0.0.5/24' либо '10.0.0.5'; мусор и dhcp игнорирует."""
|
||||
addr, _, prefix = (value or '').strip().partition('/')
|
||||
if not addr or addr in ('dhcp', 'manual', 'auto'):
|
||||
return
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(addr)
|
||||
except ValueError:
|
||||
return
|
||||
if ip_obj.is_loopback or ip_obj.is_link_local:
|
||||
return
|
||||
if any(existing['address'] == addr for existing in iface['ips']):
|
||||
return
|
||||
iface['ips'].append({
|
||||
'address': addr,
|
||||
'type': 'ipv6' if ip_obj.version == 6 else 'ipv4',
|
||||
'prefix': int(prefix) if prefix.isdigit() else (128 if ip_obj.version == 6 else 32),
|
||||
})
|
||||
|
||||
# 1) Статика из конфига контейнера
|
||||
try:
|
||||
# Для LXC можем получить IP из конфигурации
|
||||
config = self.proxmox.nodes(node).lxc(vmid).config.get()
|
||||
interfaces = []
|
||||
|
||||
# Проверяем net0, net1 и т.д.
|
||||
for i in range(10):
|
||||
net_key = f'net{i}'
|
||||
if net_key in config:
|
||||
net_config = config[net_key]
|
||||
# Парсим строку конфигурации сети
|
||||
iface_info = {'name': f'eth{i}', 'ips': []}
|
||||
|
||||
# Пытаемся извлечь MAC адрес
|
||||
if 'hwaddr=' in net_config:
|
||||
mac = net_config.split('hwaddr=')[1].split(',')[0]
|
||||
iface_info['hardware_address'] = mac
|
||||
|
||||
interfaces.append(iface_info)
|
||||
|
||||
# Также проверяем IP из конфигурации
|
||||
for i in range(10):
|
||||
ipconfig_key = f'ipconfig{i}'
|
||||
if ipconfig_key in config and interfaces and i < len(interfaces):
|
||||
ipconfig = config[ipconfig_key]
|
||||
# Парсим ip=192.168.1.10/24,gw=192.168.1.1
|
||||
if 'ip=' in ipconfig:
|
||||
ip_part = ipconfig.split('ip=')[1].split(',')[0]
|
||||
if ip_part != 'dhcp':
|
||||
# Разделяем IP и префикс
|
||||
if '/' in ip_part:
|
||||
ip, prefix = ip_part.split('/')
|
||||
interfaces[i]['ips'].append({
|
||||
'address': ip,
|
||||
'type': 'ipv4',
|
||||
'prefix': int(prefix)
|
||||
})
|
||||
|
||||
return [iface for iface in interfaces if iface.get('ips')]
|
||||
for nic in parse_guest_nics(config):
|
||||
iface = _slot(nic.name or f'eth{nic.index}', nic.mac or '')
|
||||
_add_ip(iface, nic.ip or '')
|
||||
_add_ip(iface, nic.ip6 or '')
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось получить сетевые интерфейсы контейнера {vmid}: {e}")
|
||||
return []
|
||||
logger.debug(f"Не удалось прочитать конфиг контейнера {vmid}: {e}")
|
||||
|
||||
# 2) Живые адреса — единственный источник для DHCP-контейнеров.
|
||||
# Если статика уже найдена, лишний запрос не делаем: у остановленного
|
||||
# контейнера этот эндпоинт всё равно отдаёт ошибку.
|
||||
has_static_v4 = any(
|
||||
ip_info['type'] == 'ipv4'
|
||||
for iface in interfaces.values() for ip_info in iface['ips']
|
||||
)
|
||||
if not has_static_v4 and include_live:
|
||||
try:
|
||||
for iface_data in (self.proxmox.nodes(node).lxc(vmid).interfaces.get() or []):
|
||||
name = iface_data.get('name') or ''
|
||||
if not name or name == 'lo':
|
||||
continue
|
||||
iface = _slot(name, (iface_data.get('hwaddr') or '').upper())
|
||||
_add_ip(iface, iface_data.get('inet') or '')
|
||||
_add_ip(iface, iface_data.get('inet6') or '')
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Интерфейсы контейнера {vmid} недоступны (возможно, остановлен): {e}"
|
||||
)
|
||||
|
||||
return [iface for iface in interfaces.values() if iface['ips']]
|
||||
|
||||
def execute_command(self, node: str, vmid: int, command: str, timeout: int = 30) -> Dict:
|
||||
"""
|
||||
@@ -2093,7 +2150,7 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания LXC контейнера {vmid}: {e}")
|
||||
return None
|
||||
raise
|
||||
|
||||
def clone_lxc_container(
|
||||
self,
|
||||
|
||||
@@ -239,6 +239,16 @@ class StorageMixin:
|
||||
logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}")
|
||||
return status
|
||||
|
||||
def scan_nfs_exports(self, node: str, server: str) -> List[Dict]:
|
||||
"""
|
||||
Получить список NFS-экспортов удалённого сервера через
|
||||
GET /nodes/{node}/scan/nfs — для выбора export в диалоге добавления
|
||||
хранилища вместо ручного ввода пути.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
return self.proxmox.nodes(node).scan.nfs.get(server=server)
|
||||
|
||||
def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
|
||||
"""Get storages available on a specific node, optionally filtered by content type"""
|
||||
if not self.proxmox:
|
||||
|
||||
@@ -41,6 +41,18 @@ class VmMixin:
|
||||
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def reset_vm(self, node: str, vmid: int) -> Optional[str]:
|
||||
"""Жёсткий сброс VM (аналог кнопки Reset — без ACPI, без остановки/старта заново)."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).status.reset.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка жёсткого сброса VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def _wait_vm_stopped(self, node: str, vmid: int, timeout: int) -> bool:
|
||||
"""Опрашивать статус ВМ, пока не 'stopped' или не выйдет timeout секунд."""
|
||||
deadline = time.time() + timeout
|
||||
|
||||
139
backend/app/proxmox/net_parser.py
Normal file
139
backend/app/proxmox/net_parser.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Parser for Proxmox ``netN`` guest NIC strings.
|
||||
|
||||
Proxmox stores every guest network card as one comma-separated key=value
|
||||
string in the guest config (``net0``, ``net1``, …). Two dialects exist:
|
||||
|
||||
* QEMU — ``virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=100,firewall=1``
|
||||
The NIC model is itself the key holding the MAC address.
|
||||
* LXC — ``name=eth0,bridge=vmbr0,hwaddr=AA:BB:CC:DD:EE:FF,ip=10.0.0.5/24,``
|
||||
``gw=10.0.0.1,type=veth,firewall=1``
|
||||
|
||||
The repository only ever *builds* these strings (``api/proxmox/lxc.py``,
|
||||
``proxmox/mixins/vm.py``); this module reads them back so callers can learn
|
||||
which bridge/VLAN a guest actually sits on.
|
||||
|
||||
Deliberately free of any ``proxmoxer`` import so both the API layer and the
|
||||
client mixins can use it. Parsing never raises: an unparsable token is skipped
|
||||
rather than failing the whole request.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
import re
|
||||
|
||||
# NIC models QEMU accepts. The model name doubles as the key carrying the MAC,
|
||||
# which is the only way to tell "virtio=AA:.." from an ordinary option.
|
||||
QEMU_MODELS = {
|
||||
"virtio", "e1000", "e1000e", "e1000-82540em", "e1000-82544gc",
|
||||
"e1000-82545em", "rtl8139", "vmxnet3", "ne2k_pci", "ne2k_isa",
|
||||
"pcnet", "i82551", "i82557b", "i82559er",
|
||||
}
|
||||
|
||||
_NET_KEY_RE = re.compile(r"^net(\d+)$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNic:
|
||||
"""One guest network interface, normalised across the QEMU/LXC dialects."""
|
||||
|
||||
key: str # "net0"
|
||||
index: int # 0
|
||||
model: Optional[str] = None # virtio / e1000 / … (QEMU only)
|
||||
mac: Optional[str] = None # uppercase, from model= or hwaddr=
|
||||
bridge: Optional[str] = None # vmbr0 or an SDN vnet
|
||||
vlan_tag: Optional[int] = None
|
||||
trunks: List[int] = field(default_factory=list)
|
||||
firewall: bool = False
|
||||
link_down: bool = False
|
||||
name: Optional[str] = None # LXC: name=eth0
|
||||
ip: Optional[str] = None # LXC: "10.0.0.5/24" or "dhcp"
|
||||
ip6: Optional[str] = None
|
||||
gw: Optional[str] = None
|
||||
gw6: Optional[str] = None
|
||||
rate: Optional[float] = None # MB/s
|
||||
mtu: Optional[int] = None
|
||||
queues: Optional[int] = None
|
||||
raw: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _as_int(value: str) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _as_float(value: str) -> Optional[float]:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _as_bool(value: str) -> bool:
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def parse_net_string(key: str, value: str) -> ParsedNic:
|
||||
"""Parse a single ``netN`` value. Never raises."""
|
||||
match = _NET_KEY_RE.match(key or "")
|
||||
nic = ParsedNic(key=key or "", index=int(match.group(1)) if match else 0, raw=value or "")
|
||||
|
||||
for token in (value or "").split(","):
|
||||
token = token.strip()
|
||||
if not token or "=" not in token:
|
||||
# Bare tokens (rare, e.g. a stray flag) carry nothing we need.
|
||||
continue
|
||||
opt, _, raw_val = token.partition("=")
|
||||
opt = opt.strip().lower()
|
||||
raw_val = raw_val.strip()
|
||||
|
||||
if opt in QEMU_MODELS:
|
||||
nic.model = opt
|
||||
nic.mac = raw_val.upper() or None
|
||||
elif opt == "hwaddr":
|
||||
nic.mac = raw_val.upper() or None
|
||||
elif opt == "bridge":
|
||||
nic.bridge = raw_val or None
|
||||
elif opt == "tag":
|
||||
nic.vlan_tag = _as_int(raw_val)
|
||||
elif opt == "trunks":
|
||||
nic.trunks = [v for v in (_as_int(p) for p in raw_val.split(";")) if v is not None]
|
||||
elif opt == "firewall":
|
||||
nic.firewall = _as_bool(raw_val)
|
||||
elif opt == "link_down":
|
||||
nic.link_down = _as_bool(raw_val)
|
||||
elif opt == "name":
|
||||
nic.name = raw_val or None
|
||||
elif opt == "ip":
|
||||
nic.ip = raw_val or None
|
||||
elif opt == "ip6":
|
||||
nic.ip6 = raw_val or None
|
||||
elif opt == "gw":
|
||||
nic.gw = raw_val or None
|
||||
elif opt == "gw6":
|
||||
nic.gw6 = raw_val or None
|
||||
elif opt == "rate":
|
||||
nic.rate = _as_float(raw_val)
|
||||
elif opt == "mtu":
|
||||
nic.mtu = _as_int(raw_val)
|
||||
elif opt == "queues":
|
||||
nic.queues = _as_int(raw_val)
|
||||
|
||||
return nic
|
||||
|
||||
|
||||
def parse_guest_nics(config: Dict[str, Any]) -> List[ParsedNic]:
|
||||
"""Every ``netN`` entry of a QEMU/LXC config, ordered by interface index."""
|
||||
nics: List[ParsedNic] = []
|
||||
for key, value in (config or {}).items():
|
||||
if not isinstance(key, str) or not _NET_KEY_RE.match(key):
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
nics.append(parse_net_string(key, value))
|
||||
nics.sort(key=lambda n: n.index)
|
||||
return nics
|
||||
@@ -137,12 +137,10 @@ NEW_DEFAULT_ROLES = [
|
||||
"template:view": True,
|
||||
"storage:view": True,
|
||||
"firewall:view": True,
|
||||
"network:view": True,
|
||||
"node:view": True,
|
||||
"pool:view": True,
|
||||
"backup:view": True,
|
||||
"backup:create": True,
|
||||
"ipam:view": True,
|
||||
"user:view": True,
|
||||
"log:view": True,
|
||||
"log:export": True,
|
||||
@@ -172,10 +170,8 @@ NEW_DEFAULT_ROLES = [
|
||||
"template:view": True,
|
||||
"storage:view": True,
|
||||
"firewall:view": True,
|
||||
"network:view": True,
|
||||
"node:view": True,
|
||||
"pool:view": True,
|
||||
"ipam:view": True,
|
||||
# No setting:view — that grants the panel-wide settings page. A
|
||||
# user's own profile lives at /profile and needs no permission.
|
||||
"notification:view": True,
|
||||
@@ -195,10 +191,8 @@ NEW_DEFAULT_ROLES = [
|
||||
"template:view": True,
|
||||
"storage:view": True,
|
||||
"firewall:view": True,
|
||||
"network:view": True,
|
||||
"node:view": True,
|
||||
"pool:view": True,
|
||||
"ipam:view": True,
|
||||
}
|
||||
},
|
||||
]
|
||||
@@ -489,7 +483,10 @@ def ensure_default_roles_new_format(conn) -> None:
|
||||
# custom roles do not silently lose access after the split.
|
||||
GRANULAR_BACKFILL = [
|
||||
# (umbrella permission, [granular permissions implied by it])
|
||||
("server:view", ["firewall:view", "network:view", "node:view", "storage:view"]),
|
||||
# network:view/manage is deliberately NOT implied by server:view - the
|
||||
# network topology and node/SDN interface config are admin-only, not part
|
||||
# of the baseline "can see servers" umbrella.
|
||||
("server:view", ["firewall:view", "node:view", "storage:view"]),
|
||||
("vm:view", ["pool:view", "firewall:view"]),
|
||||
("server:manage", [
|
||||
"firewall:view", "firewall:manage",
|
||||
|
||||
@@ -8,6 +8,30 @@ from pydantic import BaseModel, Field, field_validator, model_validator, EmailSt
|
||||
|
||||
_HOSTNAME_LABEL_RE = re.compile(r'^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$')
|
||||
|
||||
_DNS_NAME_RE = re.compile(r'^[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$')
|
||||
|
||||
|
||||
def validate_instance_name(name: str) -> str:
|
||||
"""Validate that an instance name is a valid DNS label (RFC 952 / RFC 1123).
|
||||
|
||||
Proxmox requires VM/CT names to be valid DNS names: start with a letter,
|
||||
contain only letters, digits and hyphens, end with a letter or digit,
|
||||
max 63 characters. Underscores, spaces and other special characters are
|
||||
rejected.
|
||||
"""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("Имя не может быть пустым")
|
||||
if len(name) > 63:
|
||||
raise ValueError("Имя не может быть длиннее 63 символов")
|
||||
if not _DNS_NAME_RE.match(name):
|
||||
raise ValueError(
|
||||
"Имя должно быть валидным DNS-именем: начинаться с буквы, "
|
||||
"содержать только латинские буквы, цифры и дефис, "
|
||||
"заканчиваться буквой или цифрой"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def validate_host(v: str) -> str:
|
||||
"""Validate that value is a valid IP address or hostname/FQDN."""
|
||||
@@ -619,6 +643,13 @@ class IPAMAllocationResponse(IPAMAllocationBase):
|
||||
last_seen: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Multi-IP: гость может держать несколько адресов
|
||||
is_primary: bool = False
|
||||
assignment_kind: str = "primary"
|
||||
target_interface: Optional[str] = None
|
||||
apply_status: Optional[str] = None
|
||||
apply_error: Optional[str] = None
|
||||
applied_at: Optional[datetime] = None
|
||||
# Extended info
|
||||
network_name: Optional[str] = None
|
||||
pool_name: Optional[str] = None
|
||||
@@ -837,3 +868,115 @@ class WorkspaceResponse(BaseModel):
|
||||
class WorkspaceDetail(WorkspaceResponse):
|
||||
servers: List[WorkspaceServerItem] = []
|
||||
users: List[WorkspaceUserItem] = []
|
||||
|
||||
|
||||
# ==================== Network topology ====================
|
||||
# Response shape for GET /proxmox/api/network/topology. Deliberately a domain
|
||||
# hierarchy (panel → cluster → node → guest) rather than ready-made graph
|
||||
# nodes/edges: the frontend derives edges from nesting, and the grouping
|
||||
# threshold changes the rendered node count client-side without a round-trip.
|
||||
# Fields the graph does not draw yet (bridges, nic.bridge/vlan_tag, tags) are
|
||||
# the groundwork for the planned "group by VLAN / by tag" views.
|
||||
|
||||
|
||||
class TopologyNic(BaseModel):
|
||||
"""One guest network card, parsed out of its ``netN`` config string."""
|
||||
|
||||
key: str
|
||||
model: Optional[str] = None
|
||||
mac: Optional[str] = None
|
||||
bridge: Optional[str] = None
|
||||
vlan_tag: Optional[int] = None
|
||||
trunks: List[int] = []
|
||||
firewall: bool = False
|
||||
link_down: bool = False
|
||||
name: Optional[str] = None
|
||||
ip: Optional[str] = None
|
||||
gw: Optional[str] = None
|
||||
rate: Optional[float] = None
|
||||
mtu: Optional[int] = None
|
||||
|
||||
|
||||
class TopologyBridge(BaseModel):
|
||||
"""A bridge / bond / SDN vnet a guest can be attached to."""
|
||||
|
||||
name: str
|
||||
type: Optional[str] = None
|
||||
cidr: Optional[str] = None
|
||||
vlan_aware: bool = False
|
||||
ports: Optional[str] = None
|
||||
active: bool = False
|
||||
zone: Optional[str] = None
|
||||
ipam_network_id: Optional[int] = None
|
||||
ipam_cidr: Optional[str] = None
|
||||
ipam_name: Optional[str] = None
|
||||
|
||||
|
||||
class TopologyGuest(BaseModel):
|
||||
id: str
|
||||
server_id: int
|
||||
node: str
|
||||
vmid: int
|
||||
type: str # "qemu" | "lxc"
|
||||
name: str
|
||||
status: str # running | stopped | paused | unknown
|
||||
is_template: bool = False
|
||||
lock: Optional[str] = None
|
||||
cpu: Optional[float] = None
|
||||
cores: Optional[int] = None
|
||||
mem: Optional[int] = None
|
||||
maxmem: Optional[int] = None
|
||||
disk: Optional[int] = None
|
||||
maxdisk: Optional[int] = None
|
||||
uptime: Optional[int] = None
|
||||
tags: List[str] = []
|
||||
owner_id: Optional[int] = None
|
||||
owner_username: Optional[str] = None
|
||||
ip: Optional[str] = None
|
||||
nics: List[TopologyNic] = []
|
||||
|
||||
|
||||
class TopologyNode(BaseModel):
|
||||
id: str
|
||||
server_id: int
|
||||
server_name: str
|
||||
node: str
|
||||
status: str # online | offline | unknown
|
||||
stale: bool = False # True → rebuilt from cache, server unreachable
|
||||
cpu: Optional[float] = None
|
||||
maxcpu: Optional[int] = None
|
||||
mem: Optional[int] = None
|
||||
maxmem: Optional[int] = None
|
||||
uptime: Optional[int] = None
|
||||
bridges: List[TopologyBridge] = []
|
||||
guests: List[TopologyGuest] = []
|
||||
|
||||
|
||||
class TopologyCluster(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
kind: str # "cluster" | "standalone"
|
||||
server_ids: List[int] = []
|
||||
online: bool = False
|
||||
nodes: List[TopologyNode] = []
|
||||
|
||||
|
||||
class TopologyWarning(BaseModel):
|
||||
server_id: Optional[int] = None
|
||||
server_name: Optional[str] = None
|
||||
code: str # offline | no_credentials | error | nics_truncated
|
||||
message: str
|
||||
|
||||
|
||||
class TopologyPanelSummary(BaseModel):
|
||||
name: str = "PVEmanager"
|
||||
cluster_count: int = 0
|
||||
node_count: int = 0
|
||||
guest_count: int = 0
|
||||
|
||||
|
||||
class NetworkTopologyResponse(BaseModel):
|
||||
generated_at: datetime
|
||||
panel: TopologyPanelSummary
|
||||
clusters: List[TopologyCluster] = []
|
||||
warnings: List[TopologyWarning] = []
|
||||
|
||||
163
backend/app/services/guest_ip/sync_aliases.sh
Normal file
163
backend/app/services/guest_ip/sync_aliases.sh
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/bin/sh
|
||||
# Привести набор дополнительных IPv4-адресов (alias) интерфейса к заданному.
|
||||
#
|
||||
# Параметры приходят переменными окружения (их подставляет
|
||||
# script_engine.render_params, значения не конкатенируются в команды):
|
||||
# IFACE — интерфейс гостя, напр. eth0
|
||||
# ADDRESSES — список CIDR через пробел; пустая строка = снять все алиасы
|
||||
# PVEM_ROOT — префикс путей; в госте всегда пуст, задаётся только тестами
|
||||
#
|
||||
# Скрипт идемпотентен: он синхронизирует состояние, а не «добавляет».
|
||||
# Под управлением находятся только адреса из своего state-файла — адреса,
|
||||
# настроенные в госте вручную, не трогаются.
|
||||
#
|
||||
# Сеть намеренно НЕ перезапускается: адрес поднимается через `ip addr add`
|
||||
# здесь и сейчас, а конфиг ОС правится только ради переживания ребута.
|
||||
# Никаких `netplan apply` / `ifup` / `nmcli con up` — они рвут связь с
|
||||
# работающим гостем.
|
||||
|
||||
[ -n "$IFACE" ] || { echo "missing IFACE"; exit 2; }
|
||||
|
||||
ROOT="${PVEM_ROOT:-}"
|
||||
|
||||
STATE_DIR="$ROOT/var/lib/pvemanager"
|
||||
STATE="$STATE_DIR/aliases.$IFACE"
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||
|
||||
PREV=""
|
||||
[ -f "$STATE" ] && PREV=$(cat "$STATE")
|
||||
|
||||
# 1. Снять адреса, ушедшие из списка (только наши, из state-файла)
|
||||
for old in $PREV; do
|
||||
keep=0
|
||||
for new in $ADDRESSES; do
|
||||
[ "$old" = "$new" ] && keep=1
|
||||
done
|
||||
[ "$keep" = "1" ] && continue
|
||||
ip addr del "$old" dev "$IFACE" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# 2. Поднять недостающие
|
||||
for a in $ADDRESSES; do
|
||||
addr=${a%%/*}
|
||||
if ip -4 addr show dev "$IFACE" 2>/dev/null | grep -qw "$addr"; then
|
||||
continue
|
||||
fi
|
||||
if ! ip addr add "$a" dev "$IFACE"; then
|
||||
echo "failed to add $a on $IFACE"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "$ADDRESSES" > "$STATE"
|
||||
|
||||
# 3. Закрепить в конфиге ОС — первый распознанный стек выигрывает.
|
||||
# Ветки идут последовательно, а не через elif: наличие инструмента ещё не
|
||||
# значит, что он управляет этим интерфейсом (типичный случай — NetworkManager
|
||||
# в LXC), и тогда управление должно уходить дальше по списку.
|
||||
persist=none
|
||||
MARKER="# managed by PVEmanager"
|
||||
|
||||
IFACES_FILE="$ROOT/etc/network/interfaces"
|
||||
IFUP_DIR="$ROOT/etc/network/interfaces.d"
|
||||
IFUP_FILE="$IFUP_DIR/pvemanager-aliases-$IFACE"
|
||||
NETPLAN_DIR="$ROOT/etc/netplan"
|
||||
NETPLAN_FILE="$NETPLAN_DIR/99-pvemanager-aliases.yaml"
|
||||
SYSCONFIG_DIR="$ROOT/etc/sysconfig/network-scripts"
|
||||
|
||||
# --- ifupdown (Debian/Ubuntu) ---
|
||||
if [ "$persist" = none ] && [ -d "$IFUP_DIR" ] && [ -f "$IFACES_FILE" ] && \
|
||||
command -v ifup >/dev/null 2>&1; then
|
||||
# Шаблоны LXC от Proxmox создают interfaces.d, но не подключают его из
|
||||
# /etc/network/interfaces — без source-строки файлы оттуда не читаются,
|
||||
# поэтому добавляем её (идемпотентно). Список интерфейсов не трогаем:
|
||||
# его перезаписывает Proxmox при правке NIC.
|
||||
if ! grep -qE '^[[:space:]]*(source|source-directory)' "$IFACES_FILE"; then
|
||||
echo "source /etc/network/interfaces.d/*" >> "$IFACES_FILE"
|
||||
fi
|
||||
if [ -z "$ADDRESSES" ]; then
|
||||
rm -f "$IFUP_FILE"
|
||||
else
|
||||
{
|
||||
echo "$MARKER — не редактировать вручную"
|
||||
n=0
|
||||
for a in $ADDRESSES; do
|
||||
n=$((n + 1))
|
||||
echo "auto $IFACE:$n"
|
||||
echo "iface $IFACE:$n inet static"
|
||||
echo " address $a"
|
||||
done
|
||||
} > "$IFUP_FILE"
|
||||
fi
|
||||
persist=ifupdown
|
||||
fi
|
||||
|
||||
# --- netplan (Ubuntu cloud-образы) ---
|
||||
if [ "$persist" = none ] && [ -d "$NETPLAN_DIR" ]; then
|
||||
if [ -z "$ADDRESSES" ]; then
|
||||
rm -f "$NETPLAN_FILE"
|
||||
else
|
||||
{
|
||||
echo "$MARKER — не редактировать вручную"
|
||||
echo "network:"
|
||||
echo " version: 2"
|
||||
echo " ethernets:"
|
||||
echo " $IFACE:"
|
||||
echo " addresses:"
|
||||
for a in $ADDRESSES; do
|
||||
echo " - $a"
|
||||
done
|
||||
} > "$NETPLAN_FILE"
|
||||
chmod 600 "$NETPLAN_FILE" 2>/dev/null || true
|
||||
fi
|
||||
persist=netplan
|
||||
fi
|
||||
|
||||
# --- NetworkManager (RHEL-based VM, современные десктопные образы) ---
|
||||
if [ "$persist" = none ] && command -v nmcli >/dev/null 2>&1; then
|
||||
# Пустой ответ = интерфейсом NM не управляет; тогда идём дальше.
|
||||
CON=$(nmcli -g GENERAL.CONNECTION device show "$IFACE" 2>/dev/null)
|
||||
if [ -n "$CON" ]; then
|
||||
for old in $PREV; do
|
||||
nmcli con mod "$CON" -ipv4.addresses "$old" >/dev/null 2>&1 || true
|
||||
done
|
||||
for a in $ADDRESSES; do
|
||||
nmcli con mod "$CON" +ipv4.addresses "$a" >/dev/null 2>&1 || true
|
||||
done
|
||||
persist=nm
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- sysconfig / network-scripts (RHEL, CentOS, Rocky, Alma) ---
|
||||
# Шаблоны LXC этих дистрибутивов Proxmox настраивает именно так
|
||||
# (pve-container, модуль Redhat.pm), а NetworkManager в них обычно не стоит.
|
||||
if [ "$persist" = none ] && [ -d "$SYSCONFIG_DIR" ]; then
|
||||
# Сносим прежние свои ifcfg-алиасы; чужие файлы без маркера не трогаем.
|
||||
for f in "$SYSCONFIG_DIR/ifcfg-$IFACE":*; do
|
||||
[ -f "$f" ] || continue
|
||||
if head -n 1 "$f" 2>/dev/null | grep -q "$MARKER"; then
|
||||
rm -f "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
n=0
|
||||
for a in $ADDRESSES; do
|
||||
n=$((n + 1))
|
||||
addr=${a%%/*}
|
||||
plen=${a##*/}
|
||||
{
|
||||
echo "$MARKER — не редактировать вручную"
|
||||
echo "DEVICE=$IFACE:$n"
|
||||
echo "NAME=$IFACE:$n"
|
||||
echo "ONPARENT=yes"
|
||||
echo "ONBOOT=yes"
|
||||
echo "BOOTPROTO=none"
|
||||
echo "IPADDR=$addr"
|
||||
echo "PREFIX=$plen"
|
||||
} > "$SYSCONFIG_DIR/ifcfg-$IFACE:$n"
|
||||
done
|
||||
persist=sysconfig
|
||||
fi
|
||||
|
||||
echo "PVEMANAGER_PERSIST=$persist"
|
||||
echo "PVEMANAGER_OK=1"
|
||||
222
backend/app/services/guest_ip_service.py
Normal file
222
backend/app/services/guest_ip_service.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Дополнительные IP-адреса гостя (alias поверх основного интерфейса).
|
||||
|
||||
Основной адрес живёт в конфиге Proxmox (`net0` у LXC, `ipconfig0` у QEMU) и
|
||||
здесь не трогается. Дополнительные навешиваются внутри гостя как alias и
|
||||
учитываются в IPAM: одна аллокация — один адрес.
|
||||
|
||||
Транспорт не изобретаем: `services/script_engine.py` уже умеет выполнять
|
||||
скрипт и в LXC (`pct exec` по SSH ноды), и в VM (QEMU guest agent).
|
||||
|
||||
Модуль работает синхронизацией, а не пошаговым add/remove: в гостя всегда
|
||||
уезжает полный список alias-адресов интерфейса, а скрипт приводит состояние
|
||||
к нему. Поэтому добавление, удаление и повторное применение — один и тот же
|
||||
код, а расхождения (адрес сняли руками) чинятся сами.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import utcnow
|
||||
from ..models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
|
||||
from . import script_engine
|
||||
|
||||
_SCRIPT_PATH = Path(__file__).parent / "guest_ip" / "sync_aliases.sh"
|
||||
_SYNC_TIMEOUT = 60
|
||||
|
||||
# Значения IPAMAllocation.apply_status
|
||||
APPLIED = "applied" # поднят и закреплён в конфиге ОС
|
||||
RUNTIME_ONLY = "runtime_only" # поднят, но стек не распознан — ребут не переживёт
|
||||
PENDING = "pending" # гость недоступен, попробуем позже
|
||||
FAILED = "failed" # скрипт отработал с ошибкой
|
||||
|
||||
_PERSIST_RE = re.compile(r"^PVEMANAGER_PERSIST=(\w+)$", re.MULTILINE)
|
||||
_OK_RE = re.compile(r"^PVEMANAGER_OK=1$", re.MULTILINE)
|
||||
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,15}$")
|
||||
|
||||
DEFAULT_INTERFACE = "eth0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyResult:
|
||||
success: bool
|
||||
status: str
|
||||
persist: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
output: str = ""
|
||||
|
||||
|
||||
def _load_script() -> str:
|
||||
return _SCRIPT_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def prefix_for(db: Session, allocation: IPAMAllocation) -> Optional[int]:
|
||||
"""Длина префикса адреса — берётся из сети, которой он принадлежит."""
|
||||
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
|
||||
if not network or not network.network:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_network(network.network, strict=False).prefixlen
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def alias_allocations(db: Session, server_id: int, vmid: int,
|
||||
interface: str) -> List[IPAMAllocation]:
|
||||
"""Все alias-адреса гостя на конкретном интерфейсе, по возрастанию адреса."""
|
||||
rows = db.query(IPAMAllocation).filter(
|
||||
IPAMAllocation.proxmox_server_id == server_id,
|
||||
IPAMAllocation.proxmox_vmid == vmid,
|
||||
IPAMAllocation.assignment_kind == "alias",
|
||||
IPAMAllocation.target_interface == interface,
|
||||
IPAMAllocation.status.in_(["allocated", "reserved"]),
|
||||
).all()
|
||||
|
||||
def _sort_key(row: IPAMAllocation):
|
||||
try:
|
||||
return (0, int(ipaddress.ip_address(row.ip_address)))
|
||||
except ValueError:
|
||||
return (1, 0)
|
||||
|
||||
return sorted(rows, key=_sort_key)
|
||||
|
||||
|
||||
def _guest_target(db: Session, server_id: int, vmid: int,
|
||||
allocation: Optional[IPAMAllocation] = None
|
||||
) -> Tuple[Optional[ProxmoxServer], Optional[str], Optional[str]]:
|
||||
"""(server, node, vm_type) для запуска скрипта; None-ы, если гость неизвестен."""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
guest = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
node = (guest.node if guest else None) or (allocation.proxmox_node if allocation else None)
|
||||
vm_type = guest.vm_type if guest else None
|
||||
if not vm_type and allocation and allocation.resource_type in ("qemu", "lxc"):
|
||||
vm_type = allocation.resource_type
|
||||
return server, node, vm_type
|
||||
|
||||
|
||||
def resolve_interface(client, vm_type: str, node: str, vmid: int,
|
||||
primary_ip: Optional[str] = None) -> str:
|
||||
"""Интерфейс по умолчанию — тот, на котором висит основной адрес гостя.
|
||||
|
||||
Данные берём у существующих методов клиента; если гость молчит,
|
||||
возвращаем eth0 — пользователь всё равно может выбрать интерфейс вручную.
|
||||
"""
|
||||
try:
|
||||
if vm_type == "lxc":
|
||||
interfaces = client.get_container_interfaces(node, vmid)
|
||||
else:
|
||||
interfaces = client.get_vm_interfaces(node, vmid)
|
||||
except Exception as e:
|
||||
logger.debug(f"[guest-ip] не удалось получить интерфейсы {vmid}: {e}")
|
||||
return DEFAULT_INTERFACE
|
||||
|
||||
fallback = None
|
||||
for iface in interfaces or []:
|
||||
name = iface.get("name") or ""
|
||||
if not name or name == "lo":
|
||||
continue
|
||||
fallback = fallback or name
|
||||
if primary_ip:
|
||||
for ip_info in iface.get("ips") or []:
|
||||
if ip_info.get("address") == primary_ip:
|
||||
return name
|
||||
return fallback or DEFAULT_INTERFACE
|
||||
|
||||
|
||||
def sync_interface(db: Session, server_id: int, vmid: int, interface: str,
|
||||
allocation: Optional[IPAMAllocation] = None) -> ApplyResult:
|
||||
"""Привести alias-адреса интерфейса в госте к тому, что записано в IPAM.
|
||||
|
||||
Обновляет apply_status у всех alias-аллокаций этого интерфейса: они
|
||||
применяются одним вызовом, значит и исход у них общий.
|
||||
"""
|
||||
if not _IFACE_RE.match(interface or ""):
|
||||
return ApplyResult(False, FAILED, error=f"Некорректное имя интерфейса: {interface}")
|
||||
|
||||
server, node, vm_type = _guest_target(db, server_id, vmid, allocation)
|
||||
rows = alias_allocations(db, server_id, vmid, interface)
|
||||
|
||||
if not server:
|
||||
return _finish(db, rows, ApplyResult(False, PENDING, error="Сервер Proxmox не найден"))
|
||||
if not node or not vm_type:
|
||||
return _finish(db, rows, ApplyResult(
|
||||
False, PENDING, error="Гость не найден в кэше — синхронизируйте инстансы"))
|
||||
|
||||
addresses = []
|
||||
for row in rows:
|
||||
prefix = prefix_for(db, row)
|
||||
if prefix is None:
|
||||
return _finish(db, rows, ApplyResult(
|
||||
False, FAILED,
|
||||
error=f"Не удалось определить префикс сети для {row.ip_address}"))
|
||||
addresses.append(f"{row.ip_address}/{prefix}")
|
||||
|
||||
script = script_engine.render_params(
|
||||
_load_script(), {"IFACE": interface, "ADDRESSES": " ".join(addresses)}
|
||||
)
|
||||
result = script_engine.execute(
|
||||
server, target_type="guest", node=node, vmid=vmid, vm_type=vm_type,
|
||||
script=script, interpreter="/bin/sh", timeout=_SYNC_TIMEOUT,
|
||||
)
|
||||
|
||||
output = result.output or ""
|
||||
if not result.success or not _OK_RE.search(output):
|
||||
error = (result.error or output or "").strip()[:1000] or "Скрипт не завершился успешно"
|
||||
# exit_code == -1 — транспорт: гость выключен, нет SSH к ноде или нет
|
||||
# guest agent. Это не ошибка конфигурации, поэтому pending: адрес
|
||||
# закреплён в IPAM и применится, когда гость станет доступен.
|
||||
status = PENDING if result.exit_code == -1 else FAILED
|
||||
return _finish(db, rows, ApplyResult(False, status, error=error, output=output))
|
||||
|
||||
persist_match = _PERSIST_RE.search(output)
|
||||
persist = persist_match.group(1) if persist_match else "none"
|
||||
status = RUNTIME_ONLY if persist == "none" else APPLIED
|
||||
return _finish(db, rows, ApplyResult(True, status, persist=persist, output=output))
|
||||
|
||||
|
||||
def _finish(db: Session, rows: List[IPAMAllocation], result: ApplyResult) -> ApplyResult:
|
||||
"""Записать исход применения во все затронутые аллокации."""
|
||||
for row in rows:
|
||||
row.apply_status = result.status
|
||||
row.apply_error = result.error
|
||||
if result.success:
|
||||
row.applied_at = utcnow()
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
def apply_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
|
||||
"""Применить (или переприменить) alias-адрес к гостю."""
|
||||
if allocation.assignment_kind != "alias":
|
||||
return ApplyResult(True, APPLIED, persist="primary",
|
||||
output="Основной адрес задаётся конфигом Proxmox")
|
||||
return sync_interface(
|
||||
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
|
||||
allocation.target_interface or DEFAULT_INTERFACE, allocation,
|
||||
)
|
||||
|
||||
|
||||
def remove_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
|
||||
"""Снять alias-адрес с гостя.
|
||||
|
||||
К моменту вызова аллокация должна быть уже освобождена или удалена —
|
||||
скрипт снимает всё, чего нет в переданном ему списке.
|
||||
"""
|
||||
if allocation.assignment_kind != "alias":
|
||||
return ApplyResult(True, APPLIED, persist="primary")
|
||||
return sync_interface(
|
||||
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
|
||||
allocation.target_interface or DEFAULT_INTERFACE, allocation,
|
||||
)
|
||||
@@ -5,6 +5,7 @@ Uses APScheduler for periodic tasks
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
@@ -108,12 +109,17 @@ class MonitoringWorker:
|
||||
# задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу.
|
||||
UPID_FAIL_THRESHOLD = 3
|
||||
|
||||
# Как часто перепроверять IP гостя у Proxmox. Синк идёт каждые 10 секунд,
|
||||
# а адрес меняется редко — без троттлинга это сотни лишних запросов в минуту.
|
||||
IP_DETECT_TTL = 300
|
||||
|
||||
def __init__(self):
|
||||
self.last_vm_states: Dict[str, str] = {} # vm_id -> status
|
||||
self.last_resource_alerts: Dict[str, float] = {} # resource_id -> last_alert_time
|
||||
self.last_server_states: Dict[int, bool] = {} # server_id -> is_online
|
||||
self.last_server_alerts: Dict[int, float] = {} # server_id -> last_alert_time
|
||||
self.upid_fail_counts: Dict[int, int] = {} # ProxmoxTask.id -> consecutive fetch failures
|
||||
self.last_ip_detect: Dict[tuple, float] = {} # (server_id, vmid) -> monotonic time
|
||||
|
||||
def _create_proxmox_client(self, server: ProxmoxServer) -> ProxmoxClient:
|
||||
"""Create ProxmoxClient from server model, raising if auth is missing."""
|
||||
@@ -619,18 +625,20 @@ class MonitoringWorker:
|
||||
IPAMAllocation.status.in_(['allocated', 'reserved'])
|
||||
).all()
|
||||
|
||||
# Build IPAM lookups
|
||||
# Build IPAM lookups.
|
||||
# Только по идентификатору гостя: сопоставление по имени давало чужой
|
||||
# адрес одноимённым гостям на разных серверах (два разных "wireguard").
|
||||
# У гостя может быть несколько адресов — в кэш инстанса пишем
|
||||
# основной (is_primary); без флага берём самую раннюю запись.
|
||||
ipam_by_server_vmid = {} # (server_id, vmid) -> allocation
|
||||
ipam_by_name = {} # hostname/resource_name -> allocation
|
||||
|
||||
|
||||
for alloc in ipam_allocations:
|
||||
if alloc.proxmox_server_id and alloc.proxmox_vmid:
|
||||
ipam_by_server_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc
|
||||
if alloc.hostname:
|
||||
ipam_by_name[alloc.hostname.lower()] = alloc
|
||||
if alloc.resource_name:
|
||||
ipam_by_name[alloc.resource_name.lower()] = alloc
|
||||
|
||||
key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
|
||||
current = ipam_by_server_vmid.get(key)
|
||||
if current is None or (alloc.is_primary and not current.is_primary):
|
||||
ipam_by_server_vmid[key] = alloc
|
||||
|
||||
# Detect clusters: servers that see multiple nodes are part of a cluster
|
||||
# Group servers by their node set
|
||||
server_nodes = {} # server_id -> set of node names
|
||||
@@ -749,26 +757,34 @@ class MonitoringWorker:
|
||||
all_seen.add((server_id, vmid))
|
||||
|
||||
# Get IP from IPAM
|
||||
ipam_alloc = (
|
||||
ipam_by_server_vmid.get((server_id, vmid)) or
|
||||
ipam_by_name.get(vm_name.lower())
|
||||
)
|
||||
ipam_alloc = ipam_by_server_vmid.get((server_id, vmid))
|
||||
ip_address = ipam_alloc.ip_address if ipam_alloc else None
|
||||
|
||||
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent)
|
||||
if not ip_address:
|
||||
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent).
|
||||
# Троттлим: адрес гостя меняется куда реже, чем идёт синк.
|
||||
detect_key = (server_id, vmid)
|
||||
detect_due = (time.monotonic() - self.last_ip_detect.get(detect_key, 0.0)) >= self.IP_DETECT_TTL
|
||||
if not ip_address and detect_due:
|
||||
self.last_ip_detect[detect_key] = time.monotonic()
|
||||
try:
|
||||
px_client = server_clients.get(server_id)
|
||||
if px_client and node_name:
|
||||
if vm_type == 'lxc':
|
||||
config = px_client.proxmox.nodes(node_name).lxc(vmid).config.get()
|
||||
for i in range(4):
|
||||
ipconfig = config.get(f'ipconfig{i}', '')
|
||||
if 'ip=' in str(ipconfig):
|
||||
ip_part = str(ipconfig).split('ip=')[1].split(',')[0]
|
||||
if ip_part and ip_part != 'dhcp':
|
||||
ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part
|
||||
# Статика из netN + живые адреса запущенного
|
||||
# контейнера (DHCP виден только там)
|
||||
for iface in px_client.get_container_interfaces(
|
||||
node_name, vmid,
|
||||
include_live=vm_data.get('status') == 'running',
|
||||
):
|
||||
for ip_info in iface.get('ips') or []:
|
||||
if ip_info.get('type') != 'ipv4':
|
||||
continue
|
||||
addr = ip_info.get('address', '')
|
||||
if addr:
|
||||
ip_address = addr
|
||||
break
|
||||
if ip_address:
|
||||
break
|
||||
elif vm_type == 'qemu' and vm_data.get('status') == 'running':
|
||||
result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get()
|
||||
if result and 'result' in result:
|
||||
@@ -862,6 +878,11 @@ class MonitoringWorker:
|
||||
db.rollback()
|
||||
return
|
||||
|
||||
# Не копим троттлинг-метки по гостям, которых больше нет
|
||||
self.last_ip_detect = {
|
||||
key: seen_at for key, seen_at in self.last_ip_detect.items() if key in all_seen
|
||||
}
|
||||
|
||||
# Mark VMs that no longer exist as deleted
|
||||
# Get all active VMs from synced servers
|
||||
synced_server_ids = set(server.id for server in servers)
|
||||
|
||||
@@ -1838,6 +1838,48 @@ def migrate_app_operations_server_id(conn):
|
||||
logger.info("✓ app_operations.server_id already exists")
|
||||
|
||||
|
||||
def migrate_ipam_multi_ip(conn):
|
||||
"""Migration 43: несколько IP-адресов на одного гостя.
|
||||
|
||||
Раньше на (server_id, vmid) подразумевалась ровно одна аллокация. Теперь
|
||||
их может быть несколько: один основной (net0/ipconfig0, попадает в колонку
|
||||
IP) и произвольное число алиасов, навешенных внутри гостя. Колонки
|
||||
apply_* хранят состояние применения алиаса к живому гостю.
|
||||
"""
|
||||
if not table_exists(conn, 'ipam_allocations'):
|
||||
logger.info("Table ipam_allocations does not exist, skipping multi-IP migration")
|
||||
return
|
||||
|
||||
added = False
|
||||
for column, ddl in (
|
||||
('is_primary', 'BOOLEAN NOT NULL DEFAULT FALSE'),
|
||||
('assignment_kind', "VARCHAR(10) NOT NULL DEFAULT 'primary'"),
|
||||
('target_interface', 'VARCHAR(20)'),
|
||||
('apply_status', 'VARCHAR(20)'),
|
||||
('apply_error', 'TEXT'),
|
||||
('applied_at', 'TIMESTAMP WITH TIME ZONE'),
|
||||
):
|
||||
if add_column_if_not_exists(conn, 'ipam_allocations', column, ddl):
|
||||
added = True
|
||||
|
||||
if added:
|
||||
conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ipam_alloc_primary "
|
||||
"ON ipam_allocations(proxmox_server_id, proxmox_vmid, is_primary)"
|
||||
))
|
||||
# Всё, что существовало до этой миграции, было единственным адресом
|
||||
# своего гостя — значит основным. Записи без привязки к гостю
|
||||
# (ручные брони) основными не считаем.
|
||||
conn.execute(text("""
|
||||
UPDATE ipam_allocations
|
||||
SET is_primary = TRUE, assignment_kind = 'primary'
|
||||
WHERE proxmox_vmid IS NOT NULL AND is_primary = FALSE
|
||||
"""))
|
||||
logger.info("✓ Added multi-IP columns to ipam_allocations")
|
||||
else:
|
||||
logger.info("✓ ipam_allocations multi-IP columns already exist")
|
||||
|
||||
|
||||
def migrate_setting_view_panel_only(conn):
|
||||
"""Migration 42: drop setting:view where it was only granting the profile page.
|
||||
|
||||
@@ -2247,6 +2289,14 @@ def run_all_migrations(engine, db_session=None):
|
||||
logger.warning(f"setting:view split migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 43: several IP addresses per guest
|
||||
try:
|
||||
migrate_ipam_multi_ip(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"IPAM multi-IP migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("✓ All migrations completed successfully")
|
||||
logger.info("=" * 50)
|
||||
|
||||
105
backend/tests/test_container_interfaces.py
Normal file
105
backend/tests/test_container_interfaces.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.proxmox.client import ProxmoxClient
|
||||
|
||||
|
||||
def _client_with_mock(proxmox):
|
||||
c = ProxmoxClient.__new__(ProxmoxClient)
|
||||
c.proxmox = proxmox
|
||||
return c
|
||||
|
||||
|
||||
def _lxc_mock(config=None, interfaces=None):
|
||||
"""MagicMock whose nodes(..).lxc(..).config/.interfaces behave like Proxmox."""
|
||||
px = MagicMock()
|
||||
lxc = px.nodes.return_value.lxc.return_value
|
||||
if isinstance(config, Exception):
|
||||
lxc.config.get.side_effect = config
|
||||
else:
|
||||
lxc.config.get.return_value = config or {}
|
||||
if isinstance(interfaces, Exception):
|
||||
lxc.interfaces.get.side_effect = interfaces
|
||||
else:
|
||||
lxc.interfaces.get.return_value = interfaces or []
|
||||
return px, lxc
|
||||
|
||||
|
||||
def test_static_ip_comes_from_net0_not_ipconfig():
|
||||
px, lxc = _lxc_mock(config={
|
||||
"hostname": "gitea",
|
||||
"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:AA:BB:CC,ip=10.10.10.9/24,gw=10.10.10.1,type=veth",
|
||||
})
|
||||
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 109)
|
||||
|
||||
assert ifaces == [{
|
||||
"name": "eth0",
|
||||
"hardware_address": "BC:24:11:AA:BB:CC",
|
||||
"ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}],
|
||||
}]
|
||||
# Static address is authoritative — no extra call to the live endpoint.
|
||||
lxc.interfaces.get.assert_not_called()
|
||||
|
||||
|
||||
def test_dhcp_container_falls_back_to_live_interfaces():
|
||||
px, _ = _lxc_mock(
|
||||
config={"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:00:11:22,ip=dhcp,type=veth"},
|
||||
interfaces=[
|
||||
{"name": "lo", "inet": "127.0.0.1/8"},
|
||||
{"name": "eth0", "hwaddr": "bc:24:11:00:11:22", "inet": "10.10.10.55/24",
|
||||
"inet6": "fe80::be24:11ff:fe00:1122/64"},
|
||||
],
|
||||
)
|
||||
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 120)
|
||||
|
||||
assert ifaces == [{
|
||||
"name": "eth0",
|
||||
"hardware_address": "BC:24:11:00:11:22",
|
||||
"ips": [{"address": "10.10.10.55", "type": "ipv4", "prefix": 24}],
|
||||
}]
|
||||
|
||||
|
||||
def test_stopped_dhcp_container_yields_nothing():
|
||||
px, _ = _lxc_mock(
|
||||
config={"net0": "name=eth0,bridge=vmbr0,ip=dhcp,type=veth"},
|
||||
interfaces=RuntimeError("500 CT is not running"),
|
||||
)
|
||||
assert _client_with_mock(px).get_container_interfaces("pve1", 120) == []
|
||||
|
||||
|
||||
def test_multiple_nics_and_ipv6_static():
|
||||
px, _ = _lxc_mock(config={
|
||||
"net0": "name=eth0,bridge=vmbr0,ip=10.10.10.9/24,gw=10.10.10.1",
|
||||
"net1": "name=eth1,bridge=vmbr1,ip=192.168.50.4/24,ip6=2001:db8::4/64",
|
||||
})
|
||||
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 121)
|
||||
|
||||
assert [i["name"] for i in ifaces] == ["eth0", "eth1"]
|
||||
assert ifaces[1]["ips"] == [
|
||||
{"address": "192.168.50.4", "type": "ipv4", "prefix": 24},
|
||||
{"address": "2001:db8::4", "type": "ipv6", "prefix": 64},
|
||||
]
|
||||
|
||||
|
||||
def test_unreadable_config_still_tries_live_interfaces():
|
||||
px, _ = _lxc_mock(
|
||||
config=RuntimeError("permission denied"),
|
||||
interfaces=[{"name": "eth0", "hwaddr": "aa:bb:cc:dd:ee:ff", "inet": "10.0.0.7/24"}],
|
||||
)
|
||||
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 122)
|
||||
assert ifaces[0]["ips"] == [{"address": "10.0.0.7", "type": "ipv4", "prefix": 24}]
|
||||
|
||||
|
||||
def test_no_proxmox_connection():
|
||||
c = ProxmoxClient.__new__(ProxmoxClient)
|
||||
c.proxmox = None
|
||||
assert c.get_container_interfaces("pve1", 100) == []
|
||||
|
||||
|
||||
def test_include_live_false_skips_live_endpoint():
|
||||
px, lxc = _lxc_mock(
|
||||
config={"net0": "name=eth0,bridge=vmbr0,ip=dhcp,type=veth"},
|
||||
interfaces=[{"name": "eth0", "inet": "10.0.0.8/24"}],
|
||||
)
|
||||
# Stopped container: querying /interfaces would only produce a 500.
|
||||
assert _client_with_mock(px).get_container_interfaces("pve1", 130, include_live=False) == []
|
||||
lxc.interfaces.get.assert_not_called()
|
||||
298
backend/tests/test_guest_ip_aliases_script.py
Normal file
298
backend/tests/test_guest_ip_aliases_script.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Скрипт синхронизации alias-адресов: детект стека и содержимое конфигов.
|
||||
|
||||
Скрипт запускается настоящим /bin/sh в песочнице: PVEM_ROOT подменяет корень
|
||||
файловой системы, а ip/nmcli/ifup подставляются заглушками через PATH. Так
|
||||
проверяется именно shell-логика — то, что реально выполняется внутри гостя.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "app" / "services" / "guest_ip" / "sync_aliases.sh"
|
||||
|
||||
FAKE_IP = """#!/bin/sh
|
||||
echo "$*" >> "$FAKE_LOG/ip.log"
|
||||
# 'addr show' печатает пустоту: считаем, что на интерфейсе адресов ещё нет
|
||||
exit 0
|
||||
"""
|
||||
|
||||
FAKE_NMCLI = """#!/bin/sh
|
||||
echo "$*" >> "$FAKE_LOG/nmcli.log"
|
||||
if [ "$1" = "-g" ]; then
|
||||
printf '%s' "$NM_CONNECTION"
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
|
||||
FAKE_IFUP = """#!/bin/sh
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sandbox(tmp_path):
|
||||
"""Песочница с фейковыми ip/nmcli/ifup и пустым «корнем» гостя."""
|
||||
root = tmp_path / "root"
|
||||
bindir = tmp_path / "bin"
|
||||
logdir = tmp_path / "log"
|
||||
for path in (root, bindir, logdir):
|
||||
path.mkdir(parents=True)
|
||||
|
||||
for name, body in (("ip", FAKE_IP), ("nmcli", FAKE_NMCLI), ("ifup", FAKE_IFUP)):
|
||||
target = bindir / name
|
||||
target.write_text(body)
|
||||
target.chmod(0o755)
|
||||
|
||||
def run(addresses="", iface="eth0", nm_connection="", tools=("ip",)):
|
||||
"""Запустить скрипт. tools — какие команды «установлены» в госте."""
|
||||
env = {
|
||||
"PATH": f"{bindir}:{os.environ['PATH']}",
|
||||
"PVEM_ROOT": str(root),
|
||||
"FAKE_LOG": str(logdir),
|
||||
"NM_CONNECTION": nm_connection,
|
||||
"IFACE": iface,
|
||||
"ADDRESSES": addresses,
|
||||
}
|
||||
# Прячем инструменты, которых в этом сценарии быть не должно
|
||||
for name in ("ip", "nmcli", "ifup"):
|
||||
path = bindir / name
|
||||
hidden = bindir / f"{name}.hidden"
|
||||
if name in tools and hidden.exists():
|
||||
hidden.rename(path)
|
||||
elif name not in tools and path.exists():
|
||||
path.rename(hidden)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/sh", str(SCRIPT)], env=env, capture_output=True, text=True, timeout=30
|
||||
)
|
||||
return result
|
||||
|
||||
def persist_of(result) -> str:
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("PVEMANAGER_PERSIST="):
|
||||
return line.split("=", 1)[1]
|
||||
raise AssertionError(f"нет маркера persist: {result.stdout} {result.stderr}")
|
||||
|
||||
def log(name: str) -> str:
|
||||
path = logdir / f"{name}.log"
|
||||
return path.read_text() if path.exists() else ""
|
||||
|
||||
return {"root": root, "run": run, "persist_of": persist_of, "log": log}
|
||||
|
||||
|
||||
def _mkdirs(root: Path, *relative: str):
|
||||
for item in relative:
|
||||
(root / item).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# --- sysconfig / network-scripts ----------------------------------------------
|
||||
|
||||
def test_sysconfig_branch_writes_ifcfg_alias(sandbox):
|
||||
"""RHEL-based LXC: alias закрепляется отдельным ifcfg-файлом."""
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24")
|
||||
|
||||
assert sandbox["persist_of"](result) == "sysconfig"
|
||||
written = (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").read_text()
|
||||
assert "DEVICE=eth0:1" in written
|
||||
assert "IPADDR=10.10.10.90" in written
|
||||
assert "PREFIX=24" in written
|
||||
assert "BOOTPROTO=none" in written
|
||||
assert "ONBOOT=yes" in written
|
||||
assert "managed by PVEmanager" in written
|
||||
|
||||
|
||||
def test_sysconfig_numbers_aliases_in_order(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
|
||||
|
||||
scripts = root / "etc/sysconfig/network-scripts"
|
||||
assert "IPADDR=10.10.10.90" in (scripts / "ifcfg-eth0:1").read_text()
|
||||
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:2").read_text()
|
||||
|
||||
|
||||
def test_sysconfig_drops_files_of_removed_addresses(sandbox):
|
||||
"""Список сократился — лишний ifcfg удаляется, оставшийся переписывается."""
|
||||
root = sandbox["root"]
|
||||
scripts = root / "etc/sysconfig/network-scripts"
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
|
||||
sandbox["run"](addresses="10.10.10.91/24")
|
||||
|
||||
assert not (scripts / "ifcfg-eth0:2").exists()
|
||||
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:1").read_text()
|
||||
|
||||
|
||||
def test_sysconfig_removes_everything_on_empty_list(sandbox):
|
||||
root = sandbox["root"]
|
||||
scripts = root / "etc/sysconfig/network-scripts"
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24")
|
||||
result = sandbox["run"](addresses="")
|
||||
|
||||
assert sandbox["persist_of"](result) == "sysconfig"
|
||||
assert list(scripts.glob("ifcfg-eth0:*")) == []
|
||||
|
||||
|
||||
def test_sysconfig_keeps_foreign_ifcfg_files(sandbox):
|
||||
"""Файлы, написанные не нами, остаются нетронутыми."""
|
||||
root = sandbox["root"]
|
||||
scripts = root / "etc/sysconfig/network-scripts"
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
foreign = scripts / "ifcfg-eth0:9"
|
||||
foreign.write_text("DEVICE=eth0:9\nIPADDR=10.10.10.250\n")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24")
|
||||
|
||||
assert foreign.exists()
|
||||
assert "10.10.10.250" in foreign.read_text()
|
||||
|
||||
|
||||
def test_sysconfig_removes_alias_del_from_interface(sandbox):
|
||||
"""Ушедший адрес снимается с интерфейса, а не только из конфига."""
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24")
|
||||
sandbox["run"](addresses="")
|
||||
|
||||
assert "addr del 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
|
||||
|
||||
|
||||
# --- порядок веток -------------------------------------------------------------
|
||||
|
||||
def test_networkmanager_without_connection_falls_through_to_sysconfig(sandbox):
|
||||
"""nmcli установлен, но интерфейсом не управляет — частый случай в LXC."""
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="",
|
||||
tools=("ip", "nmcli"))
|
||||
|
||||
assert sandbox["persist_of"](result) == "sysconfig"
|
||||
assert (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").exists()
|
||||
|
||||
|
||||
def test_networkmanager_with_connection_wins_over_sysconfig(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
|
||||
tools=("ip", "nmcli"))
|
||||
|
||||
assert sandbox["persist_of"](result) == "nm"
|
||||
assert "+ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
|
||||
assert list((root / "etc/sysconfig/network-scripts").glob("ifcfg-*")) == []
|
||||
|
||||
|
||||
def test_networkmanager_drops_previous_address(sandbox):
|
||||
_mkdirs(sandbox["root"], "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
|
||||
tools=("ip", "nmcli"))
|
||||
sandbox["run"](addresses="10.10.10.91/24", nm_connection="System eth0",
|
||||
tools=("ip", "nmcli"))
|
||||
|
||||
assert "-ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
|
||||
|
||||
|
||||
def test_ifupdown_wins_when_several_stacks_present(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/network/interfaces.d", "etc/netplan", "etc/sysconfig/network-scripts")
|
||||
(root / "etc/network/interfaces").write_text("auto eth0\niface eth0 inet dhcp\n")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
|
||||
tools=("ip", "nmcli", "ifup"))
|
||||
|
||||
assert sandbox["persist_of"](result) == "ifupdown"
|
||||
written = (root / "etc/network/interfaces.d/pvemanager-aliases-eth0").read_text()
|
||||
assert "iface eth0:1 inet static" in written
|
||||
assert "address 10.10.10.90/24" in written
|
||||
# source-строка дописывается, раз шаблон её не содержит
|
||||
assert "source /etc/network/interfaces.d/*" in (root / "etc/network/interfaces").read_text()
|
||||
|
||||
|
||||
def test_ifupdown_needs_ifup_present(sandbox):
|
||||
"""Каталог interfaces.d без ifupdown — не наш стек, уходим в sysconfig."""
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/network/interfaces.d", "etc/sysconfig/network-scripts")
|
||||
(root / "etc/network/interfaces").write_text("auto eth0\n")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip",))
|
||||
|
||||
assert sandbox["persist_of"](result) == "sysconfig"
|
||||
|
||||
|
||||
def test_netplan_branch_writes_yaml(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/netplan", "etc/sysconfig/network-scripts")
|
||||
|
||||
result = sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
|
||||
|
||||
assert sandbox["persist_of"](result) == "netplan"
|
||||
written = (root / "etc/netplan/99-pvemanager-aliases.yaml").read_text()
|
||||
assert " eth0:" in written
|
||||
assert " - 10.10.10.90/24" in written
|
||||
assert " - 10.10.10.91/24" in written
|
||||
|
||||
|
||||
def test_no_known_stack_reports_none_but_still_adds_address(sandbox):
|
||||
"""Адрес поднимается всегда; не закрепился — об этом говорит persist=none."""
|
||||
result = sandbox["run"](addresses="10.10.10.90/24")
|
||||
|
||||
assert sandbox["persist_of"](result) == "none"
|
||||
assert "addr add 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
|
||||
assert "PVEMANAGER_OK=1" in result.stdout
|
||||
|
||||
|
||||
# --- общие инварианты ----------------------------------------------------------
|
||||
|
||||
def test_state_file_tracks_managed_addresses(sandbox):
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
|
||||
|
||||
assert (root / "var/lib/pvemanager/aliases.eth0").read_text().strip() == \
|
||||
"10.10.10.90/24 10.10.10.91/24"
|
||||
|
||||
|
||||
def test_second_run_is_idempotent(sandbox):
|
||||
"""Повтор с тем же списком не должен ни удалять, ни добавлять заново."""
|
||||
root = sandbox["root"]
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24")
|
||||
before = sandbox["log"]("ip")
|
||||
sandbox["run"](addresses="10.10.10.90/24")
|
||||
added_again = sandbox["log"]("ip")[len(before):]
|
||||
|
||||
assert "addr del" not in added_again
|
||||
|
||||
|
||||
def test_other_interface_aliases_are_untouched(sandbox):
|
||||
root = sandbox["root"]
|
||||
scripts = root / "etc/sysconfig/network-scripts"
|
||||
_mkdirs(root, "etc/sysconfig/network-scripts")
|
||||
|
||||
sandbox["run"](addresses="10.10.10.90/24", iface="eth0")
|
||||
sandbox["run"](addresses="10.10.10.91/24", iface="eth1")
|
||||
|
||||
assert (scripts / "ifcfg-eth0:1").exists()
|
||||
assert (scripts / "ifcfg-eth1:1").exists()
|
||||
|
||||
|
||||
def test_missing_iface_is_rejected(sandbox):
|
||||
result = sandbox["run"](addresses="10.10.10.90/24", iface="")
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "missing IFACE" in result.stdout
|
||||
218
backend/tests/test_guest_ip_service.py
Normal file
218
backend/tests/test_guest_ip_service.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Дополнительные адреса гостя: что уезжает в гостя и как трактуется ответ."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ipam_service import IPAMService
|
||||
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
|
||||
from app.services import guest_ip_service, script_engine
|
||||
|
||||
|
||||
OK_IFUPDOWN = "PVEMANAGER_PERSIST=ifupdown\nPVEMANAGER_OK=1\n"
|
||||
OK_NO_PERSIST = "PVEMANAGER_PERSIST=none\nPVEMANAGER_OK=1\n"
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Подменяет script_engine.execute и запоминает, что ушло в гостя."""
|
||||
|
||||
def __init__(self, result):
|
||||
self.result = result
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, server, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return self.result
|
||||
|
||||
@property
|
||||
def last_script(self) -> str:
|
||||
return self.calls[-1]["script"]
|
||||
|
||||
def exported(self, name: str) -> str:
|
||||
"""Значение переменной из блока export, который добавляет render_params."""
|
||||
for line in self.last_script.splitlines():
|
||||
if line.startswith(f"export {name}="):
|
||||
return line.split("=", 1)[1].strip().strip("'")
|
||||
raise AssertionError(f"{name} not exported: {self.last_script[:200]}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guest(db_session):
|
||||
server = ProxmoxServer(name="Devserver", ip_address="192.168.43.254", hostname="dev",
|
||||
api_user="root@pam", use_password=True, password="pw")
|
||||
db_session.add(server)
|
||||
db_session.commit()
|
||||
|
||||
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
|
||||
is_active=True)
|
||||
db_session.add(network)
|
||||
db_session.commit()
|
||||
|
||||
instance = VMInstance(server_id=server.id, vmid=120, node="dev", vm_type="lxc",
|
||||
name="act-runner", status="running")
|
||||
db_session.add(instance)
|
||||
db_session.commit()
|
||||
|
||||
service = IPAMService(db_session)
|
||||
|
||||
def add_alias(ip, interface="eth0"):
|
||||
allocation, error = service.allocate_ip(
|
||||
ip_address=ip, network_id=network.id, resource_type="lxc",
|
||||
resource_name="act-runner", proxmox_server_id=server.id, proxmox_vmid=120,
|
||||
proxmox_node="dev", allocated_by="tester", assignment_kind="alias",
|
||||
target_interface=interface, apply_status=guest_ip_service.PENDING,
|
||||
)
|
||||
assert error is None
|
||||
return allocation
|
||||
|
||||
return {"db": db_session, "server": server, "network": network, "instance": instance,
|
||||
"service": service, "add_alias": add_alias}
|
||||
|
||||
|
||||
def _patch(monkeypatch, result):
|
||||
recorder = _Recorder(result)
|
||||
monkeypatch.setattr(guest_ip_service.script_engine, "execute", recorder)
|
||||
return recorder
|
||||
|
||||
|
||||
def test_apply_sends_every_alias_of_the_interface(guest, monkeypatch):
|
||||
"""В гостя уезжает полный список адресов, а не только новый."""
|
||||
guest["add_alias"]("10.10.10.90")
|
||||
second = guest["add_alias"]("10.10.10.91")
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
result = guest_ip_service.apply_address(guest["db"], second)
|
||||
|
||||
assert result.success and result.status == guest_ip_service.APPLIED
|
||||
assert recorder.exported("IFACE") == "eth0"
|
||||
assert recorder.exported("ADDRESSES") == "10.10.10.90/24 10.10.10.91/24"
|
||||
assert recorder.calls[-1]["vm_type"] == "lxc"
|
||||
assert recorder.calls[-1]["vmid"] == 120
|
||||
|
||||
|
||||
def test_prefix_comes_from_the_ipam_network(guest, monkeypatch):
|
||||
guest["network"].network = "10.10.0.0/16"
|
||||
guest["db"].commit()
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
guest_ip_service.apply_address(guest["db"], allocation)
|
||||
assert recorder.exported("ADDRESSES") == "10.10.10.90/16"
|
||||
|
||||
|
||||
def test_unrecognised_stack_is_runtime_only(guest, monkeypatch):
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
_patch(monkeypatch, script_engine.ExecResult(True, OK_NO_PERSIST, 0))
|
||||
|
||||
result = guest_ip_service.apply_address(guest["db"], allocation)
|
||||
|
||||
assert result.success
|
||||
assert result.status == guest_ip_service.RUNTIME_ONLY
|
||||
assert allocation.apply_status == guest_ip_service.RUNTIME_ONLY
|
||||
assert allocation.applied_at is not None
|
||||
|
||||
|
||||
def test_unreachable_guest_keeps_allocation_pending(guest, monkeypatch):
|
||||
"""Гость выключен или нет SSH — адрес остаётся за ним, статус pending."""
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
_patch(monkeypatch, script_engine.ExecResult(
|
||||
False, "", -1, error="Не удалось подключиться по SSH к Devserver"))
|
||||
|
||||
result = guest_ip_service.apply_address(guest["db"], allocation)
|
||||
|
||||
assert not result.success
|
||||
assert result.status == guest_ip_service.PENDING
|
||||
assert allocation.apply_status == guest_ip_service.PENDING
|
||||
assert "SSH" in allocation.apply_error
|
||||
assert allocation.applied_at is None
|
||||
# Аллокация никуда не делась — адрес зарезервирован
|
||||
assert guest["db"].query(IPAMAllocation).count() == 1
|
||||
|
||||
|
||||
def test_script_error_marks_allocation_failed(guest, monkeypatch):
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
_patch(monkeypatch, script_engine.ExecResult(
|
||||
False, "failed to add 10.10.10.90/24 on eth0", 1))
|
||||
|
||||
result = guest_ip_service.apply_address(guest["db"], allocation)
|
||||
|
||||
assert result.status == guest_ip_service.FAILED
|
||||
assert allocation.apply_status == guest_ip_service.FAILED
|
||||
|
||||
|
||||
def test_missing_ok_marker_is_not_treated_as_success(guest, monkeypatch):
|
||||
"""exit=0 без маркера — скрипт не доработал; успехом это не считаем."""
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
_patch(monkeypatch, script_engine.ExecResult(True, "some noise", 0))
|
||||
|
||||
result = guest_ip_service.apply_address(guest["db"], allocation)
|
||||
assert not result.success and result.status == guest_ip_service.FAILED
|
||||
|
||||
|
||||
def test_released_address_disappears_from_the_synced_list(guest, monkeypatch):
|
||||
"""Снятие адреса = синхронизация без него: скрипт сам сделает ip addr del."""
|
||||
keep = guest["add_alias"]("10.10.10.90")
|
||||
drop = guest["add_alias"]("10.10.10.91")
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
guest["service"].release_ip(drop.ip_address, released_by="tester")
|
||||
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
|
||||
|
||||
assert recorder.exported("ADDRESSES") == "10.10.10.90/24"
|
||||
assert keep.apply_status == guest_ip_service.APPLIED
|
||||
|
||||
|
||||
def test_last_address_removal_sends_empty_list(guest, monkeypatch):
|
||||
only = guest["add_alias"]("10.10.10.90")
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
guest["service"].release_ip(only.ip_address, released_by="tester")
|
||||
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
|
||||
|
||||
assert recorder.exported("ADDRESSES") == ""
|
||||
|
||||
|
||||
def test_addresses_of_other_interfaces_are_not_touched(guest, monkeypatch):
|
||||
guest["add_alias"]("10.10.10.90", interface="eth0")
|
||||
guest["add_alias"]("10.10.10.91", interface="eth1")
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth1")
|
||||
|
||||
assert recorder.exported("IFACE") == "eth1"
|
||||
assert recorder.exported("ADDRESSES") == "10.10.10.91/24"
|
||||
|
||||
|
||||
def test_bogus_interface_name_never_reaches_the_guest(guest, monkeypatch):
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
result = guest_ip_service.sync_interface(
|
||||
guest["db"], guest["server"].id, 120, "eth0; rm -rf /")
|
||||
|
||||
assert not result.success and result.status == guest_ip_service.FAILED
|
||||
assert recorder.calls == []
|
||||
|
||||
|
||||
def test_primary_address_is_not_pushed_into_the_guest(guest, monkeypatch):
|
||||
"""Основной адрес задаётся конфигом Proxmox — скрипт для него не нужен."""
|
||||
allocation, _ = guest["service"].allocate_ip(
|
||||
ip_address="10.10.10.5", network_id=guest["network"].id,
|
||||
proxmox_server_id=guest["server"].id, proxmox_vmid=120,
|
||||
allocated_by="tester", is_primary=True, assignment_kind="primary",
|
||||
)
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
result = guest_ip_service.apply_address(guest["db"], allocation)
|
||||
|
||||
assert result.success
|
||||
assert recorder.calls == []
|
||||
|
||||
|
||||
def test_unknown_guest_is_pending_not_crash(guest, monkeypatch):
|
||||
allocation = guest["add_alias"]("10.10.10.90")
|
||||
guest["instance"].deleted_at = __import__("app.config", fromlist=["utcnow"]).utcnow()
|
||||
guest["db"].commit()
|
||||
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
|
||||
|
||||
result = guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
|
||||
|
||||
assert result.status == guest_ip_service.PENDING
|
||||
assert recorder.calls == []
|
||||
178
backend/tests/test_ipam_link.py
Normal file
178
backend/tests/test_ipam_link.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Tests for IPAM link-allocations: MAC propagation and duplicate-IP handling."""
|
||||
|
||||
import ipaddress
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.ipam import _extract_ipv4s
|
||||
from app.ipam_service import IPAMService
|
||||
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
|
||||
|
||||
|
||||
# --- _extract_ipv4s -----------------------------------------------------------
|
||||
|
||||
HOME_NET = ipaddress.ip_network("10.10.10.0/24")
|
||||
|
||||
|
||||
class _Net:
|
||||
"""Stand-in for an IPAMNetwork row (only .id is read by the caller)."""
|
||||
|
||||
def __init__(self, id_=1):
|
||||
self.id = id_
|
||||
|
||||
|
||||
def test_extract_returns_mac_and_interface_of_matching_nic():
|
||||
interfaces = [
|
||||
{"name": "eth0", "hardware_address": "bc:24:11:aa:bb:cc",
|
||||
"ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}]},
|
||||
]
|
||||
found = _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])
|
||||
assert len(found) == 1
|
||||
assert (found[0].ip, found[0].mac, found[0].interface) == (
|
||||
"10.10.10.9", "BC:24:11:AA:BB:CC", "eth0")
|
||||
|
||||
|
||||
def test_extract_returns_every_address_in_ipam_networks():
|
||||
"""Гость с несколькими адресами отдаёт их все, а не только первый."""
|
||||
interfaces = [
|
||||
{"name": "eth0", "hardware_address": "AA:AA:AA:AA:AA:AA",
|
||||
"ips": [{"address": "192.168.99.5", "type": "ipv4", "prefix": 24},
|
||||
{"address": "10.10.10.9", "type": "ipv4", "prefix": 24},
|
||||
{"address": "10.10.10.90", "type": "ipv4", "prefix": 24}]},
|
||||
{"name": "eth1", "hardware_address": "BB:BB:BB:BB:BB:BB",
|
||||
"ips": [{"address": "10.10.10.44", "type": "ipv4", "prefix": 24}]},
|
||||
]
|
||||
found = _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])
|
||||
|
||||
assert [f.ip for f in found] == ["10.10.10.9", "10.10.10.90", "10.10.10.44"]
|
||||
assert [f.interface for f in found] == ["eth0", "eth0", "eth1"]
|
||||
assert found[-1].mac == "BB:BB:BB:BB:BB:BB"
|
||||
|
||||
|
||||
def test_extract_skips_loopback_and_link_local():
|
||||
interfaces = [{"name": "eth0", "ips": [
|
||||
{"address": "127.0.0.1", "type": "ipv4", "prefix": 8},
|
||||
{"address": "169.254.1.5", "type": "ipv4", "prefix": 16},
|
||||
{"address": "10.10.10.9", "type": "ipv4", "prefix": 24},
|
||||
]}]
|
||||
assert [f.ip for f in _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])] == ["10.10.10.9"]
|
||||
|
||||
|
||||
def test_extract_without_mac_returns_none():
|
||||
interfaces = [{"name": "eth0", "ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}]}]
|
||||
assert _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])[0].mac is None
|
||||
|
||||
|
||||
def test_extract_no_match_returns_empty_list():
|
||||
interfaces = [{"name": "eth0", "hardware_address": "AA:BB:CC:DD:EE:FF",
|
||||
"ips": [{"address": "192.168.43.222", "type": "ipv4", "prefix": 24}]}]
|
||||
assert _extract_ipv4s(interfaces, [(_Net(), HOME_NET)]) == []
|
||||
|
||||
|
||||
# --- sync_from_proxmox_vm -----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def ipam_env(db_session):
|
||||
"""A server, a network and a helper to add guests."""
|
||||
server = ProxmoxServer(name="Homelab: Dev", ip_address="10.10.10.2", hostname="pve1",
|
||||
api_user="root@pam", use_password=True, password="x")
|
||||
db_session.add(server)
|
||||
db_session.commit()
|
||||
|
||||
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
|
||||
is_active=True)
|
||||
db_session.add(network)
|
||||
db_session.commit()
|
||||
|
||||
def add_guest(vmid, name, vm_type="lxc", deleted_at=None):
|
||||
guest = VMInstance(server_id=server.id, vmid=vmid, node="pve1", vm_type=vm_type,
|
||||
name=name, status="running", deleted_at=deleted_at)
|
||||
db_session.add(guest)
|
||||
db_session.commit()
|
||||
return guest
|
||||
|
||||
return {"db": db_session, "server": server, "network": network, "add_guest": add_guest,
|
||||
"service": IPAMService(db_session)}
|
||||
|
||||
|
||||
def _sync(env, vmid, name, ip, mac=None):
|
||||
return env["service"].sync_from_proxmox_vm(
|
||||
network_id=env["network"].id,
|
||||
proxmox_server_id=env["server"].id,
|
||||
vmid=vmid,
|
||||
vm_name=name,
|
||||
vm_type="lxc",
|
||||
ip_address=ip,
|
||||
node="pve1",
|
||||
mac_address=mac,
|
||||
synced_by="tester",
|
||||
)
|
||||
|
||||
|
||||
def test_sync_stores_mac_on_new_allocation(ipam_env):
|
||||
ipam_env["add_guest"](109, "gitea")
|
||||
alloc, error = _sync(ipam_env, 109, "gitea", "10.10.10.17", "BC:24:11:AA:BB:CC")
|
||||
|
||||
assert error is None
|
||||
assert alloc.mac_address == "BC:24:11:AA:BB:CC"
|
||||
assert alloc.proxmox_vmid == 109
|
||||
|
||||
|
||||
def test_duplicate_ip_does_not_steal_allocation_from_a_live_guest(ipam_env):
|
||||
"""The 10.10.10.101 case: two live guests advertising the same address."""
|
||||
ipam_env["add_guest"](101, "proxmox-center")
|
||||
ipam_env["add_guest"](104, "powerdns-admin")
|
||||
|
||||
first, error = _sync(ipam_env, 101, "proxmox-center", "10.10.10.101")
|
||||
assert error is None and first is not None
|
||||
|
||||
second, error = _sync(ipam_env, 104, "powerdns-admin", "10.10.10.101")
|
||||
assert second is None
|
||||
assert "already linked to proxmox-center" in error
|
||||
assert "VMID 101" in error
|
||||
|
||||
# Owner untouched.
|
||||
alloc = ipam_env["db"].query(IPAMAllocation).filter(
|
||||
IPAMAllocation.ip_address == "10.10.10.101").one()
|
||||
assert (alloc.proxmox_vmid, alloc.resource_name) == (101, "proxmox-center")
|
||||
|
||||
|
||||
def test_allocation_of_a_vanished_guest_is_reclaimed(ipam_env):
|
||||
"""A guest that no longer exists must not hold the address hostage."""
|
||||
ipam_env["add_guest"](101, "old-guest", deleted_at=None)
|
||||
_sync(ipam_env, 101, "old-guest", "10.10.10.101")
|
||||
|
||||
# Guest disappears from Proxmox (soft-deleted by the sync worker).
|
||||
guest = ipam_env["db"].query(VMInstance).filter(VMInstance.vmid == 101).one()
|
||||
from app.config import utcnow
|
||||
guest.deleted_at = utcnow()
|
||||
ipam_env["db"].commit()
|
||||
|
||||
ipam_env["add_guest"](104, "powerdns-admin")
|
||||
alloc, error = _sync(ipam_env, 104, "powerdns-admin", "10.10.10.101")
|
||||
|
||||
assert error is None
|
||||
assert (alloc.proxmox_vmid, alloc.resource_name) == (104, "powerdns-admin")
|
||||
|
||||
|
||||
def test_manual_allocation_without_vmid_is_adopted(ipam_env):
|
||||
"""A hand-made record with no Proxmox link is still linkable."""
|
||||
ipam_env["add_guest"](110, "postgresql")
|
||||
manual, error = ipam_env["service"].allocate_ip(
|
||||
ip_address="10.10.10.19", network_id=ipam_env["network"].id,
|
||||
resource_name="reserved by hand", allocated_by="admin")
|
||||
assert error is None and manual.proxmox_vmid is None
|
||||
|
||||
alloc, error = _sync(ipam_env, 110, "postgresql", "10.10.10.19", "AA:BB:CC:DD:EE:FF")
|
||||
assert error is None
|
||||
assert alloc.proxmox_vmid == 110
|
||||
|
||||
|
||||
def test_resync_of_the_same_guest_is_idempotent(ipam_env):
|
||||
ipam_env["add_guest"](115, "termix")
|
||||
first, _ = _sync(ipam_env, 115, "termix", "10.10.10.36", "AA:11:22:33:44:55")
|
||||
again, error = _sync(ipam_env, 115, "termix", "10.10.10.36", "AA:11:22:33:44:55")
|
||||
|
||||
assert error is None
|
||||
assert again.id == first.id
|
||||
assert ipam_env["db"].query(IPAMAllocation).count() == 1
|
||||
257
backend/tests/test_ipam_multi_ip.py
Normal file
257
backend/tests/test_ipam_multi_ip.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Несколько IP-адресов на одном инстансе: сервис, API и колонка IP."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ipam_service import IPAMService
|
||||
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
|
||||
from app.services import guest_ip_service, script_engine
|
||||
|
||||
|
||||
OK_OUTPUT = "PVEMANAGER_PERSIST=ifupdown\nPVEMANAGER_OK=1\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(db_session, seed_users):
|
||||
server = ProxmoxServer(name="Homelab: Dev", ip_address="10.10.10.2", hostname="dev",
|
||||
api_user="root@pam", use_password=True, password="pw",
|
||||
is_online=True)
|
||||
db_session.add(server)
|
||||
db_session.commit()
|
||||
|
||||
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
|
||||
is_active=True)
|
||||
db_session.add(network)
|
||||
db_session.commit()
|
||||
|
||||
instance = VMInstance(server_id=server.id, vmid=109, node="dev", vm_type="lxc",
|
||||
name="gitea", status="running")
|
||||
db_session.add(instance)
|
||||
db_session.commit()
|
||||
|
||||
service = IPAMService(db_session)
|
||||
|
||||
def allocate(ip, primary=False, kind="alias", interface="eth0"):
|
||||
allocation, error = service.allocate_ip(
|
||||
ip_address=ip, network_id=network.id, resource_type="lxc", resource_name="gitea",
|
||||
proxmox_server_id=server.id, proxmox_vmid=109, proxmox_node="dev",
|
||||
allocated_by="tester", is_primary=primary, assignment_kind=kind,
|
||||
target_interface=interface,
|
||||
)
|
||||
assert error is None, error
|
||||
return allocation
|
||||
|
||||
return {"db": db_session, "server": server, "network": network, "instance": instance,
|
||||
"service": service, "allocate": allocate}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_real_guest(monkeypatch):
|
||||
"""Скрипт в гостя не уезжает — транспорт проверяется отдельным тестом."""
|
||||
monkeypatch.setattr(
|
||||
guest_ip_service.script_engine, "execute",
|
||||
lambda server, **kwargs: script_engine.ExecResult(True, OK_OUTPUT, 0),
|
||||
)
|
||||
|
||||
|
||||
# --- сервис -------------------------------------------------------------------
|
||||
|
||||
def test_guest_can_hold_several_addresses(env):
|
||||
env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
env["allocate"]("10.10.10.90")
|
||||
env["allocate"]("10.10.10.91")
|
||||
|
||||
rows = env["service"].find_allocations_by_resource(env["server"].id, 109)
|
||||
assert [r.ip_address for r in rows] == ["10.10.10.17", "10.10.10.90", "10.10.10.91"]
|
||||
assert rows[0].is_primary is True
|
||||
|
||||
|
||||
def test_primary_lookup_returns_the_flagged_address(env):
|
||||
env["allocate"]("10.10.10.90")
|
||||
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
|
||||
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
|
||||
assert found.id == primary.id
|
||||
|
||||
|
||||
def test_lookup_falls_back_to_earliest_without_flag(env):
|
||||
"""Записи старше миграции multi-IP флага не имеют — берём самую раннюю."""
|
||||
first = env["allocate"]("10.10.10.90")
|
||||
env["allocate"]("10.10.10.91")
|
||||
|
||||
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
|
||||
assert found.id == first.id
|
||||
|
||||
|
||||
def test_set_primary_leaves_exactly_one_primary(env):
|
||||
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
new = env["allocate"]("10.10.10.90")
|
||||
|
||||
updated, error = env["service"].set_primary(env["server"].id, 109, new.id, changed_by="tester")
|
||||
|
||||
assert error is None and updated.id == new.id
|
||||
env["db"].refresh(old)
|
||||
assert [a.is_primary for a in env["service"].find_allocations_by_resource(env["server"].id, 109)] \
|
||||
== [True, False]
|
||||
assert old.is_primary is False
|
||||
|
||||
|
||||
def test_set_primary_rejects_foreign_allocation(env):
|
||||
other = env["service"].allocate_ip(
|
||||
ip_address="10.10.10.50", network_id=env["network"].id,
|
||||
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
|
||||
|
||||
updated, error = env["service"].set_primary(env["server"].id, 109, other.id)
|
||||
assert updated is None and "does not belong" in error
|
||||
|
||||
|
||||
def test_deleting_a_guest_releases_every_address(env):
|
||||
env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
env["allocate"]("10.10.10.90")
|
||||
env["allocate"]("10.10.10.91")
|
||||
|
||||
released, ips = env["service"].release_ip_by_vmid(env["server"].id, 109, released_by="tester")
|
||||
|
||||
assert released is True
|
||||
assert "10.10.10.17" in ips and "10.10.10.91" in ips
|
||||
assert env["db"].query(IPAMAllocation).count() == 0
|
||||
|
||||
|
||||
# --- API ----------------------------------------------------------------------
|
||||
|
||||
def test_list_addresses_returns_primary_first(env, client, admin_headers):
|
||||
env["allocate"]("10.10.10.90")
|
||||
env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
|
||||
response = client.get(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["count"] == 2
|
||||
assert body["addresses"][0]["ip_address"] == "10.10.10.17"
|
||||
assert body["addresses"][0]["is_primary"] is True
|
||||
assert body["addresses"][0]["prefix"] == "24"
|
||||
|
||||
|
||||
def test_add_address_allocates_and_applies(env, client, admin_headers):
|
||||
response = client.post(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses",
|
||||
headers=admin_headers,
|
||||
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
|
||||
"target_interface": "eth0"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["applied"] is True
|
||||
assert body["apply_status"] == guest_ip_service.APPLIED
|
||||
assert body["address"]["assignment_kind"] == "alias"
|
||||
|
||||
stored = env["db"].query(IPAMAllocation).filter(
|
||||
IPAMAllocation.ip_address == "10.10.10.90").one()
|
||||
assert stored.proxmox_vmid == 109
|
||||
assert stored.target_interface == "eth0"
|
||||
|
||||
|
||||
def test_add_address_picks_next_free_ip(env, client, admin_headers):
|
||||
env["allocate"]("10.10.10.2", primary=True, kind="primary")
|
||||
|
||||
response = client.post(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses",
|
||||
headers=admin_headers,
|
||||
json={"network_id": env["network"].id, "target_interface": "eth0"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assigned = response.json()["address"]["ip_address"]
|
||||
assert assigned.startswith("10.10.10.")
|
||||
assert assigned not in ("10.10.10.1", "10.10.10.2") # шлюз и занятый адрес
|
||||
|
||||
|
||||
def test_add_address_can_take_over_as_primary(env, client, admin_headers):
|
||||
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
|
||||
response = client.post(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses",
|
||||
headers=admin_headers,
|
||||
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
|
||||
"target_interface": "eth0", "make_primary": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
env["db"].refresh(old)
|
||||
env["db"].refresh(env["instance"])
|
||||
assert old.is_primary is False
|
||||
# Кэш инстанса показывает новый основной адрес
|
||||
assert env["instance"].ip_address == "10.10.10.90"
|
||||
|
||||
|
||||
def test_duplicate_address_is_rejected(env, client, admin_headers):
|
||||
env["allocate"]("10.10.10.90")
|
||||
|
||||
response = client.post(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses",
|
||||
headers=admin_headers,
|
||||
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
|
||||
"target_interface": "eth0"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already allocated" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_release_address_frees_it(env, client, admin_headers):
|
||||
alias = env["allocate"]("10.10.10.90")
|
||||
|
||||
response = client.delete(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses/{alias.id}", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["released"] == "10.10.10.90"
|
||||
assert env["db"].query(IPAMAllocation).filter(
|
||||
IPAMAllocation.ip_address == "10.10.10.90").first() is None
|
||||
|
||||
|
||||
def test_primary_address_cannot_be_released_here(env, client, admin_headers):
|
||||
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
|
||||
response = client.delete(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses/{primary.id}", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "NIC config" in response.json()["detail"]
|
||||
assert env["db"].query(IPAMAllocation).count() == 1
|
||||
|
||||
|
||||
def test_address_of_another_guest_is_not_accessible(env, client, admin_headers):
|
||||
foreign = env["service"].allocate_ip(
|
||||
ip_address="10.10.10.50", network_id=env["network"].id,
|
||||
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
|
||||
|
||||
response = client.delete(
|
||||
f"/ipam/api/guests/{env['server'].id}/109/addresses/{foreign.id}", headers=admin_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_instance_list_exposes_all_addresses(env, client, admin_headers):
|
||||
env["allocate"]("10.10.10.17", primary=True, kind="primary")
|
||||
env["allocate"]("10.10.10.90")
|
||||
|
||||
response = client.get("/proxmox/api/virtual-machines", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
vm = next(v for v in response.json() if v["vmid"] == 109)
|
||||
assert vm["ip"] == "10.10.10.17" # колонка IP показывает основной
|
||||
assert [a["ip"] for a in vm["ips"]] == ["10.10.10.17", "10.10.10.90"]
|
||||
assert [a["is_primary"] for a in vm["ips"]] == [True, False]
|
||||
|
||||
|
||||
def test_network_scan_skips_node_and_cached_guest_ips(env):
|
||||
"""Скан вне пула не должен предлагать адрес ноды или известного гостя."""
|
||||
# Нода Homelab: Dev живёт на 10.10.10.2, гость gitea — на 10.10.10.3
|
||||
env["instance"].ip_address = "10.10.10.3"
|
||||
env["db"].commit()
|
||||
|
||||
ip = env["service"].get_next_available_ip(
|
||||
env["network"].id, None, allow_network_scan=True)
|
||||
|
||||
assert ip not in ("10.10.10.1", "10.10.10.2", "10.10.10.3")
|
||||
74
backend/tests/test_vm_list_ipam_lookup.py
Normal file
74
backend/tests/test_vm_list_ipam_lookup.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""IP shown for a guest must come from its own IPAM allocation, never from a
|
||||
same-named guest on another server."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestVMListIPAMLookup:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, db_session, seed_users):
|
||||
def _server(name, ip):
|
||||
server = ProxmoxServer(name=name, hostname=name, ip_address=ip, port=8006,
|
||||
api_user="root@pam", use_password=True, password="pw",
|
||||
verify_ssl=False, is_online=False)
|
||||
db_session.add(server)
|
||||
db_session.commit()
|
||||
db_session.refresh(server)
|
||||
return server
|
||||
|
||||
# Two distinct servers, each with its own node that happens to share a name.
|
||||
self.server_a = _server("homelab-dev", "10.10.10.2")
|
||||
self.server_b = _server("devserver", "192.168.43.254")
|
||||
|
||||
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24",
|
||||
gateway="10.10.10.1", is_active=True)
|
||||
db_session.add(network)
|
||||
db_session.commit()
|
||||
|
||||
# Same guest name on both servers — the real-world "wireguard" case.
|
||||
self.linked = VMInstance(server_id=self.server_a.id, vmid=107, node="dev",
|
||||
vm_type="lxc", name="wireguard", status="running")
|
||||
self.other = VMInstance(server_id=self.server_b.id, vmid=102, node="dev",
|
||||
vm_type="lxc", name="wireguard", status="running")
|
||||
db_session.add_all([self.linked, self.other])
|
||||
db_session.commit()
|
||||
|
||||
db_session.add(IPAMAllocation(
|
||||
ip_address="10.10.10.35", network_id=network.id, status="allocated",
|
||||
resource_type="lxc", resource_name="wireguard",
|
||||
proxmox_server_id=self.server_a.id, proxmox_vmid=107, proxmox_node="dev",
|
||||
))
|
||||
db_session.commit()
|
||||
self.db = db_session
|
||||
|
||||
def _instances(self, client, headers):
|
||||
response = client.get("/proxmox/api/virtual-machines", headers=headers)
|
||||
assert response.status_code == 200
|
||||
return {(vm["server_id"], vm["vmid"]): vm for vm in response.json()}
|
||||
|
||||
def test_allocation_is_not_shared_with_a_same_named_guest(self, client, admin_headers):
|
||||
vms = self._instances(client, admin_headers)
|
||||
|
||||
assert vms[(self.server_a.id, 107)]["ip"] == "10.10.10.35"
|
||||
# The namesake on the other server owns no allocation and has no cached IP.
|
||||
assert vms[(self.server_b.id, 102)]["ip"] == ""
|
||||
|
||||
def test_cached_ip_still_wins_when_no_allocation_exists(self, client, admin_headers):
|
||||
self.other.ip_address = "192.168.43.246"
|
||||
self.db.commit()
|
||||
|
||||
vms = self._instances(client, admin_headers)
|
||||
assert vms[(self.server_b.id, 102)]["ip"] == "192.168.43.246"
|
||||
assert vms[(self.server_a.id, 107)]["ip"] == "10.10.10.35"
|
||||
|
||||
def test_allocation_hostname_does_not_leak_either(self, client, admin_headers):
|
||||
"""A hostname on the allocation used to match guests by name as well."""
|
||||
alloc = self.db.query(IPAMAllocation).one()
|
||||
alloc.hostname = "wireguard"
|
||||
self.db.commit()
|
||||
|
||||
vms = self._instances(client, admin_headers)
|
||||
assert vms[(self.server_b.id, 102)]["ip"] == ""
|
||||
186
frontend/package-lock.json
generated
186
frontend/package-lock.json
generated
@@ -18,8 +18,10 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
@@ -2033,6 +2035,15 @@
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
@@ -2063,6 +2074,12 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
@@ -2084,6 +2101,25 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
@@ -2162,7 +2198,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2540,6 +2576,76 @@
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz",
|
||||
"integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.80",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=17",
|
||||
"@types/react-dom": ">=17",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.80",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz",
|
||||
"integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -2956,6 +3062,12 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
@@ -3269,6 +3381,28 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
@@ -3324,6 +3458,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
@@ -3369,6 +3512,41 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
@@ -4716,6 +4894,12 @@
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
|
||||
44
frontend/src/components/shared/ActiveTasksIndicator.tsx
Normal file
44
frontend/src/components/shared/ActiveTasksIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ListTodo } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useActiveTaskCount } from '@/hooks/use-tasks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ActiveTasksIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data } = useActiveTaskCount();
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative"
|
||||
onClick={() => navigate('/tasks')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
{count > 0 && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="absolute -right-0.5 -top-0.5 h-4 min-w-4 px-1 text-2xs"
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{count > 0
|
||||
? t('tasks.active_count', { count })
|
||||
: t('tasks.no_active', 'No active tasks')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative grid w-full grid-cols-[0_1fr] items-start gap-x-2.5 gap-y-1 rounded-lg border px-3.5 py-3 text-sm has-[>svg]:grid-cols-[theme(spacing.4)_1fr] [&>svg]:mt-0.5 [&>svg]:size-4",
|
||||
"relative grid w-full grid-cols-[0_minmax(0,1fr)] items-start gap-x-2.5 gap-y-1 rounded-lg border px-3.5 py-3 text-sm has-[>svg]:grid-cols-[theme(spacing.4)_minmax(0,1fr)] [&>svg]:mt-0.5 [&>svg]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -55,7 +55,11 @@ function DialogContent({
|
||||
className={cn(
|
||||
// Default width: capped at max-w-lg and centered; on small screens w-[calc(100%-2rem)]
|
||||
// keeps a 1rem gap on each side. Individual dialogs override width via their own max-w-*.
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
// grid-cols-[minmax(0,1fr)] caps the implicit column at the dialog
|
||||
// width: an `auto` track is max-content sized, so long unbreakable
|
||||
// content (e.g. CT template names) would otherwise widen the column
|
||||
// and push fields past the dialog edge.
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg grid-cols-[minmax(0,1fr)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -68,7 +68,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
className={cn("flex min-w-0 flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -87,7 +87,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-full min-w-0 items-center justify-between gap-1.5 overflow-hidden rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
20
frontend/src/components/ui/slider.tsx
Normal file
20
frontend/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({ className, ...props }: SliderPrimitive.Root.Props) {
|
||||
return (
|
||||
<SliderPrimitive.Root data-slot="slider" {...props}>
|
||||
<SliderPrimitive.Control
|
||||
className={cn("flex w-full touch-none items-center py-2 select-none", className)}
|
||||
>
|
||||
<SliderPrimitive.Track className="h-1 w-full rounded-full bg-input select-none">
|
||||
<SliderPrimitive.Indicator className="rounded-full bg-primary select-none" />
|
||||
<SliderPrimitive.Thumb className="size-3.5 rounded-full bg-background border-2 border-primary shadow-xs outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50" />
|
||||
</SliderPrimitive.Track>
|
||||
</SliderPrimitive.Control>
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle, Search } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useBackupStorages, useProxmoxBackupJobs,
|
||||
useCreateStorage, useUpdateStorage, useDeleteStorage,
|
||||
useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob,
|
||||
useScanNfsExports,
|
||||
type ProxmoxJobInput,
|
||||
} from '@/hooks/use-backups';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
@@ -88,6 +89,8 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
const { t } = useTranslation();
|
||||
const createStorage = useCreateStorage(serverId);
|
||||
const updateStorage = useUpdateStorage(serverId);
|
||||
const scanNfs = useScanNfsExports(serverId);
|
||||
const [nfsExports, setNfsExports] = useState<string[]>([]);
|
||||
const [form, setForm] = useState<StorageForm>(() => editing ? {
|
||||
...emptyForm,
|
||||
storage: editing.storage,
|
||||
@@ -102,6 +105,20 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
} : emptyForm);
|
||||
const set = <K extends keyof StorageForm>(k: K, v: StorageForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
const handleScanNfs = () => {
|
||||
if (!form.server.trim()) { toast.error(t('storages.server_required')); return; }
|
||||
setNfsExports([]);
|
||||
scanNfs.mutate(form.server.trim(), {
|
||||
onSuccess: (data) => {
|
||||
const paths = (data.exports ?? []).map(e => e.path).filter(Boolean);
|
||||
setNfsExports(paths);
|
||||
if (paths.length === 0) toast.error(t('storages.no_exports_found'));
|
||||
else if (!paths.includes(form.export)) set('export', paths[0]);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!editing && !form.storage.trim()) { toast.error(t('storages.id_required')); return; }
|
||||
const done = (msg: string) => { toast.success(msg); onOpenChange(false); };
|
||||
@@ -185,8 +202,27 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
)}
|
||||
{!editing && form.type === 'nfs' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('storages.server')}</Label><Input value={form.server} onChange={e => set('server', e.target.value)} className="mt-1" placeholder="10.0.0.5" /></div>
|
||||
<div><Label>{t('storages.export')}</Label><Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" /></div>
|
||||
<div>
|
||||
<Label>{t('storages.server')}</Label>
|
||||
<div className="mt-1 flex gap-1.5">
|
||||
<Input value={form.server} onChange={e => { set('server', e.target.value); setNfsExports([]); }} placeholder="10.0.0.5" />
|
||||
<Button type="button" variant="outline" size="icon" className="shrink-0" title={t('storages.scan_exports')}
|
||||
disabled={scanNfs.isPending} onClick={handleScanNfs}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('storages.export')}</Label>
|
||||
{nfsExports.length > 0 ? (
|
||||
<Select value={form.export} onValueChange={v => v && set('export', v)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{nfsExports.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!editing && form.type === 'cifs' && (
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { Maximize, Minimize, Keyboard, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Maximize,
|
||||
Minimize,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
Power,
|
||||
ChevronDown,
|
||||
RotateCw,
|
||||
PowerOff,
|
||||
Zap,
|
||||
Square,
|
||||
ClipboardPaste,
|
||||
Camera,
|
||||
Scan,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { vmTypeLabel } from '@/lib/format';
|
||||
import { usePowerAction } from '@/hooks/use-instances';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
// X11 keysyms используемые доп. клавишами консоли (см. noVNC core/input/keysym.js)
|
||||
const KEYSYM = {
|
||||
BackSpace: 0xff08,
|
||||
Tab: 0xff09,
|
||||
Escape: 0xff1b,
|
||||
Delete: 0xffff,
|
||||
Control_L: 0xffe3,
|
||||
Alt_L: 0xffe9,
|
||||
F1: 0xffbe,
|
||||
} as const;
|
||||
const FKEY_CODES = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `F${i + 1}`,
|
||||
keysym: KEYSYM.F1 + i,
|
||||
code: `F${i + 1}`,
|
||||
}));
|
||||
|
||||
|
||||
// noVNC and xterm.js will be loaded dynamically
|
||||
// We use dynamic imports for code splitting
|
||||
@@ -23,6 +73,22 @@ interface VNCData {
|
||||
auth_ticket?: string;
|
||||
}
|
||||
|
||||
// Сессия консоли переживает один проход эффекта: connect* асинхронны, а React
|
||||
// StrictMode в dev монтирует эффект дважды. Без флага отмены вторая пара
|
||||
// xterm/RFB ложится в тот же контейнер поверх первой — «осиротевший» терминал
|
||||
// остаётся в DOM и занимает видимую область, из-за чего экран выглядит пустым.
|
||||
interface SessionGuard {
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
interface RFBHandle {
|
||||
disconnect: () => void;
|
||||
sendCtrlAltDel: () => void;
|
||||
sendKey: (keysym: number, code: string, down?: boolean) => void;
|
||||
clipboardPasteFrom: (text: string) => void;
|
||||
scaleViewport: boolean;
|
||||
}
|
||||
|
||||
export default function ConsolePage() {
|
||||
const { serverId, vmid } = useParams<{ serverId: string; vmid: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -30,25 +96,57 @@ export default function ConsolePage() {
|
||||
const node = searchParams.get('node') || '';
|
||||
const type = searchParams.get('type') || 'qemu';
|
||||
const isSerial = type === 'qemu' && searchParams.get('mode') === 'serial';
|
||||
// Панель управления питанием/консолью доступна для VNC (qemu, не serial); для
|
||||
// serial и LXC-терминала клавиатура и буфер обмена уже идут через сам xterm.
|
||||
const isVnc = type === 'qemu' && !isSerial;
|
||||
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [scaleToFit, setScaleToFit] = useState(true);
|
||||
const [clipboardOpen, setClipboardOpen] = useState(false);
|
||||
const [clipboardText, setClipboardText] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rfbRef = useRef<unknown>(null);
|
||||
const rfbRef = useRef<RFBHandle | null>(null);
|
||||
const termRef = useRef<unknown>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const sid = Number(serverId);
|
||||
const vid = Number(vmid);
|
||||
const power = usePowerAction(sid, vid, type, node);
|
||||
|
||||
const guardRef = useRef<SessionGuard | null>(null);
|
||||
|
||||
// Освобождает ресурсы текущей сессии (RFB / xterm / WebSocket / keepalive).
|
||||
const cleanupSession = useCallback(() => {
|
||||
if (rfbRef.current) {
|
||||
try { rfbRef.current.disconnect(); } catch { /* */ }
|
||||
rfbRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (keepaliveRef.current) {
|
||||
clearInterval(keepaliveRef.current);
|
||||
keepaliveRef.current = null;
|
||||
}
|
||||
if (termRef.current) {
|
||||
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
|
||||
t.cleanup();
|
||||
t.terminal.dispose();
|
||||
termRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ==================== VNC Console (QEMU) ====================
|
||||
const connectVNC = useCallback(async () => {
|
||||
const connectVNC = useCallback(async (guard: SessionGuard) => {
|
||||
try {
|
||||
const data = await apiClient.get<VNCData>(
|
||||
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
|
||||
);
|
||||
if (guard.cancelled) return;
|
||||
|
||||
// Dynamic import noVNC. The package ships CJS, so depending on bundler
|
||||
// interop the result can be: { default: RFB }, { default: { default: RFB, __esModule: true } },
|
||||
@@ -84,7 +182,7 @@ export default function ConsolePage() {
|
||||
|
||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
|
||||
|
||||
if (!containerRef.current) return;
|
||||
if (guard.cancelled || !containerRef.current) return;
|
||||
|
||||
// Pass URL string to RFB, NOT an open WebSocket
|
||||
const rfb = new RFB(containerRef.current, wsUrl, {
|
||||
@@ -92,13 +190,15 @@ export default function ConsolePage() {
|
||||
});
|
||||
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
rfb.resizeSession = false;
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('connected');
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (e: CustomEvent) => {
|
||||
if (guard.cancelled) return;
|
||||
if (e.detail?.clean) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Connection closed');
|
||||
@@ -108,8 +208,10 @@ export default function ConsolePage() {
|
||||
}
|
||||
});
|
||||
|
||||
rfbRef.current = rfb;
|
||||
rfbRef.current = rfb as RFBHandle;
|
||||
setScaleToFit(true);
|
||||
} catch (err) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
@@ -117,7 +219,7 @@ export default function ConsolePage() {
|
||||
|
||||
// ==================== xterm.js session (LXC terminal / VM serial) ====================
|
||||
// Общая обвязка xterm + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси.
|
||||
const setupXtermSession = useCallback(async (wsPath: string) => {
|
||||
const setupXtermSession = useCallback(async (wsPath: string, guard: SessionGuard) => {
|
||||
// Dynamic import xterm.js
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
@@ -125,7 +227,7 @@ export default function ConsolePage() {
|
||||
import('@xterm/addon-web-links'),
|
||||
]);
|
||||
|
||||
if (!containerRef.current) return;
|
||||
if (guard.cancelled || !containerRef.current) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
@@ -153,9 +255,12 @@ export default function ConsolePage() {
|
||||
const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (guard.cancelled) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
setStatus('connected');
|
||||
const { cols, rows } = terminal;
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
@@ -165,11 +270,20 @@ export default function ConsolePage() {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (guard.cancelled) return;
|
||||
if (event.data instanceof ArrayBuffer) terminal.write(new Uint8Array(event.data));
|
||||
else terminal.write(event.data);
|
||||
};
|
||||
ws.onclose = () => { setStatus('error'); setErrorMsg('Terminal connection closed'); };
|
||||
ws.onerror = () => { setStatus('error'); setErrorMsg('Terminal connection error'); };
|
||||
ws.onclose = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg('Terminal connection closed');
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg('Terminal connection error');
|
||||
};
|
||||
|
||||
terminal.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
@@ -183,62 +297,75 @@ export default function ConsolePage() {
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Сессию могли отменить, пока грузились чанки xterm. Убираем за собой сами и
|
||||
// НЕ трогаем refs: они уже могут указывать на актуальную сессию. Иначе в
|
||||
// контейнере остаётся невидимый терминал поверх живого — чёрный экран.
|
||||
if (guard.cancelled) {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
terminal.dispose();
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
|
||||
}, []);
|
||||
|
||||
// ==================== Terminal Console (LXC) ====================
|
||||
const connectTerminal = useCallback(async () => {
|
||||
const connectTerminal = useCallback(async (guard: SessionGuard) => {
|
||||
try {
|
||||
const data = await apiClient.get<VNCData>(
|
||||
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}`
|
||||
);
|
||||
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${data.node}/${vid}`);
|
||||
// Termproxy-сессию создаёт сам ws-эндпоинт. Отдельный REST-вызов
|
||||
// (GET .../container/{vmid}/terminal) поднимал бы на ноде вторую
|
||||
// termproxy-сессию, к которой никто не подключается: Proxmox ждёт клиента
|
||||
// ~10 секунд и завершает задачу с «failed waiting for client: timed out».
|
||||
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${node}/${vid}`, guard);
|
||||
} catch (err) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node, setupXtermSession]);
|
||||
|
||||
// ==================== Serial Console (QEMU) ====================
|
||||
const connectSerial = useCallback(async () => {
|
||||
const connectSerial = useCallback(async (guard: SessionGuard) => {
|
||||
try {
|
||||
// Убедиться, что у VM есть serial0 (добавит при отсутствии)
|
||||
await apiClient.post(`/proxmox/api/${sid}/vm/${vid}/serial/enable?node=${node}`);
|
||||
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`);
|
||||
if (guard.cancelled) return;
|
||||
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`, guard);
|
||||
} catch (err) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node, setupXtermSession]);
|
||||
|
||||
// ==================== Connect on mount ====================
|
||||
// Старт новой сессии: старая помечается отменённой и полностью убирается,
|
||||
// чтобы в контейнере всегда был ровно один живой терминал/RFB.
|
||||
const startSession = useCallback(() => {
|
||||
if (guardRef.current) guardRef.current.cancelled = true;
|
||||
cleanupSession();
|
||||
|
||||
const guard: SessionGuard = { cancelled: false };
|
||||
guardRef.current = guard;
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
|
||||
if (isSerial) connectSerial(guard);
|
||||
else if (type === 'qemu') connectVNC(guard);
|
||||
else connectTerminal(guard);
|
||||
}, [isSerial, type, connectSerial, connectVNC, connectTerminal, cleanupSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSerial) {
|
||||
connectSerial();
|
||||
} else if (type === 'qemu') {
|
||||
connectVNC();
|
||||
} else {
|
||||
connectTerminal();
|
||||
}
|
||||
startSession();
|
||||
|
||||
return () => {
|
||||
// Cleanup
|
||||
if (rfbRef.current) {
|
||||
try { (rfbRef.current as { disconnect: () => void }).disconnect(); } catch { /* */ }
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
if (keepaliveRef.current) {
|
||||
clearInterval(keepaliveRef.current);
|
||||
}
|
||||
if (termRef.current) {
|
||||
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
|
||||
t.cleanup();
|
||||
t.terminal.dispose();
|
||||
}
|
||||
if (guardRef.current) guardRef.current.cancelled = true;
|
||||
cleanupSession();
|
||||
};
|
||||
}, [type, isSerial, connectVNC, connectTerminal, connectSerial]);
|
||||
}, [startSession, cleanupSession]);
|
||||
|
||||
// ==================== Fullscreen ====================
|
||||
const toggleFullscreen = () => {
|
||||
@@ -253,13 +380,87 @@ export default function ConsolePage() {
|
||||
|
||||
// ==================== Send Ctrl+Alt+Del (VNC) ====================
|
||||
const sendCtrlAltDel = () => {
|
||||
if (rfbRef.current) {
|
||||
(rfbRef.current as { sendCtrlAltDel: () => void }).sendCtrlAltDel();
|
||||
rfbRef.current?.sendCtrlAltDel();
|
||||
};
|
||||
|
||||
// ==================== Extra keys (VNC) ====================
|
||||
const sendKeyCombo = (keysym: number, code: string) => {
|
||||
rfbRef.current?.sendKey(keysym, code);
|
||||
};
|
||||
const sendCtrlAltBackspace = () => {
|
||||
const rfb = rfbRef.current;
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(KEYSYM.Control_L, 'ControlLeft', true);
|
||||
rfb.sendKey(KEYSYM.Alt_L, 'AltLeft', true);
|
||||
rfb.sendKey(KEYSYM.BackSpace, 'Backspace', true);
|
||||
rfb.sendKey(KEYSYM.BackSpace, 'Backspace', false);
|
||||
rfb.sendKey(KEYSYM.Alt_L, 'AltLeft', false);
|
||||
rfb.sendKey(KEYSYM.Control_L, 'ControlLeft', false);
|
||||
};
|
||||
const sendCtrlAltF = (keysym: number, code: string) => {
|
||||
const rfb = rfbRef.current;
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(KEYSYM.Control_L, 'ControlLeft', true);
|
||||
rfb.sendKey(KEYSYM.Alt_L, 'AltLeft', true);
|
||||
rfb.sendKey(keysym, code, true);
|
||||
rfb.sendKey(keysym, code, false);
|
||||
rfb.sendKey(KEYSYM.Alt_L, 'AltLeft', false);
|
||||
rfb.sendKey(KEYSYM.Control_L, 'ControlLeft', false);
|
||||
};
|
||||
|
||||
// ==================== Scale toggle (VNC) ====================
|
||||
const toggleScale = () => {
|
||||
const rfb = rfbRef.current;
|
||||
if (!rfb) return;
|
||||
const next = !scaleToFit;
|
||||
rfb.scaleViewport = next;
|
||||
setScaleToFit(next);
|
||||
};
|
||||
|
||||
// ==================== Clipboard → VM (VNC) ====================
|
||||
const sendClipboard = () => {
|
||||
if (!clipboardText) return;
|
||||
rfbRef.current?.clipboardPasteFrom(clipboardText);
|
||||
setClipboardOpen(false);
|
||||
setClipboardText('');
|
||||
};
|
||||
|
||||
// ==================== Screenshot ====================
|
||||
const takeScreenshot = () => {
|
||||
// querySelectorAll + last(): в dev-режиме React StrictMode временно
|
||||
// монтирует RFB дважды, оставляя осиротевший canvas первым в DOM —
|
||||
// актуальный (живой) canvas всегда последний.
|
||||
const canvases = containerRef.current?.querySelectorAll('canvas');
|
||||
const canvas = canvases && canvases.length > 0 ? canvases[canvases.length - 1] : null;
|
||||
if (!canvas) {
|
||||
toast.error(t('console.screenshot_failed'));
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.download = `console-${type}-${vmid}-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
};
|
||||
|
||||
// ==================== Power actions ====================
|
||||
const runPowerAction = (action: 'restart' | 'shutdown' | 'reset' | 'stop', force?: boolean) => {
|
||||
if (
|
||||
(action === 'reset' || (action === 'stop' && force)) &&
|
||||
!window.confirm(action === 'reset' ? t('console.confirm_hard_reset') : t('console.confirm_hard_stop'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
power.mutate(
|
||||
{ action, force },
|
||||
{
|
||||
onSuccess: () => toast.success(t('console.action_sent')),
|
||||
onError: (err) => toast.error(err.message || t('console.action_failed')),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-[#09090B]">
|
||||
<div className="flex h-screen min-h-0 flex-col bg-[#09090B]">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between border-b border-border/30 bg-[#111113] px-3 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -281,20 +482,103 @@ export default function ConsolePage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{type === 'qemu' && !isSerial && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={sendCtrlAltDel}
|
||||
>
|
||||
<Keyboard className="mr-1 h-3 w-3" />
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
)}
|
||||
{isSerial && (
|
||||
<Badge variant="secondary" className="text-2xs bg-blue-500/10 text-blue-500">serial</Badge>
|
||||
)}
|
||||
|
||||
{/* Power menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" />}>
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('console.power')}
|
||||
<ChevronDown className="ml-1 h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuItem onClick={() => runPowerAction('restart')}>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
{t('console.reboot')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => runPowerAction('shutdown')}>
|
||||
<PowerOff className="h-3.5 w-3.5" />
|
||||
{t('console.shutdown')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{type === 'qemu' && (
|
||||
<DropdownMenuItem variant="destructive" onClick={() => runPowerAction('reset')}>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t('console.hard_reset')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem variant="destructive" onClick={() => runPowerAction('stop', true)}>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
{t('console.hard_stop')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isVnc && (
|
||||
<>
|
||||
{/* Extra keys menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" />}>
|
||||
<Keyboard className="mr-1 h-3 w-3" />
|
||||
{t('console.extra_keys')}
|
||||
<ChevronDown className="ml-1 h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuItem onClick={sendCtrlAltDel}>{t('console.ctrl_alt_del')}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={sendCtrlAltBackspace}>{t('console.ctrl_alt_backspace')}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => sendKeyCombo(KEYSYM.Tab, 'Tab')}>Tab</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => sendKeyCombo(KEYSYM.Escape, 'Escape')}>Esc</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Ctrl+Alt+F1…F12</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{FKEY_CODES.map((k) => (
|
||||
<DropdownMenuItem key={k.label} onClick={() => sendCtrlAltF(k.keysym, k.code)}>
|
||||
{k.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Scale toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
title={scaleToFit ? t('console.actual_size') : t('console.scale_to_fit')}
|
||||
onClick={toggleScale}
|
||||
>
|
||||
<Scan className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Clipboard */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
title={t('console.clipboard')}
|
||||
onClick={() => setClipboardOpen(true)}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Screenshot */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
title={t('console.screenshot')}
|
||||
onClick={takeScreenshot}
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -306,8 +590,35 @@ export default function ConsolePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={clipboardOpen} onOpenChange={setClipboardOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ClipboardPaste className="h-4 w-4" /> {t('console.clipboard_title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('console.clipboard_desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
value={clipboardText}
|
||||
onChange={(e) => setClipboardText(e.target.value)}
|
||||
placeholder={t('console.clipboard_placeholder')}
|
||||
rows={5}
|
||||
className="w-full min-w-0 resize-none rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClipboardOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={sendClipboard} disabled={!clipboardText}>
|
||||
{t('console.send')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Console viewport */}
|
||||
<div className="relative flex-1">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{status === 'connecting' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
@@ -324,13 +635,7 @@ export default function ConsolePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
if (isSerial) connectSerial();
|
||||
else if (type === 'qemu') connectVNC();
|
||||
else connectTerminal();
|
||||
}}
|
||||
onClick={startSession}
|
||||
>
|
||||
{t('console.reconnect')}
|
||||
</Button>
|
||||
@@ -340,8 +645,7 @@ export default function ConsolePage() {
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ minHeight: 'calc(100vh - 40px)' }}
|
||||
className="h-full w-full overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,30 @@ export default function NodeShellPage() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const termRef = useRef<{ terminal: { dispose: () => void }; cleanup: () => void } | null>(null);
|
||||
// Флаг отмены: connect() асинхронен, а React StrictMode в dev монтирует эффект
|
||||
// дважды — без него второй xterm ложится в контейнер поверх первого и видимой
|
||||
// остаётся «осиротевшая» пустая сессия.
|
||||
const guardRef = useRef<{ cancelled: boolean } | null>(null);
|
||||
|
||||
const sid = Number(serverId);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
const cleanupSession = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (keepaliveRef.current) {
|
||||
clearInterval(keepaliveRef.current);
|
||||
keepaliveRef.current = null;
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.cleanup();
|
||||
termRef.current.terminal.dispose();
|
||||
termRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (guard: { cancelled: boolean }) => {
|
||||
try {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
@@ -36,7 +56,7 @@ export default function NodeShellPage() {
|
||||
import('@xterm/addon-web-links'),
|
||||
]);
|
||||
|
||||
if (!containerRef.current) return;
|
||||
if (guard.cancelled || !containerRef.current) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
@@ -65,9 +85,12 @@ export default function NodeShellPage() {
|
||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/node-shell/${sid}/${node}?${params}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (guard.cancelled) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
setStatus('connected');
|
||||
const { cols, rows } = terminal;
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
@@ -77,6 +100,7 @@ export default function NodeShellPage() {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (guard.cancelled) return;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
terminal.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
@@ -85,10 +109,12 @@ export default function NodeShellPage() {
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
|
||||
};
|
||||
@@ -106,24 +132,43 @@ export default function NodeShellPage() {
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Сессию могли отменить, пока грузились чанки xterm. Убираем за собой сами
|
||||
// и не трогаем refs — они уже могут указывать на актуальную сессию.
|
||||
if (guard.cancelled) {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
terminal.dispose();
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
termRef.current = { terminal, cleanup: () => window.removeEventListener('resize', handleResize) };
|
||||
} catch (err) {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, node, cmd, t]);
|
||||
|
||||
const startSession = useCallback(() => {
|
||||
if (guardRef.current) guardRef.current.cancelled = true;
|
||||
cleanupSession();
|
||||
|
||||
const guard = { cancelled: false };
|
||||
guardRef.current = guard;
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
connect(guard);
|
||||
}, [connect, cleanupSession]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
startSession();
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
if (keepaliveRef.current) clearInterval(keepaliveRef.current);
|
||||
if (termRef.current) {
|
||||
termRef.current.cleanup();
|
||||
termRef.current.terminal.dispose();
|
||||
}
|
||||
if (guardRef.current) guardRef.current.cancelled = true;
|
||||
cleanupSession();
|
||||
};
|
||||
}, [connect]);
|
||||
}, [startSession, cleanupSession]);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
@@ -182,11 +227,7 @@ export default function NodeShellPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
connect();
|
||||
}}
|
||||
onClick={startSession}
|
||||
>
|
||||
{t('console.reconnect', 'Переподключиться')}
|
||||
</Button>
|
||||
|
||||
235
frontend/src/features/instances/AddGuestIPDialog.tsx
Normal file
235
frontend/src/features/instances/AddGuestIPDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useIPAMNetworks, useIPAMPools, useAddGuestAddress } from '@/hooks/use-ipam';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
/** Имена интерфейсов гостя, среди которых можно выбрать целевой */
|
||||
interfaces: string[];
|
||||
}
|
||||
|
||||
export default function AddGuestIPDialog({ open, onClose, serverId, vmid, interfaces }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Диалог монтируется только на время показа (см. NetworkingTab), поэтому
|
||||
// состояние достаточно задать начальными значениями — сбрасывать эффектом
|
||||
// при каждом открытии не нужно.
|
||||
const [networkId, setNetworkId] = useState('');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [mode, setMode] = useState<'auto' | 'manual'>('auto');
|
||||
const [ip, setIp] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] ?? '');
|
||||
const [makePrimary, setMakePrimary] = useState(false);
|
||||
|
||||
const { data: networks } = useIPAMNetworks();
|
||||
const { data: pools } = useIPAMPools(networkId ? Number(networkId) : undefined);
|
||||
const addAddress = useAddGuestAddress(serverId, vmid);
|
||||
|
||||
const activeNetworks = useMemo(
|
||||
() => (networks ?? []).filter((n) => n.is_active !== false),
|
||||
[networks],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
if (!networkId) {
|
||||
toast.error(t('ipam.err_network_required', 'Выберите сеть'));
|
||||
return;
|
||||
}
|
||||
if (mode === 'manual' && !IP_RE.test(ip)) {
|
||||
toast.error(t('ipam.err_bad_ip', 'Некорректный IP-адрес'));
|
||||
return;
|
||||
}
|
||||
|
||||
addAddress.mutate(
|
||||
{
|
||||
network_id: Number(networkId),
|
||||
pool_id: poolId ? Number(poolId) : undefined,
|
||||
ip_address: mode === 'manual' ? ip : undefined,
|
||||
target_interface: iface || undefined,
|
||||
make_primary: makePrimary,
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
const address = res.address?.ip_address ?? '';
|
||||
if (res.applied) {
|
||||
if (res.apply_status === 'runtime_only') {
|
||||
toast.warning(
|
||||
t('ipam.address_runtime_only', {
|
||||
defaultValue: '{{ip}} поднят, но не закреплён: стек сети гостя не распознан',
|
||||
ip: address,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t('ipam.address_added', { defaultValue: 'Адрес {{ip}} назначен', ip: address }),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Адрес всё равно зарезервирован — применить можно позже
|
||||
toast.warning(
|
||||
t('ipam.address_reserved_not_applied', {
|
||||
defaultValue: '{{ip}} зарезервирован, но не применён: {{error}}',
|
||||
ip: address,
|
||||
error: res.error ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{t('ipam.add_address_title', 'Добавить IP-адрес')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'ipam.add_address_description',
|
||||
'Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.network', 'Сеть')}</Label>
|
||||
<Select value={networkId} onValueChange={(v) => { setNetworkId(v ?? ''); setPoolId(''); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ipam.select_network', 'Выберите сеть')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeNetworks.map((n) => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
{n.name}
|
||||
<span className="text-xs text-muted-foreground">{n.network}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.pool', 'Пул')}</Label>
|
||||
<Select value={poolId} onValueChange={(v) => setPoolId(v ?? '')} disabled={!networkId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('ipam.any_pool', 'Любой')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(pools ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={String(p.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
{p.name}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{p.range_start} – {p.range_end}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.address_mode', 'Адрес')}</Label>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as 'auto' | 'manual')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('ipam.next_free', 'Следующий свободный')}</SelectItem>
|
||||
<SelectItem value="manual">{t('ipam.manual_ip', 'Указать вручную')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === 'manual' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="guest-ip">IP</Label>
|
||||
<Input
|
||||
id="guest-ip"
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
placeholder="10.10.10.90"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('ipam.target_interface', 'Интерфейс гостя')}</Label>
|
||||
{interfaces.length > 0 ? (
|
||||
<Select value={iface} onValueChange={(v) => setIface(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="eth0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{interfaces.map((name) => (
|
||||
<SelectItem key={name} value={name}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={iface}
|
||||
onChange={(e) => setIface(e.target.value)}
|
||||
placeholder="eth0"
|
||||
className="font-mono"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={makePrimary} onChange={(e) => setMakePrimary(e.target.checked)} />
|
||||
{t('ipam.make_primary', 'Сделать основным адресом')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||
<Button onClick={submit} disabled={addAddress.isPending || !networkId}>
|
||||
{t('ipam.add_address_submit', 'Выдать адрес')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -32,6 +32,39 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type InstanceKind = 'vm' | 'lxc' | 'iso';
|
||||
|
||||
const DNS_NAME_RE = /^[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
||||
function isValidDnsName(name: string): boolean {
|
||||
return name.length > 0 && name.length <= 63 && DNS_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
function NumericInput({ value, onChange, ...props }: Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'> & {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
}) {
|
||||
const [raw, setRaw] = useState(String(value));
|
||||
const committed = useRef(value);
|
||||
if (value !== committed.current) {
|
||||
committed.current = value;
|
||||
setRaw(String(value));
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
type="number"
|
||||
value={raw}
|
||||
onChange={e => {
|
||||
setRaw(e.target.value);
|
||||
const n = e.target.value === '' ? 0 : Number(e.target.value);
|
||||
if (!Number.isNaN(n)) {
|
||||
committed.current = n;
|
||||
onChange(n);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = ['server', 'type', 'template', 'config', 'confirm'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
@@ -235,9 +268,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
return !!selectedLXCTemplate;
|
||||
}
|
||||
if (step === 'config') {
|
||||
if (kind === 'vm') return config.name.length >= 2;
|
||||
if (kind === 'iso') return isoConfig.name.length >= 2;
|
||||
return lxcConfig.name.length >= 2;
|
||||
if (kind === 'vm') return isValidDnsName(config.name) && !!config.cloud_init_user.trim() && !!config.cloud_init_password.trim();
|
||||
if (kind === 'iso') return isValidDnsName(isoConfig.name);
|
||||
return isValidDnsName(lxcConfig.name);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -689,6 +722,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<div>
|
||||
<Label>{t('common.name')}</Label>
|
||||
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder={t('common.placeholder_vm_name')} />
|
||||
{config.name && !isValidDnsName(config.name) && (
|
||||
<p className="mt-1 text-xs text-destructive">{t('wizard.invalid_name')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.target_node')}</Label>
|
||||
@@ -719,30 +755,27 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>vCPU</Label>
|
||||
<Input
|
||||
type="number"
|
||||
<NumericInput
|
||||
min={selectedTemplate?.min_cores || 1}
|
||||
value={config.cores}
|
||||
onChange={e => setConfig(p => ({ ...p, cores: Number(e.target.value) }))}
|
||||
onChange={v => setConfig(p => ({ ...p, cores: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
<NumericInput
|
||||
min={selectedTemplate?.min_memory || 256}
|
||||
step={256}
|
||||
value={config.memory}
|
||||
onChange={e => setConfig(p => ({ ...p, memory: Number(e.target.value) }))}
|
||||
onChange={v => setConfig(p => ({ ...p, memory: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
<NumericInput
|
||||
min={selectedTemplate?.min_disk || 5}
|
||||
value={config.disk}
|
||||
onChange={e => setConfig(p => ({ ...p, disk: Number(e.target.value) }))}
|
||||
onChange={v => setConfig(p => ({ ...p, disk: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -799,12 +832,22 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm">Cloud-Init</CardTitle></CardHeader>
|
||||
<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={t('common.placeholder_ubuntu')} />
|
||||
<Label>{t('wizard.ci_user')} <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
value={config.cloud_init_user}
|
||||
onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))}
|
||||
placeholder={t('common.placeholder_ubuntu')}
|
||||
className={!config.cloud_init_user.trim() ? 'border-destructive' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.ci_password')}</Label>
|
||||
<Input type="password" value={config.cloud_init_password} onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))} />
|
||||
<Label>{t('wizard.ci_password')} <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={config.cloud_init_password}
|
||||
onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))}
|
||||
className={!config.cloud_init_password.trim() ? 'border-destructive' : ''}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<SSHOwnerSelector
|
||||
@@ -844,6 +887,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<div>
|
||||
<Label>{t('common.name')}</Label>
|
||||
<Input value={isoConfig.name} onChange={e => setIsoConfig(p => ({ ...p, name: e.target.value }))} placeholder={t('common.placeholder_vm_name')} />
|
||||
{isoConfig.name && !isValidDnsName(isoConfig.name) && (
|
||||
<p className="mt-1 text-xs text-destructive">{t('wizard.invalid_name')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.os_type')}</Label>
|
||||
@@ -887,15 +933,15 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>vCPU</Label>
|
||||
<Input type="number" min={1} value={isoConfig.cores} onChange={e => setIsoConfig(p => ({ ...p, cores: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={isoConfig.cores} onChange={v => setIsoConfig(p => ({ ...p, cores: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input type="number" min={256} step={256} value={isoConfig.memory} onChange={e => setIsoConfig(p => ({ ...p, memory: Number(e.target.value) }))} />
|
||||
<NumericInput min={256} step={256} value={isoConfig.memory} onChange={v => setIsoConfig(p => ({ ...p, memory: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input type="number" min={1} value={isoConfig.disk} onChange={e => setIsoConfig(p => ({ ...p, disk: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={isoConfig.disk} onChange={v => setIsoConfig(p => ({ ...p, disk: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.storage')}</Label>
|
||||
@@ -1018,6 +1064,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
onChange={e => setLxcConfig(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder={t('common.placeholder_vm_name')}
|
||||
/>
|
||||
{lxcConfig.name && !isValidDnsName(lxcConfig.name) && (
|
||||
<p className="mt-1 text-xs text-destructive">{t('wizard.invalid_name')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.storage')}</Label>
|
||||
@@ -1056,19 +1105,19 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>vCPU</Label>
|
||||
<Input type="number" min={1} value={lxcConfig.cores} onChange={e => setLxcConfig(p => ({ ...p, cores: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={lxcConfig.cores} onChange={v => setLxcConfig(p => ({ ...p, cores: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input type="number" min={64} step={128} value={lxcConfig.memory} onChange={e => setLxcConfig(p => ({ ...p, memory: Number(e.target.value) }))} />
|
||||
<NumericInput min={64} step={128} value={lxcConfig.memory} onChange={v => setLxcConfig(p => ({ ...p, memory: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.swap_mb')}</Label>
|
||||
<Input type="number" min={0} step={128} value={lxcConfig.swap} onChange={e => setLxcConfig(p => ({ ...p, swap: Number(e.target.value) }))} />
|
||||
<NumericInput min={0} step={128} value={lxcConfig.swap} onChange={v => setLxcConfig(p => ({ ...p, swap: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input type="number" min={1} value={lxcConfig.disk} onChange={e => setLxcConfig(p => ({ ...p, disk: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={lxcConfig.disk} onChange={v => setLxcConfig(p => ({ ...p, disk: v }))} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { Fragment, useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
Settings2,
|
||||
Archive,
|
||||
Cpu,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Globe,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||
import BulkMigrateDialog from './BulkMigrateDialog';
|
||||
@@ -94,15 +98,25 @@ import type { VMInstance } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ==================== Inline Progress Bar ====================
|
||||
function InlineProgress({ value, max, className }: { value: number; max: number; className?: string }) {
|
||||
function InlineProgress({
|
||||
value,
|
||||
max,
|
||||
barClassName = 'w-16',
|
||||
textClassName = 'text-xs',
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
barClassName?: string;
|
||||
textClassName?: string;
|
||||
}) {
|
||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
||||
const color = pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-primary';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-16 overflow-hidden rounded-full bg-muted ${className}`}>
|
||||
<div className={`h-1.5 overflow-hidden rounded-full bg-muted ${barClassName}`}>
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
|
||||
<span className={`${textClassName} tabular-nums text-muted-foreground`}>{pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -268,6 +282,155 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Metric row (label + progress bar + detail) ====================
|
||||
function MetricRow({ label, value, max, detail }: { label: string; value: number | null | undefined; max: number; detail: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-16 shrink-0 text-sm font-medium text-muted-foreground">{label}</span>
|
||||
{value != null && max ? (
|
||||
<InlineProgress value={value} max={max} barClassName="w-32 sm:w-44" textClassName="text-sm" />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Info item (label + value) ====================
|
||||
function InfoItem({ label, value, mono, icon: Icon }: { label: string; value: string; mono?: boolean; icon?: typeof Globe }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className={`flex items-center gap-1 ${mono ? 'font-mono text-sm' : 'text-sm'}`}>
|
||||
{Icon && <Icon className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Expandable instance metrics panel ====================
|
||||
function InstanceMetricsPanel({ vm, nodeLabelName }: { vm: VMInstance; nodeLabelName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isQemu = vm.type === 'qemu';
|
||||
const owner =
|
||||
vm.owner_user?.email || vm.owner_user?.full_name || vm.owner_user?.username || vm.owner || '—';
|
||||
const os = vm.os || vm.os_template || '—';
|
||||
const isRunning = vm.status === 'running';
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/30">
|
||||
<div className="flex flex-wrap items-center gap-4 border-b px-4 py-2 text-sm">
|
||||
<Link
|
||||
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=settings`}
|
||||
className="flex items-center gap-1.5 text-primary hover:underline"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" /> {t('instances.parameters', 'Параметры')}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" /> {t('common.console', 'Console')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-6 p-4 sm:grid-cols-2">
|
||||
<div className="space-y-2.5">
|
||||
<MetricRow
|
||||
label="vCPU"
|
||||
value={vm.cpu != null ? vm.cpu * 100 : null}
|
||||
max={100}
|
||||
detail={vm.cores ? `${vm.cores} vCPU` : '—'}
|
||||
/>
|
||||
<MetricRow
|
||||
label="RAM"
|
||||
value={vm.mem ?? null}
|
||||
max={vm.maxmem || 0}
|
||||
detail={vm.mem != null && vm.maxmem ? `${formatBytes(vm.mem)} / ${formatBytes(vm.maxmem)}` : '—'}
|
||||
/>
|
||||
{!isQemu && (
|
||||
<MetricRow
|
||||
label="Storage"
|
||||
value={vm.disk ?? null}
|
||||
max={vm.maxdisk || 0}
|
||||
detail={vm.disk != null && vm.maxdisk ? `${formatBytes(vm.disk)} / ${formatBytes(vm.maxdisk)}` : '—'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 content-start gap-x-6 gap-y-3">
|
||||
<InfoItem label={t('instances.ip_address', 'IP-адрес')} value={vm.ip_address || '—'} mono icon={Globe} />
|
||||
<InfoItem label={t('common.node', 'Узел')} value={nodeLabelName} />
|
||||
<InfoItem label={t('instances.os_config', 'ОС')} value={os} />
|
||||
<InfoItem label={t('instances.owner', 'Владелец')} value={owner} />
|
||||
<InfoItem label={t('instances.uptime', 'Аптайм')} value={vm.uptime ? formatUptime(vm.uptime) : '—'} />
|
||||
</div>
|
||||
</div>
|
||||
{isRunning && (
|
||||
<div className="grid gap-3 border-t px-4 py-4 sm:grid-cols-2">
|
||||
<IoStatCard
|
||||
icon={Network}
|
||||
label={t('instances.network', 'Сеть')}
|
||||
rows={[
|
||||
{ icon: ArrowDown, iconClass: 'text-primary', label: t('instances.net_in', 'Входящий'), value: vm.netin_rate },
|
||||
{ icon: ArrowUp, iconClass: 'text-violet-500', label: t('instances.net_out', 'Исходящий'), value: vm.netout_rate },
|
||||
]}
|
||||
/>
|
||||
<IoStatCard
|
||||
icon={HardDrive}
|
||||
label={t('instances.disk_io', 'Диск I/O')}
|
||||
rows={[
|
||||
{ badge: 'R', badgeClass: 'text-cyan-500', label: t('instances.disk_read', 'Чтение'), value: vm.diskread_rate },
|
||||
{ badge: 'W', badgeClass: 'text-orange-500', label: t('instances.disk_write', 'Запись'), value: vm.diskwrite_rate },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== I/O stat card (network / disk throughput) ====================
|
||||
type IoStatRow = {
|
||||
label: string;
|
||||
value?: number;
|
||||
icon?: typeof ArrowDown;
|
||||
iconClass?: string;
|
||||
badge?: string;
|
||||
badgeClass?: string;
|
||||
};
|
||||
|
||||
function IoStatCard({ icon: Icon, label, rows }: { icon: typeof Network; label: string; rows: IoStatRow[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="mb-2.5 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<Icon className="h-4 w-4" /> {label}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
{row.icon ? (
|
||||
<row.icon className={`h-4 w-4 shrink-0 ${row.iconClass}`} />
|
||||
) : (
|
||||
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-sm bg-muted text-[10px] font-bold ${row.badgeClass}`}>
|
||||
{row.badge}
|
||||
</span>
|
||||
)}
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold tabular-nums">
|
||||
{row.value != null ? `${formatBytes(row.value)}/s` : '—'}
|
||||
</div>
|
||||
<div className="text-2xs text-muted-foreground">{row.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Instances Page ====================
|
||||
export default function InstancesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -376,6 +539,15 @@ export default function InstancesPage() {
|
||||
}
|
||||
});
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const toggleExpand = useCallback((rowId: string) => {
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowId)) next.delete(rowId);
|
||||
else next.add(rowId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset every filter that can reference a server/node identity when the
|
||||
// active workspace changes — those values belong to whatever workspace was
|
||||
@@ -668,7 +840,7 @@ export default function InstancesPage() {
|
||||
// component types, so a new columns array remounts EVERY cell — which closes
|
||||
// an open row dropdown menu the moment vms/servers/tasks data refreshes.
|
||||
// Cells still see fresh values: any store/query update re-renders the table.
|
||||
const cellCtx = { deployTasks, overlayByVm, bulkOverlayByVm, serverNameById, nodeLabel };
|
||||
const cellCtx = { deployTasks, overlayByVm, bulkOverlayByVm, serverNameById, nodeLabel, expandedRows, toggleExpand };
|
||||
const cellCtxRef = useRef(cellCtx);
|
||||
cellCtxRef.current = cellCtx;
|
||||
|
||||
@@ -727,11 +899,20 @@ export default function InstancesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const rowId = vmRowId(vm);
|
||||
const isExpanded = cellCtxRef.current.expandedRows.has(rowId);
|
||||
return (
|
||||
<Link
|
||||
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
|
||||
className="group flex items-center gap-2"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cellCtxRef.current.toggleExpand(rowId)}
|
||||
className="group flex items-center gap-2 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<StatusDot status={vm.status} pulse />
|
||||
<div>
|
||||
<span className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
@@ -739,7 +920,7 @@ export default function InstancesPage() {
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">#{vm.vmid}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -850,16 +1031,33 @@ export default function InstancesPage() {
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
accessorKey: 'ip_address',
|
||||
// Поиск идёт по всем адресам гостя, а показывается основной
|
||||
accessorFn: (vm) => [vm.ip_address, ...(vm.ips ?? []).map((a) => a.ip)]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
filterFn: 'includesString',
|
||||
meta: { filter: 'text' },
|
||||
header: 'IP',
|
||||
cell: ({ getValue }) => {
|
||||
const ip = getValue<string>();
|
||||
return ip ? (
|
||||
<span className="font-mono text-xs">{ip}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
const primary = vm.ip_address;
|
||||
const extra = (vm.ips ?? []).filter((a) => a.ip !== primary);
|
||||
if (!primary) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs">{primary}</span>
|
||||
{extra.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-2xs"
|
||||
title={extra.map((a) => a.ip).join(', ')}
|
||||
>
|
||||
{t('ipam.extra_addresses', { defaultValue: 'ещё {{count}}', count: extra.length })}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
size: 130,
|
||||
@@ -905,124 +1103,6 @@ export default function InstancesPage() {
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
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} />;
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
header: t('dashboard.memory', 'Memory'),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
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>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'disk',
|
||||
header: 'Disk',
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
// For QEMU, cluster/resources 'disk' = disk I/O bytes (not usage) → not meaningful as %
|
||||
if (vm.type !== 'lxc' || (!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>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'net_io',
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowDown className="h-3 w-3 text-primary" />
|
||||
<ArrowUp className="h-3 w-3 text-violet-500" />
|
||||
Net
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId || vm.status !== 'running') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const inRate = vm.netin_rate;
|
||||
const outRate = vm.netout_rate;
|
||||
if (inRate == null && outRate == null) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const fmt = (v?: number) => v != null ? `${formatBytes(v)}/s` : '—';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowDown className="h-3 w-3 shrink-0 text-primary" />
|
||||
<span className="text-xs tabular-nums">{fmt(inRate)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUp className="h-3 w-3 shrink-0 text-violet-500" />
|
||||
<span className="text-xs tabular-nums">{fmt(outRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
id: 'disk_io',
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
Disk I/O
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId || vm.status !== 'running') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const readRate = vm.diskread_rate;
|
||||
const writeRate = vm.diskwrite_rate;
|
||||
if (readRate == null && writeRate == null) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const fmt = (v?: number) => v != null ? `${formatBytes(v)}/s` : '—';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-2xs text-cyan-500 font-medium w-3">R</span>
|
||||
<span className="text-xs tabular-nums">{fmt(readRate)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-2xs text-orange-500 font-medium w-3">W</span>
|
||||
<span className="text-xs tabular-nums">{fmt(writeRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
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>;
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
@@ -1311,18 +1391,33 @@ export default function InstancesPage() {
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const isGhost = !!row.original._deployTaskId;
|
||||
const isExpanded = !isGhost && expandedRows.has(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={isGhost ? 'opacity-70 bg-primary/5 border-l-2 border-l-primary' : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={isGhost ? 'opacity-70 bg-primary/5 border-l-2 border-l-primary' : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
|
||||
<InstanceMetricsPanel
|
||||
vm={row.original}
|
||||
nodeLabelName={
|
||||
(row.original.server_id != null ? serverNameById.get(row.original.server_id) : undefined) ??
|
||||
row.original.node
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
|
||||
@@ -326,21 +326,23 @@ function DiskManagementCard({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const { data: storages = [] } = useLXCStorages(serverId, node);
|
||||
const moveDisk = useMoveDisk(serverId, vmid, node);
|
||||
// Только хранилища, поддерживающие нужный тип контента (rootdir для LXC, images для VM) —
|
||||
// иначе Proxmox отклонит задачу переноса уже после запуска.
|
||||
const { data: storages = [] } = useLXCStorages(serverId, node, type === 'lxc' ? 'rootdir' : 'images');
|
||||
const moveDisk = useMoveDisk(serverId, vmid, type, node);
|
||||
const addDisk = useAddDisk(serverId, vmid, node);
|
||||
const detachDisk = useDetachDisk(serverId, vmid, node);
|
||||
|
||||
// Реальные дисковые устройства (без cd-rom и cloudinit)
|
||||
const disks = useMemo(() => {
|
||||
if (!config) return [] as string[];
|
||||
const re = /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
|
||||
const re = type === 'lxc' ? /^(rootfs|mp\d+)$/ : /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
|
||||
return Object.entries(config)
|
||||
.filter(([k, v]) => re.test(k) && typeof v === 'string'
|
||||
&& !v.includes('media=cdrom') && !v.includes('cloudinit')
|
||||
&& !v.startsWith('/dev/')) // проброшенные физдиски — управляются в блоке «Проброс устройств»
|
||||
.map(([k]) => k);
|
||||
}, [config]);
|
||||
}, [config, type]);
|
||||
|
||||
const [moveDiskDev, setMoveDiskDev] = useState('');
|
||||
const [moveStorage, setMoveStorage] = useState('');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe, Wifi, Plus, Trash2 } from 'lucide-react';
|
||||
import { Globe, Wifi, Plus, Trash2, Star, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -13,9 +13,17 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useVMInterfaces, useVMConfig, useVMStatus, useUpdateConfig } from '@/hooks/use-instances';
|
||||
import {
|
||||
useGuestAddresses,
|
||||
useApplyGuestAddress,
|
||||
useSetPrimaryGuestAddress,
|
||||
useRemoveGuestAddress,
|
||||
} from '@/hooks/use-ipam';
|
||||
import type { GuestAddress } from '@/types';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
|
||||
import AddGuestIPDialog from '../AddGuestIPDialog';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
@@ -41,6 +49,23 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
// Имена интерфейсов гостя для выбора цели alias-адреса: сперва то, что
|
||||
// реально видно в госте, иначе имена из конфига (LXC пишет name=ethN).
|
||||
const guestInterfaces = (() => {
|
||||
const live = (ifaces?.interfaces ?? [])
|
||||
.map((i) => i.name)
|
||||
.filter((name): name is string => !!name && name !== 'lo');
|
||||
if (live.length > 0) return live;
|
||||
return netDevices
|
||||
.map(([, value]) =>
|
||||
String(value)
|
||||
.split(',')
|
||||
.find((part) => part.startsWith('name='))
|
||||
?.slice(5),
|
||||
)
|
||||
.filter((name): name is string => !!name);
|
||||
})();
|
||||
|
||||
const removeNic = () => {
|
||||
if (!deleteTarget) return;
|
||||
updateConfig.mutate(
|
||||
@@ -73,6 +98,9 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* IPAM addresses of this guest */}
|
||||
<GuestAddressesCard serverId={serverId} vmid={vmid} interfaces={guestInterfaces} />
|
||||
|
||||
{/* Configured network devices */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@@ -199,3 +227,213 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestAddressesCard({ serverId, vmid, interfaces }: {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
interfaces: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGuestAddresses(serverId, vmid);
|
||||
const applyAddress = useApplyGuestAddress(serverId, vmid);
|
||||
const setPrimary = useSetPrimaryGuestAddress(serverId, vmid);
|
||||
const removeAddress = useRemoveGuestAddress(serverId, vmid);
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [releaseTarget, setReleaseTarget] = useState<GuestAddress | null>(null);
|
||||
|
||||
const addresses = data?.addresses ?? [];
|
||||
|
||||
// Статус применения alias-адреса: applied — закреплён в конфиге гостя,
|
||||
// runtime_only — поднят, но не переживёт перезагрузку, pending/failed —
|
||||
// адрес зарезервирован в IPAM, но на гостя не попал.
|
||||
const statusBadge = (address: GuestAddress) => {
|
||||
if (address.assignment_kind !== 'alias') {
|
||||
return <Badge variant="secondary" className="text-xs">{t('ipam.from_nic_config', 'из конфига NIC')}</Badge>;
|
||||
}
|
||||
switch (address.apply_status) {
|
||||
case 'applied':
|
||||
return <Badge variant="secondary" className="text-xs">{t('ipam.applied', 'применён')}</Badge>;
|
||||
case 'runtime_only':
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
title={t('ipam.runtime_only_hint', 'Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет')}
|
||||
>
|
||||
{t('ipam.runtime_only', 'до перезагрузки')}
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs" title={address.apply_error ?? undefined}>
|
||||
{t('ipam.not_applied', 'не применён')}
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs" title={address.apply_error ?? undefined}>
|
||||
{t('common.error', 'Ошибка')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline" className="text-xs">{t('ipam.discovered', 'обнаружен')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
if (!releaseTarget) return;
|
||||
removeAddress.mutate(releaseTarget.id, {
|
||||
onSuccess: (res) => {
|
||||
if (res.removed_from_guest) {
|
||||
toast.success(t('ipam.address_released', { defaultValue: 'Адрес {{ip}} освобождён', ip: res.released }));
|
||||
} else {
|
||||
toast.warning(
|
||||
t('ipam.address_released_not_removed', {
|
||||
defaultValue: '{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}',
|
||||
ip: res.released,
|
||||
error: res.error ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
setReleaseTarget(null);
|
||||
},
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{t('ipam.addresses', 'IP-адреса')}
|
||||
{addresses.length > 0 && <Badge variant="secondary">{addresses.length}</Badge>}
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('ipam.add_address', 'Добавить IP')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка...')}</p>
|
||||
) : addresses.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('ipam.no_addresses', 'Адресов в IPAM нет')}
|
||||
</p>
|
||||
) : (
|
||||
addresses.map((address) => (
|
||||
<div key={address.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm">
|
||||
{address.ip_address}
|
||||
{address.prefix ? `/${address.prefix}` : ''}
|
||||
</span>
|
||||
{address.is_primary && (
|
||||
<Badge className="text-xs">{t('ipam.primary', 'основной')}</Badge>
|
||||
)}
|
||||
{statusBadge(address)}
|
||||
{address.target_interface && (
|
||||
<Badge variant="outline" className="text-xs">{address.target_interface}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{address.assignment_kind === 'alias' && address.apply_status !== 'applied' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={applyAddress.isPending}
|
||||
title={t('ipam.apply', 'Применить')}
|
||||
onClick={() =>
|
||||
applyAddress.mutate(address.id, {
|
||||
onSuccess: (res) =>
|
||||
res.applied
|
||||
? toast.success(t('ipam.applied', 'применён'))
|
||||
: toast.error(res.error ?? t('common.error', 'Ошибка')),
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{!address.is_primary && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={setPrimary.isPending}
|
||||
title={t('ipam.make_primary', 'Сделать основным адресом')}
|
||||
onClick={() =>
|
||||
setPrimary.mutate(address.id, {
|
||||
onError: (e) => toast.error((e as Error).message),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
disabled={address.assignment_kind !== 'alias'}
|
||||
title={
|
||||
address.assignment_kind === 'alias'
|
||||
? t('ipam.release', 'Освободить')
|
||||
: t('ipam.primary_managed_by_nic', 'Основной адрес задаётся конфигом интерфейса')
|
||||
}
|
||||
onClick={() => setReleaseTarget(address)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{address.apply_error && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{address.apply_error}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{showAdd && (
|
||||
<AddGuestIPDialog
|
||||
open
|
||||
onClose={() => setShowAdd(false)}
|
||||
serverId={serverId}
|
||||
vmid={vmid}
|
||||
interfaces={interfaces}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!releaseTarget} onOpenChange={(o) => !o && setReleaseTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('ipam.release_title', 'Освободить адрес?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ipam.release_description', {
|
||||
defaultValue:
|
||||
'Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.',
|
||||
ip: releaseTarget?.ip_address ?? '',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReleaseTarget(null)}>
|
||||
{t('common.cancel', 'Отмена')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={release} disabled={removeAddress.isPending}>
|
||||
{t('ipam.release', 'Освободить')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
<ArrowDown className="h-4 w-4 text-muted-foreground" />
|
||||
{t('common.io_rates')}
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<IoRateCard
|
||||
label={t('common.disk_read_rate')}
|
||||
value={status?.diskread_rate}
|
||||
@@ -230,18 +230,6 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
icon={HardDrive}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
<IoRateCard
|
||||
label={t('common.net_in_rate')}
|
||||
value={status?.netin_rate}
|
||||
icon={ArrowDown}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
<IoRateCard
|
||||
label={t('common.net_out_rate')}
|
||||
value={status?.netout_rate}
|
||||
icon={ArrowUp}
|
||||
color="text-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk } from '@/hooks/use-instances';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk, useMoveDisk, useVMStatus, usePowerAction } from '@/hooks/use-instances';
|
||||
import { useLXCStorages } from '@/hooks/use-lxc-templates';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
@@ -264,3 +275,188 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Move disk to another storage ====================
|
||||
|
||||
type MoveStage = 'confirm' | 'stopping' | 'moving' | 'starting' | 'error';
|
||||
|
||||
export function DiskMoveCard({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
// Только хранилища, поддерживающие нужный тип контента (rootdir для LXC, images для VM) —
|
||||
// иначе Proxmox отклонит задачу переноса уже после запуска.
|
||||
const { data: storages = [] } = useLXCStorages(serverId, node, type === 'lxc' ? 'rootdir' : 'images');
|
||||
const moveDisk = useMoveDisk(serverId, vmid, type, node);
|
||||
const power = usePowerAction(serverId, vmid, type, node);
|
||||
// move_volume для LXC (в отличие от QEMU move_disk) не работает на запущенном контейнере —
|
||||
// Proxmox отклоняет задачу с "cannot move volumes of a running container".
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||
const isRunning = status?.status === 'running';
|
||||
const blockedByRunning = type === 'lxc' && isRunning;
|
||||
|
||||
const diskDevices = useMemo(() => {
|
||||
if (!config) return [] as string[];
|
||||
const re = type === 'lxc' ? /^(rootfs|mp\d+)$/ : /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
|
||||
return Object.entries(config)
|
||||
.filter(([k, v]) => re.test(k) && typeof v === 'string' && !v.includes('media=cdrom'))
|
||||
.map(([k]) => k);
|
||||
}, [config, type]);
|
||||
|
||||
const [moveDiskDev, setMoveDiskDev] = useState('');
|
||||
const [moveStorage, setMoveStorage] = useState('');
|
||||
const [moveDelete, setMoveDelete] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [stage, setStage] = useState<MoveStage>('confirm');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (diskDevices.length && !diskDevices.includes(moveDiskDev)) setMoveDiskDev(diskDevices[0]);
|
||||
}, [diskDevices, moveDiskDev]);
|
||||
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
const fetchStatus = () =>
|
||||
apiClient
|
||||
.get<{ status: string }>(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`)
|
||||
.then((r) => r.status);
|
||||
|
||||
const waitForStatus = async (target: string, timeoutMs = 60000, intervalMs = 1500) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await fetchStatus()) === target) return true;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const runMove = () =>
|
||||
moveDisk.mutateAsync({ disk: moveDiskDev, target_storage: moveStorage, delete: moveDelete });
|
||||
|
||||
const openConfirm = () => {
|
||||
if (!moveDiskDev || !moveStorage) return;
|
||||
setErrorMsg('');
|
||||
setStage('confirm');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// Прямой перенос — контейнер/VM уже в подходящем состоянии.
|
||||
const confirmDirectMove = async () => {
|
||||
setStage('moving');
|
||||
try {
|
||||
await runMove();
|
||||
setDialogOpen(false);
|
||||
toast.success(t('instances.disk_moved'));
|
||||
setMoveStorage('');
|
||||
} catch (e) {
|
||||
setStage('error');
|
||||
setErrorMsg((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// Согласованный сценарий для запущенного LXC: стоп -> перенос -> запуск обратно.
|
||||
const confirmStopAndMove = async () => {
|
||||
setStage('stopping');
|
||||
try {
|
||||
await power.mutateAsync({ action: 'stop' });
|
||||
const stopped = await waitForStatus('stopped');
|
||||
if (!stopped) throw new Error(t('instances.disk_move_stop_timeout'));
|
||||
|
||||
setStage('moving');
|
||||
await runMove();
|
||||
|
||||
setStage('starting');
|
||||
await power.mutateAsync({ action: 'start' });
|
||||
|
||||
setDialogOpen(false);
|
||||
toast.success(t('instances.disk_moved'));
|
||||
setMoveStorage('');
|
||||
} catch (e) {
|
||||
// Даже если перенос упал, возвращаем контейнер в исходное состояние.
|
||||
try { await power.mutateAsync({ action: 'start' }); } catch { /* уже могло стартовать */ }
|
||||
setStage('error');
|
||||
setErrorMsg((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = stage === 'stopping' || stage === 'moving' || stage === 'starting';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t('instances.disk_move')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.disk_device')}</Label>
|
||||
<Select value={moveDiskDev} onValueChange={(v) => v && setMoveDiskDev(v)}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.disk_device')} /></SelectTrigger>
|
||||
<SelectContent>{diskDevices.map((d) => <SelectItem key={d} value={d}>{d}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.migrate_target_storage')}</Label>
|
||||
<Select value={moveStorage} onValueChange={(v) => v && setMoveStorage(v)}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.migrate_target_storage')} /></SelectTrigger>
|
||||
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<Checkbox checked={moveDelete} onChange={(e) => setMoveDelete(e.target.checked)} />
|
||||
<span>{t('instances.disk_move_delete')}</span>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={openConfirm} disabled={!moveDiskDev || !moveStorage}>
|
||||
{t('instances.disk_move')}
|
||||
</Button>
|
||||
{blockedByRunning && (
|
||||
<p className="text-xs text-muted-foreground">{t('instances.disk_move_stop_hint')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!open && !busy) setDialogOpen(false); }}>
|
||||
<DialogContent showCloseButton={!busy}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('instances.disk_move_confirm_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{blockedByRunning
|
||||
? t('instances.disk_move_confirm_running', { dev: moveDiskDev, storage: moveStorage })
|
||||
: t('instances.disk_move_confirm_plain', { dev: moveDiskDev, storage: moveStorage })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{stage === 'stopping' && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />{t('instances.disk_move_stage_stopping')}
|
||||
</p>
|
||||
)}
|
||||
{stage === 'moving' && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />{t('instances.disk_move_stage_moving')}
|
||||
</p>
|
||||
)}
|
||||
{stage === 'starting' && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />{t('instances.disk_move_stage_starting')}
|
||||
</p>
|
||||
)}
|
||||
{stage === 'error' && (
|
||||
<p className="text-sm text-destructive">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={blockedByRunning ? confirmStopAndMove : confirmDirectMove}
|
||||
>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{blockedByRunning ? t('instances.disk_move_stop_and_move') : t('instances.disk_move')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useVMConfig, useUpdateConfig, useVMOwner, useSetVMOwner, useExecuteScri
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { ComputeResourcesCard, DiskResizeCard } from './ResourceCards';
|
||||
import { ComputeResourcesCard, DiskResizeCard, DiskMoveCard } from './ResourceCards';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
@@ -736,6 +736,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
|
||||
{type === 'lxc' && <DiskResizeCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
{type === 'lxc' && <DiskMoveCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
<SavedConfigCard serverId={serverId} vmid={vmid} />
|
||||
|
||||
{/* Raw Config */}
|
||||
|
||||
@@ -44,7 +44,9 @@ export default function IPAMAllocationsPage() {
|
||||
a.ip_address?.toLowerCase().includes(s) ||
|
||||
a.resource_name?.toLowerCase().includes(s) ||
|
||||
a.mac_address?.toLowerCase().includes(s) ||
|
||||
a.description?.toLowerCase().includes(s)
|
||||
a.description?.toLowerCase().includes(s) ||
|
||||
// поиск по VMID, чтобы собрать все адреса одного гостя
|
||||
String(a.proxmox_vmid ?? '').includes(s)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -108,12 +110,25 @@ export default function IPAMAllocationsPage() {
|
||||
<TableBody>
|
||||
{filtered.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono">{a.ip_address}</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{a.ip_address}
|
||||
{/* У гостя может быть несколько адресов — помечаем основной */}
|
||||
{a.is_primary && (
|
||||
<Badge variant="secondary" className="text-2xs">{t('ipam.primary')}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{a.network_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{a.resource_name || '—'}
|
||||
{a.proxmox_vmid != null && (
|
||||
<span className="ml-1 text-muted-foreground">#{a.proxmox_vmid}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -235,7 +235,20 @@ function UnlinkedCard() {
|
||||
|
||||
const handleLink = () => {
|
||||
link.mutate(undefined, {
|
||||
onSuccess: (r) => toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`),
|
||||
onSuccess: (r) => {
|
||||
toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`);
|
||||
// Пропуски (дубль IP, недоступный сервер, адрес вне сетей IPAM) иначе
|
||||
// видны только в логах бэкенда — показываем причины первых записей.
|
||||
const skipped = r.not_found ?? [];
|
||||
if (skipped.length > 0) {
|
||||
toast.warning(t('ipam.link_skipped', { count: skipped.length }), {
|
||||
description: skipped
|
||||
.slice(0, 3)
|
||||
.map((s) => `${s.resource_name}: ${s.reason}`)
|
||||
.join('\n'),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -119,8 +119,16 @@ const OS_MAP: Array<{ keys: string[]; info: OsInfo }> = [
|
||||
bg: '#294172',
|
||||
fg: '#fff',
|
||||
svg: (
|
||||
<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm4 11h-3v3a1 1 0 0 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 2 0v3h3a1 1 0 0 1 0 2z" />
|
||||
<svg viewBox="0 0 267 267" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 266.62575,133.50613 C 266.62575,59.98128 207.02222,0.37583 133.49792,0.37583 C 60.00668,0.37583 0.42639,59.93123 0.37425,133.41225 L 0.37425,236.4333 C 0.4138,253.11763 13.94545,266.62417 30.64027,266.62417 L 133.55192,266.62417 C 207.05167,266.59532 266.62575,207.01142 266.62575,133.50613" fill="#294172"/>
|
||||
<path d="M 77.126289,142.09756 C 77.126289,142.09756 124.97104,142.09756 124.97104,142.09756 C 124.97104,142.09756 124.97104,189.94234 124.97104,189.94234 C 124.97104,216.35263 103.53659,237.78707 77.126289,237.78707 C 50.715979,237.78707 29.28153,216.35263 29.28153,189.94234 C 29.28153,163.53203 50.715979,142.09756 77.126289,142.09756 z" fill="none" stroke="#3c6eb4" strokeWidth="29.21"/>
|
||||
<use transform="matrix(-1,0,0,-1,249.71151,284.2882)" xlinkHref="#fedora-in"/>
|
||||
<path d="M 139.6074,127.52923 L 139.6074,189.87541 C 139.6074,224.37943 111.63203,252.35541 77.12679,252.35541 C 71.89185,252.35541 68.1703,251.7644 63.32444,250.49771 C 56.25849,248.64859 50.48398,242.85518 50.48158,236.1166 C 50.48158,227.97147 56.39394,222.0467 65.23187,222.0467 C 69.43824,222.0467 70.96454,222.85435 77.12679,222.85435 C 95.3184,222.85435 110.07443,208.11916 110.10634,189.92756 L 110.10634,161.27099 C 110.10634,158.70324 108.01971,156.62274 105.44767,156.62274 L 83.78246,156.61846 C 75.71034,156.61846 69.18845,150.18003 69.18845,142.0858 C 69.18414,133.94124 75.77725,127.52923 83.93653,127.52923" fill="#ffffff"/>
|
||||
<use transform="matrix(-1,0,0,-1,249.71152,284.28821)" xlinkHref="#fedora-free"/>
|
||||
<defs>
|
||||
<path d="M 77.126289,142.09756 C 77.126289,142.09756 124.97104,142.09756 124.97104,142.09756 C 124.97104,142.09756 124.97104,189.94234 124.97104,189.94234 C 124.97104,216.35263 103.53659,237.78707 77.126289,237.78707 C 50.715979,237.78707 29.28153,216.35263 29.28153,189.94234 C 29.28153,163.53203 50.715979,142.09756 77.126289,142.09756 z" id="fedora-in" fill="none" stroke="#3c6eb4" strokeWidth="29.21"/>
|
||||
<path d="M 139.6074,127.52923 L 139.6074,189.87541 C 139.6074,224.37943 111.63203,252.35541 77.12679,252.35541 C 71.89185,252.35541 68.1703,251.7644 63.32444,250.49771 C 56.25849,248.64859 50.48398,242.85518 50.48158,236.1166 C 50.48158,227.97147 56.39394,222.0467 65.23187,222.0467 C 69.43824,222.0467 70.96454,222.85435 77.12679,222.85435 C 95.3184,222.85435 110.07443,208.11916 110.10634,189.92756 L 110.10634,161.27099 C 110.10634,158.70324 108.01971,156.62274 105.44767,156.62274 L 83.78246,156.61846 C 75.71034,156.61846 69.18845,150.18003 69.18845,142.0858 C 69.18414,133.94124 75.77725,127.52923 83.93653,127.52923" id="fedora-free" fill="#ffffff"/>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -159,7 +159,8 @@ export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: ()
|
||||
<SelectTrigger className="mt-1"><SelectValue placeholder={t('templates.select_template')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{available.map(a => (
|
||||
<SelectItem key={a.template} value={a.template}>{a.template}{a.headline ? ` — ${a.headline}` : ''}</SelectItem>
|
||||
// Show the friendly name only; the raw file name stays in the tooltip.
|
||||
<SelectItem key={a.template} value={a.template} title={a.template}>{a.headline || a.template}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -60,9 +60,9 @@ export default function TemplatesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={() => setDownloadOpen(true)}>
|
||||
<PackagePlus className="mr-2 h-4 w-4" />{t('templates.download_ct')}
|
||||
</Button>
|
||||
@@ -79,21 +79,21 @@ export default function TemplatesPage() {
|
||||
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Select value={groupFilter} onValueChange={v => { if (v !== null) setGroupFilter(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
|
||||
<SelectTrigger className="w-full sm:w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_groups')}</SelectItem>
|
||||
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={serverFilter} onValueChange={v => { if (v !== null) setServerFilter(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
|
||||
<SelectTrigger className="w-full sm:w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_servers')}</SelectItem>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={v => { if (v !== null) setTypeFilter(v); }}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue placeholder={t('templates.all_types')} /></SelectTrigger>
|
||||
<SelectTrigger className="w-full sm:w-[160px]"><SelectValue placeholder={t('templates.all_types')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_types')}</SelectItem>
|
||||
<SelectItem value="qemu">{t('templates.type_kvm')}</SelectItem>
|
||||
@@ -115,8 +115,8 @@ export default function TemplatesPage() {
|
||||
{filtered.map(tpl => (
|
||||
<Card key={tpl.id} className="group">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="shrink-0">
|
||||
{tpl.vm_type === 'qemu' ? (
|
||||
<OsLogo name={tpl.name} className="h-7 w-7" />
|
||||
) : tpl.group_icon ? (
|
||||
@@ -124,12 +124,12 @@ export default function TemplatesPage() {
|
||||
) : (
|
||||
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{tpl.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate" title={tpl.name}>{tpl.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
119
frontend/src/features/topology/TopologyCanvas.tsx
Normal file
119
frontend/src/features/topology/TopologyCanvas.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TopologyLegend } from './TopologyLegend';
|
||||
import { ClusterNode } from './nodes/ClusterNode';
|
||||
import { GuestGroupNode } from './nodes/GuestGroupNode';
|
||||
import { GuestNode } from './nodes/GuestNode';
|
||||
import { PanelNode } from './nodes/PanelNode';
|
||||
import { PveNode } from './nodes/PveNode';
|
||||
import './topology.css';
|
||||
|
||||
// Declared at module scope: recreating this object on every render makes React
|
||||
// Flow remount every node and the canvas crawls.
|
||||
const nodeTypes = {
|
||||
panel: PanelNode,
|
||||
cluster: ClusterNode,
|
||||
pve: PveNode,
|
||||
guest: GuestNode,
|
||||
guestGroup: GuestGroupNode,
|
||||
} as const;
|
||||
|
||||
const defaultEdgeOptions = { type: 'smoothstep' as const, animated: true };
|
||||
|
||||
// Above this many nodes React Flow only mounts what is on screen, and the
|
||||
// minimap (which renders every node again) is dropped.
|
||||
const VIRTUALIZE_ABOVE = 150;
|
||||
const MINIMAP_LIMIT = 400;
|
||||
|
||||
interface TopologyCanvasProps {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
onExpandNode: (nodeId: string) => void;
|
||||
/** Rendered inside the canvas only in fullscreen, where the page chrome is gone. */
|
||||
toolbar?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TopologyCanvas({ nodes, edges, onExpandNode, toolbar }: TopologyCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
// The panel drives its theme through this store, not next-themes (which
|
||||
// ships only as a sonner dependency and has no provider mounted here).
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// The group node's expand button lives in node data; buildGraph is pure and
|
||||
// cannot know about the callback, so it is attached here.
|
||||
const decorated = useMemo(
|
||||
() =>
|
||||
nodes.map((node) =>
|
||||
node.type === 'guestGroup'
|
||||
? { ...node, data: { ...node.data, onExpand: onExpandNode } }
|
||||
: node,
|
||||
),
|
||||
[nodes, onExpandNode],
|
||||
);
|
||||
|
||||
const handleInit = useCallback(() => {
|
||||
fitView({ padding: 0.15, duration: 0 });
|
||||
}, [fitView]);
|
||||
|
||||
// Filtering, grouping or expanding changes how much space the tree needs, so
|
||||
// refit whenever the node count moves — otherwise the graph drifts off-screen
|
||||
// or stays zoomed out after a filter shrinks it.
|
||||
const nodeCount = decorated.length;
|
||||
useEffect(() => {
|
||||
if (nodeCount === 0) return;
|
||||
const timer = window.setTimeout(() => fitView({ padding: 0.15, duration: 300 }), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [nodeCount, fitView]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={decorated}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
colorMode={theme === 'light' ? 'light' : 'dark'}
|
||||
onInit={handleInit}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.05}
|
||||
maxZoom={2}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
zoomOnDoubleClick={false}
|
||||
proOptions={{ hideAttribution: false }}
|
||||
onlyRenderVisibleElements={decorated.length > VIRTUALIZE_ABOVE}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={18} size={1} />
|
||||
{/* Top-right keeps the zoom buttons clear of the toolbar and the legend. */}
|
||||
<Controls showInteractive={false} position="top-right" orientation="horizontal" />
|
||||
{decorated.length <= MINIMAP_LIMIT ? <MiniMap pannable zoomable className="!h-24 !w-40" /> : null}
|
||||
{toolbar ? (
|
||||
<Panel position="top-left" className="!m-3 max-w-[calc(100%-1.5rem)]">
|
||||
{toolbar}
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel position="bottom-left" className="!m-3">
|
||||
<TopologyLegend />
|
||||
</Panel>
|
||||
{decorated.length === 0 ? (
|
||||
<Panel position="top-center" className="!mt-24">
|
||||
<p className="text-sm text-muted-foreground">{t('topology.empty', 'No servers available')}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
42
frontend/src/features/topology/TopologyLegend.tsx
Normal file
42
frontend/src/features/topology/TopologyLegend.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Boxes, Monitor, Network, Server, WifiOff } from 'lucide-react';
|
||||
|
||||
const ITEMS = [
|
||||
{ key: 'panel', Icon: Boxes, className: 'text-primary' },
|
||||
{ key: 'cluster', Icon: Network, className: 'text-muted-foreground' },
|
||||
{ key: 'node', Icon: Server, className: 'text-muted-foreground' },
|
||||
{ key: 'vm', Icon: Monitor, className: 'text-emerald-500' },
|
||||
{ key: 'lxc', Icon: Box, className: 'text-emerald-500' },
|
||||
{ key: 'stale', Icon: WifiOff, className: 'text-amber-500' },
|
||||
] as const;
|
||||
|
||||
export function TopologyLegend() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-export-ignore="true"
|
||||
className="rounded-lg border border-border bg-card/95 px-3 py-2 shadow-sm backdrop-blur"
|
||||
>
|
||||
<div className="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('topology.legend.title', 'Legend')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ITEMS.map(({ key, Icon, className }) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Icon className={`h-3 w-3 shrink-0 ${className}`} />
|
||||
<span>{t(`topology.legend.${key}`, key)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span>{t('topology.legend.running', 'Running')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-muted-foreground/50" />
|
||||
<span>{t('topology.legend.stopped', 'Stopped')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
frontend/src/features/topology/TopologyPage.tsx
Normal file
196
frontend/src/features/topology/TopologyPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useNetworkTopology } from '@/hooks/use-topology';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TopologyCanvas } from './TopologyCanvas';
|
||||
import { TopologyToolbar } from './TopologyToolbar';
|
||||
import { buildGraph, listConnections } from './lib/build-graph';
|
||||
import { exportGraph } from './lib/export';
|
||||
import type { TopologyFilters } from './lib/types';
|
||||
|
||||
const THRESHOLD_STORAGE_KEY = 'pve-topology-threshold';
|
||||
const DEFAULT_THRESHOLD = 20;
|
||||
// Past this many guests an ungrouped graph is unusable, so grouping is forced.
|
||||
const FORCE_GROUPING_ABOVE = 1500;
|
||||
const EXPORT_NODE_LIMIT = 1200;
|
||||
|
||||
function readStoredThreshold(): number {
|
||||
// Guard the missing key explicitly: Number(null) is 0, which would silently
|
||||
// turn "never stored" into "grouping off" on a first visit.
|
||||
const raw = localStorage.getItem(THRESHOLD_STORAGE_KEY);
|
||||
if (raw === null) return DEFAULT_THRESHOLD;
|
||||
const stored = Number(raw);
|
||||
return Number.isFinite(stored) && stored >= 0 && stored <= 100 ? stored : DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
function TopologyView() {
|
||||
const { t } = useTranslation();
|
||||
const { fitView, getNodesBounds } = useReactFlow();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<TopologyFilters>(() => ({
|
||||
serverId: 'all',
|
||||
vmStatus: 'all',
|
||||
groupThreshold: readStoredThreshold(),
|
||||
groupMode: 'hierarchy',
|
||||
}));
|
||||
|
||||
// The whole tree is fetched once and every filter — connection included — is
|
||||
// applied in buildGraph. Putting the connection in the query key instead
|
||||
// would blank the graph on each switch while the new response is in flight.
|
||||
const { data, isLoading, error, refetch, isFetching } = useNetworkTopology();
|
||||
|
||||
const tooManyGuests = (data?.panel.guest_count ?? 0) > FORCE_GROUPING_ABOVE;
|
||||
const effectiveFilters = useMemo<TopologyFilters>(
|
||||
() =>
|
||||
tooManyGuests && filters.groupThreshold === 0
|
||||
? { ...filters, groupThreshold: DEFAULT_THRESHOLD }
|
||||
: filters,
|
||||
[filters, tooManyGuests],
|
||||
);
|
||||
|
||||
const { nodes, edges } = useMemo(
|
||||
() => buildGraph(data, effectiveFilters, expanded),
|
||||
[data, effectiveFilters, expanded],
|
||||
);
|
||||
const connections = useMemo(() => listConnections(data), [data]);
|
||||
|
||||
const handleFiltersChange = useCallback((next: TopologyFilters) => {
|
||||
setFilters((previous) => {
|
||||
// Expanding is per-node state; a different connection shows other nodes.
|
||||
if (previous.serverId !== next.serverId) setExpanded(new Set());
|
||||
if (previous.groupThreshold !== next.groupThreshold) {
|
||||
localStorage.setItem(THRESHOLD_STORAGE_KEY, String(next.groupThreshold));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpandNode = useCallback((nodeId: string) => {
|
||||
setExpanded((previous) => new Set(previous).add(nodeId));
|
||||
}, []);
|
||||
|
||||
const handleFit = useCallback(() => {
|
||||
fitView({ padding: 0.15, duration: 300 });
|
||||
}, [fitView]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(document.fullscreenElement === wrapperRef.current);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
if (document.fullscreenElement === wrapperRef.current) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void wrapperRef.current?.requestFullscreen();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(format: 'png' | 'svg') => {
|
||||
// getNodesBounds must come from the hook (not the standalone helper) so
|
||||
// it reads each node's actual measured width/height instead of zero.
|
||||
const bounds = getNodesBounds(nodes);
|
||||
toast.promise(exportGraph(bounds, nodes.length > 0, format, 'topology'), {
|
||||
loading: t('topology.export.progress', 'Rendering image…'),
|
||||
success: t('topology.export.done', 'Image saved'),
|
||||
error: t('topology.export.failed', 'Export failed'),
|
||||
});
|
||||
},
|
||||
[nodes, getNodesBounds, t],
|
||||
);
|
||||
|
||||
const toolbar = (
|
||||
<TopologyToolbar
|
||||
filters={effectiveFilters}
|
||||
onChange={handleFiltersChange}
|
||||
connections={connections}
|
||||
isFullscreen={isFullscreen}
|
||||
isFetching={isFetching}
|
||||
onFit={handleFit}
|
||||
onFullscreen={handleFullscreen}
|
||||
onRefresh={() => void refetch()}
|
||||
onExport={handleExport}
|
||||
exportDisabled={nodes.length === 0 || nodes.length > EXPORT_NODE_LIMIT}
|
||||
/>
|
||||
);
|
||||
|
||||
const warnings = data?.warnings ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('topology.title', 'Network topology')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('topology.subtitle', 'Hierarchical map of clusters, nodes and guests')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<div className="space-y-1 rounded-lg border border-amber-500/40 bg-amber-500/5 px-3 py-2">
|
||||
{warnings.map((warning, index) => (
|
||||
<div key={`${warning.code}-${index}`} className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{warning.code === 'offline'
|
||||
? t('topology.warning.offline', { server: warning.server_name ?? '' })
|
||||
: warning.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tooManyGuests ? (
|
||||
<p className="text-xs text-muted-foreground">{t('topology.too_many', 'Too many objects; grouping enabled')}</p>
|
||||
) : null}
|
||||
|
||||
{/* Above the canvas normally; in fullscreen it moves inside, where it is
|
||||
the only way to reach the filters. */}
|
||||
{isFullscreen ? null : toolbar}
|
||||
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn(
|
||||
'topology-canvas overflow-hidden border border-border bg-background',
|
||||
// In fullscreen the browser sizes the element, so the page-layout
|
||||
// height (and the rounded frame) would only leave a dead strip.
|
||||
isFullscreen ? 'h-full w-full' : 'h-[calc(100vh-19rem)] min-h-[480px] rounded-lg',
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full flex-col gap-3 p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-destructive">
|
||||
{t('topology.error', 'Failed to load topology')}
|
||||
</div>
|
||||
) : (
|
||||
<TopologyCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onExpandNode={handleExpandNode}
|
||||
toolbar={isFullscreen ? toolbar : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopologyPage() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<TopologyView />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
154
frontend/src/features/topology/TopologyToolbar.tsx
Normal file
154
frontend/src/features/topology/TopologyToolbar.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Maximize2, Minimize2, RefreshCw, Scan } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TopologyFilters, VmStatusFilter } from './lib/types';
|
||||
|
||||
const STATUSES: VmStatusFilter[] = ['all', 'running', 'stopped'];
|
||||
|
||||
interface TopologyToolbarProps {
|
||||
filters: TopologyFilters;
|
||||
onChange: (filters: TopologyFilters) => void;
|
||||
connections: Array<{ id: number; name: string }>;
|
||||
isFullscreen: boolean;
|
||||
isFetching: boolean;
|
||||
onFit: () => void;
|
||||
onFullscreen: () => void;
|
||||
onRefresh: () => void;
|
||||
onExport: (format: 'png' | 'svg') => void;
|
||||
exportDisabled: boolean;
|
||||
}
|
||||
|
||||
export function TopologyToolbar({
|
||||
filters,
|
||||
onChange,
|
||||
connections,
|
||||
isFullscreen,
|
||||
isFetching,
|
||||
onFit,
|
||||
onFullscreen,
|
||||
onRefresh,
|
||||
onExport,
|
||||
exportDisabled,
|
||||
}: TopologyToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-export-ignore="true"
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card/95 px-3 py-2 shadow-sm backdrop-blur"
|
||||
>
|
||||
<Select
|
||||
value={String(filters.serverId)}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...filters, serverId: value === 'all' ? 'all' : Number(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[190px] text-xs">
|
||||
<SelectValue placeholder={t('topology.filters.connection', 'Connection')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('topology.filters.all_connections', 'All connections')}</SelectItem>
|
||||
{connections.map((connection) => (
|
||||
<SelectItem key={connection.id} value={String(connection.id)}>
|
||||
{connection.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('topology.filters.vm_status', 'VM status')}:</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-border">
|
||||
{STATUSES.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...filters, vmStatus: status })}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs transition-colors',
|
||||
filters.vmStatus === status
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{t(`topology.filters.status_${status}`, status)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
||||
{t('topology.filters.threshold', 'Group VMs above')}
|
||||
</span>
|
||||
<Slider
|
||||
className="w-28"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={filters.groupThreshold}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...filters, groupThreshold: Array.isArray(value) ? value[0] : value })
|
||||
}
|
||||
/>
|
||||
<span className="w-8 text-xs tabular-nums text-muted-foreground">
|
||||
{filters.groupThreshold === 0 ? t('topology.filters.threshold_off', 'Off') : filters.groupThreshold}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh} title={t('topology.actions.refresh', 'Refresh')}>
|
||||
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onFit} title={t('topology.actions.fit', 'Fit to screen')}>
|
||||
<Scan className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={exportDisabled}
|
||||
title={t('topology.actions.export', 'Export')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onExport('png')}>
|
||||
{t('topology.actions.export_png', 'Export PNG')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onExport('svg')} title={t('topology.export.svg_hint', '')}>
|
||||
{t('topology.actions.export_svg', 'Export SVG')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onFullscreen}
|
||||
title={
|
||||
isFullscreen
|
||||
? t('topology.actions.exit_fullscreen', 'Exit fullscreen')
|
||||
: t('topology.actions.fullscreen', 'Fullscreen')
|
||||
}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
frontend/src/features/topology/lib/build-graph.ts
Normal file
158
frontend/src/features/topology/lib/build-graph.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Edge, Node } from '@xyflow/react';
|
||||
import { layoutTree, type LayoutNode } from './layout';
|
||||
import type { NetworkTopology, TopologyFilters, TopologyGuest } from './types';
|
||||
|
||||
const PANEL_ID = 'panel:root';
|
||||
// Wrapping earlier keeps the tree closer to the viewport's aspect ratio:
|
||||
// one wide row per node forces fitView to shrink everything to a ribbon.
|
||||
const GUESTS_PER_ROW = 6;
|
||||
|
||||
export interface BuildGraphResult {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
/** Guests left out by the status filter — shown in the toolbar counter. */
|
||||
filteredOut: number;
|
||||
}
|
||||
|
||||
function matchesStatus(guest: TopologyGuest, filter: TopologyFilters['vmStatus']): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'running') return guest.status === 'running';
|
||||
return guest.status !== 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the API hierarchy into React Flow nodes and edges.
|
||||
*
|
||||
* Pure and synchronous so it can live inside a `useMemo` and rerun on every
|
||||
* filter or slider change. Edges are derived from nesting — the API does not
|
||||
* ship them.
|
||||
*/
|
||||
export function buildGraph(
|
||||
data: NetworkTopology | undefined,
|
||||
filters: TopologyFilters,
|
||||
expanded: Set<string>,
|
||||
): BuildGraphResult {
|
||||
if (!data) return { nodes: [], edges: [], filteredOut: 0 };
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
let filteredOut = 0;
|
||||
let widestGuestList = 0;
|
||||
|
||||
const tree: LayoutNode = { id: PANEL_ID, kind: 'panel', children: [] };
|
||||
|
||||
const clusters =
|
||||
filters.serverId === 'all'
|
||||
? data.clusters
|
||||
: data.clusters.filter((cluster) => cluster.server_ids.includes(filters.serverId as number));
|
||||
|
||||
nodes.push({
|
||||
id: PANEL_ID,
|
||||
type: 'panel',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { panel: data.panel },
|
||||
draggable: false,
|
||||
});
|
||||
|
||||
for (const cluster of clusters) {
|
||||
const clusterBranch: LayoutNode = { id: cluster.id, kind: 'cluster', children: [] };
|
||||
const pveNodes =
|
||||
filters.serverId === 'all'
|
||||
? cluster.nodes
|
||||
: cluster.nodes.filter((node) => node.server_id === filters.serverId);
|
||||
if (pveNodes.length === 0) continue;
|
||||
|
||||
let clusterGuests = 0;
|
||||
|
||||
for (const node of pveNodes) {
|
||||
const visible = node.guests.filter((guest) => matchesStatus(guest, filters.vmStatus));
|
||||
filteredOut += node.guests.length - visible.length;
|
||||
clusterGuests += visible.length;
|
||||
|
||||
const nodeBranch: LayoutNode = { id: node.id, kind: 'pve', children: [] };
|
||||
|
||||
const collapse =
|
||||
filters.groupThreshold > 0 &&
|
||||
visible.length > filters.groupThreshold &&
|
||||
!expanded.has(node.id);
|
||||
|
||||
if (collapse) {
|
||||
const groupId = `${node.id}:group`;
|
||||
const running = visible.filter((guest) => guest.status === 'running').length;
|
||||
nodes.push({
|
||||
id: groupId,
|
||||
type: 'guestGroup',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
parentId: node.id,
|
||||
total: visible.length,
|
||||
running,
|
||||
stopped: visible.length - running,
|
||||
},
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${node.id}:${groupId}`, source: node.id, target: groupId });
|
||||
nodeBranch.children.push({ id: groupId, kind: 'guestGroup', children: [] });
|
||||
} else {
|
||||
widestGuestList = Math.max(widestGuestList, visible.length);
|
||||
for (const guest of visible) {
|
||||
nodes.push({
|
||||
id: guest.id,
|
||||
type: 'guest',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { guest },
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${node.id}:${guest.id}`, source: node.id, target: guest.id });
|
||||
nodeBranch.children.push({ id: guest.id, kind: 'guest', children: [] });
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: 'pve',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { node, guestCount: visible.length },
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${cluster.id}:${node.id}`, source: cluster.id, target: node.id });
|
||||
clusterBranch.children.push(nodeBranch);
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: cluster.id,
|
||||
type: 'cluster',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { cluster, nodeCount: pveNodes.length, guestCount: clusterGuests },
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${PANEL_ID}:${cluster.id}`, source: PANEL_ID, target: cluster.id });
|
||||
tree.children.push(clusterBranch);
|
||||
}
|
||||
|
||||
// A single row of dozens of guests makes the graph a thin unreadable
|
||||
// ribbon, so long lists wrap into a block once they exceed this.
|
||||
const positions = layoutTree(tree, {
|
||||
guestsPerRow: widestGuestList > GUESTS_PER_ROW + 2 ? GUESTS_PER_ROW : undefined,
|
||||
});
|
||||
for (const node of nodes) {
|
||||
const position = positions.get(node.id);
|
||||
if (position) node.position = position;
|
||||
}
|
||||
|
||||
return { nodes, edges, filteredOut };
|
||||
}
|
||||
|
||||
/** Connections present in the response, for the toolbar's connection filter. */
|
||||
export function listConnections(data: NetworkTopology | undefined): Array<{ id: number; name: string }> {
|
||||
if (!data) return [];
|
||||
const byId = new Map<number, string>();
|
||||
for (const cluster of data.clusters) {
|
||||
for (const node of cluster.nodes) {
|
||||
if (!byId.has(node.server_id)) byId.set(node.server_id, node.server_name);
|
||||
}
|
||||
}
|
||||
return [...byId.entries()]
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
102
frontend/src/features/topology/lib/export.ts
Normal file
102
frontend/src/features/topology/lib/export.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { getViewportForBounds, type Rect } from '@xyflow/react';
|
||||
import { toPng, toSvg } from 'html-to-image';
|
||||
|
||||
const MAX_SIDE = 4096;
|
||||
|
||||
/**
|
||||
* Saves the graph as an image.
|
||||
*
|
||||
* Custom nodes are HTML, so a native SVG serialisation is impossible — the SVG
|
||||
* variant wraps the markup in `<foreignObject>`, which browsers render fine but
|
||||
* vector editors only partially. PNG is the default for that reason.
|
||||
*
|
||||
* The capture always covers the whole graph rather than the current viewport,
|
||||
* which is what people expect from an export button.
|
||||
*
|
||||
* `bounds` must come from the `useReactFlow().getNodesBounds()` hook, not the
|
||||
* standalone `getNodesBounds` helper - the standalone version has no access to
|
||||
* each node's measured width/height and silently treats them as zero, which
|
||||
* undersizes the canvas and clips the last row/column of the graph.
|
||||
*/
|
||||
export async function exportGraph(
|
||||
bounds: Rect,
|
||||
hasNodes: boolean,
|
||||
format: 'png' | 'svg',
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const viewport = document.querySelector<HTMLElement>('.react-flow__viewport');
|
||||
if (!viewport || !hasNodes) return;
|
||||
|
||||
// Edges live inside a nested <svg> (`.react-flow__edges`). html-to-image
|
||||
// clones any <svg> with the browser's native `cloneNode(true)` instead of
|
||||
// its own computed-style-inlining walk (see cloneChildren in
|
||||
// html-to-image/lib/clone-node.js, which returns early for svg elements),
|
||||
// so nothing that comes from an external stylesheet - including React
|
||||
// Flow's own `.react-flow__edge-path { stroke: var(--xy-edge-stroke) }` -
|
||||
// survives into the clone. The path has no inline style of its own, so the
|
||||
// exported edge ends up with no stroke at all. Give every edge element an
|
||||
// explicit inline stroke (its current resolved color) right before
|
||||
// capture, then revert once the capture is done.
|
||||
const edgeLayer = viewport.querySelector<SVGElement>('.react-flow__edges');
|
||||
const restoreEdgeStyles: Array<() => void> = [];
|
||||
if (edgeLayer) {
|
||||
const styledEls = edgeLayer.querySelectorAll<SVGElement>('path, circle, polygon, rect');
|
||||
styledEls.forEach((el) => {
|
||||
const computed = getComputedStyle(el);
|
||||
const prevStroke = el.style.stroke;
|
||||
const prevFill = el.style.fill;
|
||||
const stroke = computed.stroke;
|
||||
const fill = computed.fill;
|
||||
if (stroke && stroke !== 'none') el.style.stroke = stroke;
|
||||
if (fill && fill !== 'none') el.style.fill = fill;
|
||||
restoreEdgeStyles.push(() => {
|
||||
el.style.stroke = prevStroke;
|
||||
el.style.fill = prevFill;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const padding = 40;
|
||||
// Size the canvas to the graph itself, not an arbitrary minimum - forcing a
|
||||
// larger canvas than the content needs a zoom above the 2x cap to fill it,
|
||||
// so small graphs end up tiny in a mostly-empty image.
|
||||
const width = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.width + padding * 2)));
|
||||
const height = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.height + padding * 2)));
|
||||
// getViewportForBounds's padding is a fraction of width/height unless given
|
||||
// as a "px" string - a bare number here (e.g. 40) is read as 4000% padding,
|
||||
// which collapses the zoom to the floor and leaves the graph tiny.
|
||||
const transform = getViewportForBounds(bounds, width, height, 0.2, 2, `${padding}px`);
|
||||
|
||||
const background =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--background').trim() || '#0a0a0a';
|
||||
|
||||
const options = {
|
||||
backgroundColor: background,
|
||||
width,
|
||||
height,
|
||||
style: {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
|
||||
},
|
||||
// Controls and the minimap are UI chrome, not part of the diagram.
|
||||
filter: (element: HTMLElement) =>
|
||||
!element?.classList?.contains?.('react-flow__minimap') &&
|
||||
!element?.classList?.contains?.('react-flow__controls') &&
|
||||
!element?.dataset?.exportIgnore,
|
||||
cacheBust: true,
|
||||
pixelRatio: format === 'png' ? 2 : 1,
|
||||
};
|
||||
|
||||
let dataUrl: string;
|
||||
try {
|
||||
dataUrl = format === 'png' ? await toPng(viewport, options) : await toSvg(viewport, options);
|
||||
} finally {
|
||||
restoreEdgeStyles.forEach((restore) => restore());
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${filename}.${format}`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
153
frontend/src/features/topology/lib/layout.ts
Normal file
153
frontend/src/features/topology/lib/layout.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Tidy-tree layout for the topology graph.
|
||||
*
|
||||
* The graph is a strict tree of fixed depth (panel → cluster → node → guest),
|
||||
* so a dedicated layout engine would be all cost and no benefit: this is ~60
|
||||
* synchronous lines, deterministic, and O(n) — it can rerun on every slider
|
||||
* tick without the flicker an async layout pass introduces.
|
||||
*
|
||||
* Orientation is top-to-bottom: each depth level is a fixed row, siblings
|
||||
* spread out horizontally within it.
|
||||
*/
|
||||
|
||||
export type LayoutKind = 'panel' | 'cluster' | 'pve' | 'guest' | 'guestGroup';
|
||||
|
||||
export interface LayoutNode {
|
||||
id: string;
|
||||
kind: LayoutKind;
|
||||
children: LayoutNode[];
|
||||
}
|
||||
|
||||
export interface XY {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Fixed vertical position of every depth level. */
|
||||
export const ROW_Y: Record<LayoutKind, number> = {
|
||||
panel: 0,
|
||||
cluster: 200,
|
||||
pve: 420,
|
||||
guest: 640,
|
||||
guestGroup: 640,
|
||||
};
|
||||
|
||||
export const NODE_SIZE: Record<LayoutKind, { width: number; height: number }> = {
|
||||
panel: { width: 230, height: 92 },
|
||||
cluster: { width: 250, height: 96 },
|
||||
pve: { width: 270, height: 132 },
|
||||
guest: { width: 250, height: 84 },
|
||||
guestGroup: { width: 250, height: 96 },
|
||||
};
|
||||
|
||||
const GAP_X = 30;
|
||||
const SUBTREE_GAP = 40;
|
||||
const ROW_GAP_Y = 26;
|
||||
|
||||
export interface LayoutOptions {
|
||||
/**
|
||||
* Wrap a node's guests into extra rows once the list is longer than this.
|
||||
* Keeps a busy node from stretching the graph into an unreadable ribbon.
|
||||
*/
|
||||
guestsPerRow?: number;
|
||||
}
|
||||
|
||||
/** How many rows a node's guest list is wrapped into. */
|
||||
function rowsFor(node: LayoutNode, opts: LayoutOptions): number {
|
||||
if (node.kind !== 'pve' || !opts.guestsPerRow) return 1;
|
||||
return Math.max(1, Math.ceil(node.children.length / opts.guestsPerRow));
|
||||
}
|
||||
|
||||
/** Horizontal space a subtree needs, including the gaps between its children. */
|
||||
function subtreeWidth(node: LayoutNode, opts: LayoutOptions): number {
|
||||
const own = NODE_SIZE[node.kind].width;
|
||||
if (node.children.length === 0) return own;
|
||||
|
||||
const rows = rowsFor(node, opts);
|
||||
if (rows > 1) {
|
||||
const perRow = Math.ceil(node.children.length / rows);
|
||||
const widest = Math.max(...node.children.map((c) => subtreeWidth(c, opts)));
|
||||
return Math.max(own, perRow * widest + (perRow - 1) * GAP_X);
|
||||
}
|
||||
|
||||
const childrenWidth = node.children.reduce(
|
||||
(sum, child, index) => sum + subtreeWidth(child, opts) + (index > 0 ? GAP_X : 0),
|
||||
0,
|
||||
);
|
||||
return Math.max(own, childrenWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute position of every node, keyed by id. Parents are centred on the
|
||||
* horizontal span of their children.
|
||||
*/
|
||||
export function layoutTree(root: LayoutNode, opts: LayoutOptions = {}): Map<string, XY> {
|
||||
const positions = new Map<string, XY>();
|
||||
|
||||
function place(node: LayoutNode, left: number): number {
|
||||
const size = NODE_SIZE[node.kind];
|
||||
const width = subtreeWidth(node, opts);
|
||||
const y = ROW_Y[node.kind];
|
||||
|
||||
if (node.children.length === 0) {
|
||||
positions.set(node.id, { x: left + (width - size.width) / 2, y });
|
||||
return width;
|
||||
}
|
||||
|
||||
const rows = rowsFor(node, opts);
|
||||
|
||||
if (rows > 1) {
|
||||
// Wrap the guest list into a block so a busy node stays readable.
|
||||
const perRow = Math.ceil(node.children.length / rows);
|
||||
const childHeight = NODE_SIZE[node.children[0].kind].height;
|
||||
node.children.forEach((child, index) => {
|
||||
const row = Math.floor(index / perRow);
|
||||
const col = index % perRow;
|
||||
const childWidth = NODE_SIZE[child.kind].width;
|
||||
positions.set(child.id, {
|
||||
x: left + col * (childWidth + GAP_X),
|
||||
y: ROW_Y[child.kind] + row * (childHeight + ROW_GAP_Y),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
let cursor = left;
|
||||
node.children.forEach((child, index) => {
|
||||
if (index > 0) cursor += GAP_X;
|
||||
cursor += place(child, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
positions.set(node.id, { x: left + (width - size.width) / 2, y });
|
||||
return width;
|
||||
}
|
||||
|
||||
place(root, 0);
|
||||
|
||||
// Separate top-level subtrees a little more than siblings inside one.
|
||||
let extra = 0;
|
||||
for (const cluster of root.children) {
|
||||
if (extra > 0) shift(cluster, extra, positions);
|
||||
extra += SUBTREE_GAP;
|
||||
}
|
||||
if (root.children.length > 0) {
|
||||
const clusterPositions = root.children
|
||||
.map((c) => positions.get(c.id))
|
||||
.filter((p): p is XY => Boolean(p));
|
||||
if (clusterPositions.length > 0) {
|
||||
const first = clusterPositions[0].x;
|
||||
const last = clusterPositions[clusterPositions.length - 1].x;
|
||||
positions.set(root.id, {
|
||||
x: (first + last) / 2 + (NODE_SIZE.cluster.width - NODE_SIZE.panel.width) / 2,
|
||||
y: ROW_Y.panel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function shift(node: LayoutNode, delta: number, positions: Map<string, XY>): void {
|
||||
const position = positions.get(node.id);
|
||||
if (position) positions.set(node.id, { ...position, x: position.x + delta });
|
||||
for (const child of node.children) shift(child, delta, positions);
|
||||
}
|
||||
137
frontend/src/features/topology/lib/types.ts
Normal file
137
frontend/src/features/topology/lib/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/** Shapes returned by GET /proxmox/api/network/topology (backend schemas.py). */
|
||||
|
||||
export interface TopologyNic {
|
||||
key: string;
|
||||
model?: string | null;
|
||||
mac?: string | null;
|
||||
bridge?: string | null;
|
||||
vlan_tag?: number | null;
|
||||
trunks: number[];
|
||||
firewall: boolean;
|
||||
link_down: boolean;
|
||||
name?: string | null;
|
||||
ip?: string | null;
|
||||
gw?: string | null;
|
||||
rate?: number | null;
|
||||
mtu?: number | null;
|
||||
}
|
||||
|
||||
export interface TopologyBridge {
|
||||
name: string;
|
||||
type?: string | null;
|
||||
cidr?: string | null;
|
||||
vlan_aware: boolean;
|
||||
ports?: string | null;
|
||||
active: boolean;
|
||||
zone?: string | null;
|
||||
ipam_network_id?: number | null;
|
||||
ipam_cidr?: string | null;
|
||||
ipam_name?: string | null;
|
||||
}
|
||||
|
||||
export interface TopologyGuest {
|
||||
id: string;
|
||||
server_id: number;
|
||||
node: string;
|
||||
vmid: number;
|
||||
type: 'qemu' | 'lxc' | string;
|
||||
name: string;
|
||||
status: string;
|
||||
is_template: boolean;
|
||||
lock?: string | null;
|
||||
cpu?: number | null;
|
||||
cores?: number | null;
|
||||
mem?: number | null;
|
||||
maxmem?: number | null;
|
||||
disk?: number | null;
|
||||
maxdisk?: number | null;
|
||||
uptime?: number | null;
|
||||
tags: string[];
|
||||
owner_id?: number | null;
|
||||
owner_username?: string | null;
|
||||
ip?: string | null;
|
||||
nics: TopologyNic[];
|
||||
}
|
||||
|
||||
export interface TopologyNode {
|
||||
id: string;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
node: string;
|
||||
status: string;
|
||||
/** Rebuilt from the local VM cache because the connection is unreachable. */
|
||||
stale: boolean;
|
||||
cpu?: number | null;
|
||||
maxcpu?: number | null;
|
||||
mem?: number | null;
|
||||
maxmem?: number | null;
|
||||
uptime?: number | null;
|
||||
bridges: TopologyBridge[];
|
||||
guests: TopologyGuest[];
|
||||
}
|
||||
|
||||
export interface TopologyCluster {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'cluster' | 'standalone' | string;
|
||||
server_ids: number[];
|
||||
online: boolean;
|
||||
nodes: TopologyNode[];
|
||||
}
|
||||
|
||||
export interface TopologyWarning {
|
||||
server_id?: number | null;
|
||||
server_name?: string | null;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TopologyPanelSummary {
|
||||
name: string;
|
||||
cluster_count: number;
|
||||
node_count: number;
|
||||
guest_count: number;
|
||||
}
|
||||
|
||||
export interface NetworkTopology {
|
||||
generated_at: string;
|
||||
panel: TopologyPanelSummary;
|
||||
clusters: TopologyCluster[];
|
||||
warnings: TopologyWarning[];
|
||||
}
|
||||
|
||||
export type VmStatusFilter = 'all' | 'running' | 'stopped';
|
||||
|
||||
/** Grouping mode. Only `hierarchy` ships today; VLAN/tag views reuse this switch. */
|
||||
export type GroupMode = 'hierarchy';
|
||||
|
||||
export interface TopologyFilters {
|
||||
serverId: number | 'all';
|
||||
vmStatus: VmStatusFilter;
|
||||
/** Collapse a node's guests into one badge above this count. 0 disables it. */
|
||||
groupThreshold: number;
|
||||
groupMode: GroupMode;
|
||||
}
|
||||
|
||||
/** Data carried by each React Flow node type. */
|
||||
export interface PanelNodeData extends Record<string, unknown> {
|
||||
panel: TopologyPanelSummary;
|
||||
}
|
||||
export interface ClusterNodeData extends Record<string, unknown> {
|
||||
cluster: TopologyCluster;
|
||||
nodeCount: number;
|
||||
guestCount: number;
|
||||
}
|
||||
export interface PveNodeData extends Record<string, unknown> {
|
||||
node: TopologyNode;
|
||||
guestCount: number;
|
||||
}
|
||||
export interface GuestNodeData extends Record<string, unknown> {
|
||||
guest: TopologyGuest;
|
||||
}
|
||||
export interface GuestGroupNodeData extends Record<string, unknown> {
|
||||
parentId: string;
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
}
|
||||
48
frontend/src/features/topology/nodes/ClusterNode.tsx
Normal file
48
frontend/src/features/topology/nodes/ClusterNode.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Network, Server } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ClusterNodeData } from '../lib/types';
|
||||
|
||||
function ClusterNodeImpl({ data }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { cluster, nodeCount, guestCount } = data as ClusterNodeData;
|
||||
const isCluster = cluster.kind === 'cluster';
|
||||
const Icon = isCluster ? Network : Server;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[250px] rounded-lg border bg-card px-3 py-2.5 shadow-sm',
|
||||
cluster.online ? 'border-emerald-500/60' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">{cluster.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto h-2 w-2 shrink-0 rounded-full',
|
||||
cluster.online ? 'bg-emerald-500' : 'bg-muted-foreground/50',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{isCluster
|
||||
? t('topology.legend.cluster', 'Cluster')
|
||||
: t('topology.legend.standalone', 'Standalone')}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('topology.node.nodes_count', { count: nodeCount })} · {t('topology.node.guests_count', { count: guestCount })}
|
||||
</span>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClusterNode = memo(ClusterNodeImpl);
|
||||
38
frontend/src/features/topology/nodes/GuestGroupNode.tsx
Normal file
38
frontend/src/features/topology/nodes/GuestGroupNode.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layers } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { GuestGroupNodeData } from '../lib/types';
|
||||
|
||||
interface GuestGroupNodeProps extends NodeProps {
|
||||
data: GuestGroupNodeData & { onExpand?: (nodeId: string) => void };
|
||||
}
|
||||
|
||||
function GuestGroupNodeImpl({ data }: GuestGroupNodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { parentId, total, running, stopped, onExpand } = data;
|
||||
|
||||
return (
|
||||
<div className="w-[250px] rounded-md border border-dashed border-primary/60 bg-card px-2.5 py-2 shadow-sm">
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!bg-border" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="text-xs font-medium">{t('topology.group.count', { count: total })}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{t('topology.group.breakdown', { running, stopped })}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-6 w-full text-[10px]"
|
||||
onClick={() => onExpand?.(parentId)}
|
||||
>
|
||||
{t('topology.group.expand', 'Show all')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GuestGroupNode = memo(GuestGroupNodeImpl);
|
||||
56
frontend/src/features/topology/nodes/GuestNode.tsx
Normal file
56
frontend/src/features/topology/nodes/GuestNode.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Link } from 'react-router';
|
||||
import { Box, Monitor } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GuestNodeData } from '../lib/types';
|
||||
|
||||
const VISIBLE_TAGS = 2;
|
||||
|
||||
function GuestNodeImpl({ data }: NodeProps) {
|
||||
const { guest } = data as GuestNodeData;
|
||||
const running = guest.status === 'running';
|
||||
const Icon = guest.type === 'lxc' ? Box : Monitor;
|
||||
const extraTags = guest.tags.length - VISIBLE_TAGS;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/instances/${guest.server_id}/${guest.vmid}?node=${guest.node}&type=${guest.type}`}
|
||||
className={cn(
|
||||
'block w-[250px] rounded-md border bg-card px-2.5 py-2 shadow-sm transition-colors hover:border-primary/60 hover:bg-accent/40',
|
||||
running ? 'border-emerald-500/50' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!bg-border" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className={cn('h-3.5 w-3.5 shrink-0', running ? 'text-emerald-500' : 'text-muted-foreground')} />
|
||||
<span className="truncate text-xs font-medium">{guest.name}</span>
|
||||
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground">#{guest.vmid}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[10px]">
|
||||
<span className={cn(running ? 'text-emerald-500' : 'text-muted-foreground')}>{guest.status}</span>
|
||||
{guest.ip ? <span className="truncate font-mono text-muted-foreground">{guest.ip}</span> : null}
|
||||
{guest.nics[0]?.bridge ? (
|
||||
<span className="truncate text-muted-foreground">{guest.nics[0].bridge}</span>
|
||||
) : null}
|
||||
{guest.nics[0]?.vlan_tag ? (
|
||||
<span className="text-muted-foreground">VLAN {guest.nics[0].vlan_tag}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{guest.tags.length > 0 ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{guest.tags.slice(0, VISIBLE_TAGS).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="px-1 py-0 text-[9px]">{tag}</Badge>
|
||||
))}
|
||||
{extraTags > 0 ? (
|
||||
<span className="text-[9px] text-muted-foreground">+{extraTags}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const GuestNode = memo(GuestNodeImpl);
|
||||
27
frontend/src/features/topology/nodes/PanelNode.tsx
Normal file
27
frontend/src/features/topology/nodes/PanelNode.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Boxes } from 'lucide-react';
|
||||
import type { PanelNodeData } from '../lib/types';
|
||||
|
||||
function PanelNodeImpl({ data }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { panel } = data as PanelNodeData;
|
||||
|
||||
return (
|
||||
<div className="w-[230px] rounded-lg border-2 border-primary bg-card px-3 py-2.5 shadow-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="h-5 w-5 shrink-0 text-primary" />
|
||||
<span className="truncate text-sm font-semibold">{panel.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{t('topology.node.clusters_count', { count: panel.cluster_count })}</span>
|
||||
<span>{t('topology.node.nodes_count', { count: panel.node_count })}</span>
|
||||
<span>{t('topology.node.guests_count', { count: panel.guest_count })}</span>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PanelNode = memo(PanelNodeImpl);
|
||||
79
frontend/src/features/topology/nodes/PveNode.tsx
Normal file
79
frontend/src/features/topology/nodes/PveNode.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, WifiOff } from 'lucide-react';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PveNodeData } from '../lib/types';
|
||||
|
||||
function Meter({ label, value, hint }: { label: string; value: number | null; hint?: string }) {
|
||||
const percent = value === null ? 0 : Math.min(100, Math.max(0, value * 100));
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span>{value === null ? '—' : `${percent.toFixed(0)}%`}{hint ? ` · ${hint}` : ''}</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-input">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-[width]',
|
||||
percent > 90 ? 'bg-red-500' : percent > 70 ? 'bg-amber-500' : 'bg-primary',
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PveNodeImpl({ data }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, guestCount } = data as PveNodeData;
|
||||
const online = node.status === 'online';
|
||||
const memRatio = node.mem && node.maxmem ? node.mem / node.maxmem : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[270px] rounded-lg border bg-card px-3 py-2.5 shadow-sm',
|
||||
node.stale ? 'border-dashed border-amber-500/60' : online ? 'border-emerald-500/50' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">{node.node}</span>
|
||||
{node.stale ? (
|
||||
<WifiOff className="ml-auto h-3.5 w-3.5 shrink-0 text-amber-500" aria-label={t('topology.legend.stale', 'Cached data')} />
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto h-2 w-2 shrink-0 rounded-full',
|
||||
online ? 'bg-emerald-500' : 'bg-muted-foreground/50',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span className="truncate">{node.server_name}</span>
|
||||
<span>{t('topology.node.guests_count', { count: guestCount })}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Meter
|
||||
label={t('topology.node.cpu', 'CPU')}
|
||||
value={node.cpu ?? null}
|
||||
hint={node.maxcpu ? `${node.maxcpu} vCPU` : undefined}
|
||||
/>
|
||||
<Meter
|
||||
label={t('topology.node.ram', 'RAM')}
|
||||
value={memRatio}
|
||||
hint={node.maxmem ? formatBytes(node.maxmem, 0) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PveNode = memo(PveNodeImpl);
|
||||
35
frontend/src/features/topology/topology.css
Normal file
35
frontend/src/features/topology/topology.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/* React Flow chrome follows the panel's design tokens, in both themes. */
|
||||
.topology-canvas .react-flow {
|
||||
--xy-background-color: var(--background);
|
||||
--xy-edge-stroke: var(--primary);
|
||||
--xy-edge-stroke-selected: var(--primary);
|
||||
--xy-node-border-radius: var(--radius);
|
||||
--xy-controls-button-background-color: var(--card);
|
||||
--xy-controls-button-background-color-hover: var(--accent);
|
||||
--xy-controls-button-color: var(--foreground);
|
||||
--xy-controls-button-color-hover: var(--foreground);
|
||||
--xy-controls-button-border-color: var(--border);
|
||||
--xy-minimap-background-color: var(--card);
|
||||
--xy-minimap-mask-background-color-props: color-mix(in oklab, var(--background) 70%, transparent);
|
||||
--xy-attribution-background-color: transparent;
|
||||
}
|
||||
|
||||
/* Nodes bring their own card styling; the default white box would fight it. */
|
||||
.topology-canvas .react-flow__node {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.topology-canvas .react-flow__handle {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.topology-canvas .react-flow__attribution {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -70,6 +70,15 @@ export function useDeleteProxmoxJob(serverId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useScanNfsExports(serverId: number) {
|
||||
return useMutation({
|
||||
mutationFn: (server: string) =>
|
||||
apiClient.get<{ exports: { path: string }[] }>(
|
||||
`/proxmox/api/backups/scan-nfs/${serverId}?server=${encodeURIComponent(server)}`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateStorage(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -592,11 +592,12 @@ export function useUpdateCloudInit(serverId: number, vmid: number, node: string)
|
||||
});
|
||||
}
|
||||
|
||||
export function useMoveDisk(serverId: number, vmid: number, node: string) {
|
||||
export function useMoveDisk(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (body: { disk: string; target_storage: string; delete?: boolean; format?: string }) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/disk/move?node=${node}`, body),
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/disk/move?node=${node}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { IPAMNetwork, IPAMPool, IPAMAllocation } from '@/types';
|
||||
import type {
|
||||
IPAMNetwork,
|
||||
IPAMPool,
|
||||
IPAMAllocation,
|
||||
GuestAddress,
|
||||
GuestAddressApplyStatus,
|
||||
} from '@/types';
|
||||
|
||||
export const ipamKeys = {
|
||||
networks: ['ipam-networks'] as const,
|
||||
@@ -251,11 +257,17 @@ export function useCleanupOrphans() {
|
||||
});
|
||||
}
|
||||
|
||||
export interface IPAMLinkSkipped {
|
||||
ip_address: string;
|
||||
resource_name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function useLinkAllocations() {
|
||||
const invalidate = useInvalidateIPAM();
|
||||
return useMutation({
|
||||
mutationFn: (body?: { server_id?: number; network_id?: number }) =>
|
||||
apiClient.post<{ linked: unknown[]; not_found: unknown[] }>('/ipam/api/link-allocations', body ?? {}),
|
||||
apiClient.post<{ linked: unknown[]; not_found: IPAMLinkSkipped[] }>('/ipam/api/link-allocations', body ?? {}),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -283,3 +295,95 @@ export function useIPHistoryLookup() {
|
||||
mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ==================== Адреса инстанса (несколько IP на гостя) ====================
|
||||
|
||||
export interface GuestAddressApplyResult {
|
||||
address: GuestAddress;
|
||||
applied: boolean;
|
||||
apply_status: GuestAddressApplyStatus;
|
||||
persist?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const guestAddressKey = (serverId: number, vmid: number) =>
|
||||
['guest-addresses', serverId, vmid] as const;
|
||||
|
||||
export function useGuestAddresses(serverId: number, vmid: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: guestAddressKey(serverId, vmid),
|
||||
queryFn: () =>
|
||||
apiClient.get<{ addresses: GuestAddress[]; count: number }>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
|
||||
),
|
||||
enabled: enabled && serverId > 0 && vmid > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Инвалидация адресов гостя + всего, что показывает IP инстанса. */
|
||||
function useInvalidateGuestAddresses(serverId: number, vmid: number) {
|
||||
const qc = useQueryClient();
|
||||
const invalidateIPAM = useInvalidateIPAM();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: guestAddressKey(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: ['instances'] });
|
||||
qc.invalidateQueries({ queryKey: ['vms'] });
|
||||
invalidateIPAM();
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (body: {
|
||||
network_id: number;
|
||||
pool_id?: number;
|
||||
ip_address?: string;
|
||||
target_interface?: string;
|
||||
make_primary?: boolean;
|
||||
hostname?: string;
|
||||
notes?: string;
|
||||
}) =>
|
||||
apiClient.post<GuestAddressApplyResult>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
|
||||
body,
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (allocationId: number) =>
|
||||
apiClient.post<GuestAddressApplyResult>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/apply`,
|
||||
{},
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetPrimaryGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (allocationId: number) =>
|
||||
apiClient.post<{ address: GuestAddress }>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/primary`,
|
||||
{},
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveGuestAddress(serverId: number, vmid: number) {
|
||||
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
|
||||
return useMutation({
|
||||
mutationFn: (allocationId: number) =>
|
||||
apiClient.delete<{ released: string; removed_from_guest: boolean; error?: string | null }>(
|
||||
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}`,
|
||||
),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getTasksWebSocket } from '@/lib/websocket';
|
||||
import { vmKeys } from './use-instances';
|
||||
import { nodeKeys } from './use-nodes';
|
||||
import { topologyKeys } from './use-topology';
|
||||
|
||||
/**
|
||||
* Глобальная подписка на real-time события WebSocket, которая инвалидирует
|
||||
@@ -41,11 +42,13 @@ export function useGlobalRealtimeSync() {
|
||||
old?.filter((vm) => !(vm.server_id === msg.server_id && vm.vmid === msg.vmid))
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
qc.invalidateQueries({ queryKey: topologyKeys.all });
|
||||
});
|
||||
|
||||
const unsubVmCreated = ws.onType('vm_created', () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
qc.invalidateQueries({ queryKey: topologyKeys.all });
|
||||
});
|
||||
|
||||
const unsubVmOwnerChanged = ws.onType('vm_owner_changed', (data: unknown) => {
|
||||
@@ -73,6 +76,8 @@ export function useGlobalRealtimeSync() {
|
||||
// Список VM/LXC зависит от набора серверов и их online-статуса
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
// Топология рисует те же серверы/ноды — иначе граф останется старым
|
||||
qc.invalidateQueries({ queryKey: topologyKeys.all });
|
||||
};
|
||||
|
||||
const unsubServerAdded = ws.onType('server_added', invalidateServerCaches);
|
||||
|
||||
35
frontend/src/hooks/use-topology.ts
Normal file
35
frontend/src/hooks/use-topology.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { NetworkTopology } from '@/features/topology/lib/types';
|
||||
|
||||
export interface TopologyParams {
|
||||
serverId?: number;
|
||||
includeNics?: boolean;
|
||||
includeTemplates?: boolean;
|
||||
}
|
||||
|
||||
export const topologyKeys = {
|
||||
all: ['network-topology'] as const,
|
||||
filtered: (params: TopologyParams) => ['network-topology', params] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole infrastructure tree in one request.
|
||||
*
|
||||
* The VM status filter is applied client-side (see build-graph) rather than
|
||||
* through `include_stopped`, so flipping it never costs a round-trip.
|
||||
*/
|
||||
export function useNetworkTopology(params: TopologyParams = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.serverId !== undefined) query.set('server_id', String(params.serverId));
|
||||
if (params.includeNics) query.set('include_nics', 'true');
|
||||
if (params.includeTemplates) query.set('include_templates', 'true');
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
|
||||
return useQuery({
|
||||
queryKey: topologyKeys.filtered(params),
|
||||
queryFn: () => apiClient.get<NetworkTopology>(`/proxmox/api/network/topology${suffix}`),
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { useGlobalRealtimeSync } from '@/hooks/use-realtime-sync';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import NotificationsDropdown from '@/components/shared/NotificationsDropdown';
|
||||
import ActiveTasksIndicator from '@/components/shared/ActiveTasksIndicator';
|
||||
import CommandPalette from '@/components/shared/CommandPalette';
|
||||
import MinimizedOpsTray from '@/components/shared/MinimizedOpsTray';
|
||||
import { navGroups, canSeeNavItem } from '@/lib/nav-items';
|
||||
@@ -275,6 +276,7 @@ function TopBar() {
|
||||
<TooltipContent>{theme === 'dark' ? 'Light mode' : 'Dark mode'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ActiveTasksIndicator />
|
||||
<NotificationsDropdown />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Package,
|
||||
Terminal,
|
||||
UserCog,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
import { hasPermission } from './permissions';
|
||||
|
||||
@@ -64,6 +65,7 @@ export const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: 'Network',
|
||||
items: [
|
||||
{ label: 'Topology', icon: Waypoints, path: '/topology', permission: 'network:view' },
|
||||
{ label: 'IPAM', icon: Globe, path: '/ipam', permission: 'ipam:view' },
|
||||
{ label: 'Networks', icon: Network, path: '/networks', permission: 'server:manage' },
|
||||
],
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"my apps": "My Apps",
|
||||
"search": "Search sections",
|
||||
"searchPlaceholder": "Jump to a section...",
|
||||
"searchEmpty": "No results found"
|
||||
"searchEmpty": "No results found",
|
||||
"topology": "Topology"
|
||||
},
|
||||
"appstore": {
|
||||
"title": "App Store",
|
||||
@@ -491,6 +492,15 @@
|
||||
"disk_move": "Move disk",
|
||||
"disk_move_delete": "Delete source after move",
|
||||
"disk_moved": "Disk move started",
|
||||
"disk_move_stop_hint": "The container is running — it needs to be stopped before the move. The wizard will stop it, move the disk, and start it back automatically.",
|
||||
"disk_move_confirm_title": "Move disk?",
|
||||
"disk_move_confirm_plain": "Disk {{dev}} will be moved to storage {{storage}}.",
|
||||
"disk_move_confirm_running": "The container is currently running. Moving disk {{dev}} to {{storage}} requires it to be stopped first. It will be stopped, the disk moved, then the container will be started again.",
|
||||
"disk_move_stage_stopping": "Stopping container...",
|
||||
"disk_move_stage_moving": "Moving disk...",
|
||||
"disk_move_stage_starting": "Starting container back up...",
|
||||
"disk_move_stop_and_move": "Stop and move",
|
||||
"disk_move_stop_timeout": "Timed out waiting for the container to stop — move cancelled",
|
||||
"disk_add": "Add disk",
|
||||
"disk_added": "Disk {{dev}} added",
|
||||
"disk_bus": "Bus",
|
||||
@@ -755,7 +765,8 @@
|
||||
"quota_exceeded_hint": "Reduce the resources above to continue.",
|
||||
"quota_used_of": "{{used}} of {{limit}}",
|
||||
"quota_projection_note": "Including the instance being created.",
|
||||
"ip_cidr_placeholder": "10.10.10.5/24 or dhcp"
|
||||
"ip_cidr_placeholder": "10.10.10.5/24 or dhcp",
|
||||
"invalid_name": "Name must be a valid DNS name: start with a letter, contain only letters, digits and hyphens, end with a letter or digit (e.g. my-app-01)"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Servers",
|
||||
@@ -1235,6 +1246,9 @@
|
||||
"path": "Path",
|
||||
"server": "Server",
|
||||
"export": "Export",
|
||||
"scan_exports": "Scan server for exports",
|
||||
"server_required": "Enter the server address first",
|
||||
"no_exports_found": "No exports found — check /etc/exports on the server",
|
||||
"share": "Share",
|
||||
"datastore": "Datastore",
|
||||
"username": "Username",
|
||||
@@ -1405,6 +1419,7 @@
|
||||
"no_unlinked": "All VMs are linked",
|
||||
"link_allocations": "Scan & link",
|
||||
"linked": "Linked",
|
||||
"link_skipped": "Skipped: {{count}}",
|
||||
"ip_history": "IP history lookup",
|
||||
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
|
||||
"resource_required": "Please specify a resource before allocating an IP.",
|
||||
@@ -1469,7 +1484,40 @@
|
||||
"allocation_created": "Allocation created",
|
||||
"allocation_updated": "Allocation updated",
|
||||
"delete_allocation_confirm": "Release IP {{ip}}?",
|
||||
"notes": "Notes"
|
||||
"notes": "Notes",
|
||||
"addresses": "IP addresses",
|
||||
"add_address": "Add IP",
|
||||
"add_address_title": "Add IP address",
|
||||
"add_address_description": "The address is allocated in IPAM and attached to the guest interface as an alias.",
|
||||
"add_address_submit": "Assign address",
|
||||
"no_addresses": "No addresses in IPAM",
|
||||
"primary": "primary",
|
||||
"make_primary": "Make primary address",
|
||||
"primary_managed_by_nic": "The primary address comes from the NIC config",
|
||||
"from_nic_config": "from NIC config",
|
||||
"applied": "applied",
|
||||
"not_applied": "not applied",
|
||||
"runtime_only": "until reboot",
|
||||
"runtime_only_hint": "Address is up, but the guest network stack was not recognised — it will not survive a reboot",
|
||||
"discovered": "discovered",
|
||||
"apply": "Apply",
|
||||
"release": "Release",
|
||||
"release_title": "Release address?",
|
||||
"release_description": "Address {{ip}} will be removed from the guest interface and released in IPAM.",
|
||||
"address_added": "Address {{ip}} assigned",
|
||||
"address_released": "Address {{ip}} released",
|
||||
"address_released_not_removed": "{{ip}} released in IPAM, but could not be removed from the guest: {{error}}",
|
||||
"address_runtime_only": "{{ip}} is up but not persisted: guest network stack was not recognised",
|
||||
"address_reserved_not_applied": "{{ip}} reserved but not applied: {{error}}",
|
||||
"address_mode": "Address",
|
||||
"next_free": "Next free",
|
||||
"manual_ip": "Enter manually",
|
||||
"target_interface": "Guest interface",
|
||||
"any_pool": "Any",
|
||||
"pool": "Pool",
|
||||
"err_network_required": "Select a network",
|
||||
"err_bad_ip": "Invalid IP address",
|
||||
"extra_addresses": "+{{count}} more"
|
||||
},
|
||||
"logs": {
|
||||
"level": "Level",
|
||||
@@ -1689,7 +1737,10 @@
|
||||
"server": "Server",
|
||||
"node": "Node",
|
||||
"started": "Started",
|
||||
"progress": "Progress"
|
||||
"progress": "Progress",
|
||||
"active_count_one": "{{count}} active task",
|
||||
"active_count_other": "{{count}} active tasks",
|
||||
"no_active": "No active tasks"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -1717,7 +1768,28 @@
|
||||
"connected": "Connected",
|
||||
"error": "Error",
|
||||
"terminal_closed": "Terminal connection closed",
|
||||
"terminal_error": "Terminal connection error"
|
||||
"terminal_error": "Terminal connection error",
|
||||
"power": "Power",
|
||||
"reboot": "Reboot",
|
||||
"shutdown": "Shutdown",
|
||||
"hard_reset": "Hard Reset",
|
||||
"hard_stop": "Hard Stop",
|
||||
"confirm_hard_reset": "Hard reset will forcibly reboot the VM without a graceful OS shutdown. Continue?",
|
||||
"confirm_hard_stop": "Hard stop will forcibly kill the VM process without a graceful shutdown. Continue?",
|
||||
"action_sent": "Command sent",
|
||||
"action_failed": "Failed to send command",
|
||||
"extra_keys": "Extra keys",
|
||||
"ctrl_alt_del": "Ctrl+Alt+Del",
|
||||
"ctrl_alt_backspace": "Ctrl+Alt+Backspace",
|
||||
"scale_to_fit": "Scale to fit",
|
||||
"actual_size": "Actual size",
|
||||
"clipboard": "Clipboard",
|
||||
"clipboard_title": "Send text to VM clipboard",
|
||||
"clipboard_desc": "Text will be sent to the guest clipboard via VNC.",
|
||||
"clipboard_placeholder": "Type or paste text to send...",
|
||||
"send": "Send",
|
||||
"screenshot": "Screenshot",
|
||||
"screenshot_failed": "Failed to capture screenshot"
|
||||
},
|
||||
"images": {
|
||||
"title": "Images",
|
||||
@@ -1894,5 +1966,70 @@
|
||||
"notafter": "Valid until",
|
||||
"pubkey": "Key",
|
||||
"fingerprint": "Fingerprint"
|
||||
},
|
||||
"topology": {
|
||||
"title": "Network topology",
|
||||
"subtitle": "Hierarchical map of clusters, nodes and guests",
|
||||
"empty": "No servers available",
|
||||
"error": "Failed to load topology",
|
||||
"too_many": "Too many objects; grouping is enabled",
|
||||
"filters": {
|
||||
"connection": "Connection",
|
||||
"all_connections": "All connections",
|
||||
"vm_status": "VM status",
|
||||
"status_all": "All",
|
||||
"status_running": "Running",
|
||||
"status_stopped": "Stopped",
|
||||
"threshold": "Group VMs above",
|
||||
"threshold_off": "Off"
|
||||
},
|
||||
"actions": {
|
||||
"fit": "Fit to screen",
|
||||
"fullscreen": "Fullscreen",
|
||||
"exit_fullscreen": "Exit fullscreen",
|
||||
"export": "Export",
|
||||
"export_png": "Export PNG",
|
||||
"export_svg": "Export SVG",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"export": {
|
||||
"progress": "Rendering image…",
|
||||
"done": "Image saved",
|
||||
"failed": "Export failed",
|
||||
"svg_hint": "SVG embeds HTML; some vector editors render it only partially"
|
||||
},
|
||||
"legend": {
|
||||
"title": "Legend",
|
||||
"panel": "Panel",
|
||||
"cluster": "Cluster",
|
||||
"standalone": "Standalone",
|
||||
"node": "Proxmox node",
|
||||
"vm": "Virtual machine",
|
||||
"lxc": "Container",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"stale": "Cached data"
|
||||
},
|
||||
"node": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"clusters_count_one": "{{count}} cluster",
|
||||
"clusters_count_other": "{{count}} clusters",
|
||||
"nodes_count_one": "{{count}} node",
|
||||
"nodes_count_other": "{{count}} nodes",
|
||||
"guests_count_one": "{{count}} guest",
|
||||
"guests_count_other": "{{count}} guests"
|
||||
},
|
||||
"group": {
|
||||
"count": "{{count}} VMs",
|
||||
"count_one": "{{count}} VM",
|
||||
"expand": "Show all",
|
||||
"collapse": "Collapse",
|
||||
"breakdown": "{{running}} running · {{stopped}} stopped"
|
||||
},
|
||||
"warning": {
|
||||
"offline": "{{server}} is unreachable — showing cached data",
|
||||
"error": "{{server}}: {{message}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"my apps": "Мои приложения",
|
||||
"search": "Поиск разделов",
|
||||
"searchPlaceholder": "Перейти к разделу...",
|
||||
"searchEmpty": "Ничего не найдено"
|
||||
"searchEmpty": "Ничего не найдено",
|
||||
"topology": "Топология"
|
||||
},
|
||||
"appstore": {
|
||||
"title": "Каталог приложений",
|
||||
@@ -311,7 +312,6 @@
|
||||
"queued": "В очереди...",
|
||||
"no_data": "Нет данных",
|
||||
"confirm_delete": "Вы уверены, что хотите удалить?",
|
||||
"no_data": "Нет данных",
|
||||
"all": "Все",
|
||||
"date": "Дата",
|
||||
"user": "Пользователь",
|
||||
@@ -490,6 +490,15 @@
|
||||
"disk_move": "Переместить диск",
|
||||
"disk_move_delete": "Удалить исходный после перемещения",
|
||||
"disk_moved": "Перемещение диска запущено",
|
||||
"disk_move_stop_hint": "Контейнер запущен — перед переносом его нужно остановить. Мастер сделает это автоматически и запустит обратно после переноса.",
|
||||
"disk_move_confirm_title": "Переместить диск?",
|
||||
"disk_move_confirm_plain": "Диск {{dev}} будет перемещён на хранилище {{storage}}.",
|
||||
"disk_move_confirm_running": "Контейнер сейчас запущен. Диск {{dev}} можно перенести на {{storage}} только на остановленном контейнере. Он будет остановлен, диск перемещён, после чего контейнер запустится снова.",
|
||||
"disk_move_stage_stopping": "Останавливаем контейнер...",
|
||||
"disk_move_stage_moving": "Переносим диск...",
|
||||
"disk_move_stage_starting": "Запускаем контейнер обратно...",
|
||||
"disk_move_stop_and_move": "Остановить и перенести",
|
||||
"disk_move_stop_timeout": "Не дождались остановки контейнера — перенос отменён",
|
||||
"disk_add": "Добавить диск",
|
||||
"disk_added": "Диск {{dev}} добавлен",
|
||||
"disk_bus": "Шина",
|
||||
@@ -754,7 +763,8 @@
|
||||
"quota_exceeded_hint": "Уменьшите ресурсы выше, чтобы продолжить.",
|
||||
"quota_used_of": "{{used}} из {{limit}}",
|
||||
"quota_projection_note": "С учётом создаваемого инстанса.",
|
||||
"ip_cidr_placeholder": "10.10.10.5/24 или dhcp"
|
||||
"ip_cidr_placeholder": "10.10.10.5/24 или dhcp",
|
||||
"invalid_name": "Имя должно быть валидным DNS-именем: начинаться с буквы, содержать только латинские буквы, цифры и дефис, заканчиваться буквой или цифрой (например, my-app-01)"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Серверы",
|
||||
@@ -1218,6 +1228,9 @@
|
||||
"path": "Путь",
|
||||
"server": "Сервер",
|
||||
"export": "Экспорт",
|
||||
"scan_exports": "Найти экспорты на сервере",
|
||||
"server_required": "Сначала укажите адрес сервера",
|
||||
"no_exports_found": "Экспорты не найдены — проверьте /etc/exports на сервере",
|
||||
"share": "Шара",
|
||||
"datastore": "Datastore",
|
||||
"username": "Пользователь",
|
||||
@@ -1388,6 +1401,7 @@
|
||||
"no_unlinked": "Все VM связаны",
|
||||
"link_allocations": "Сканировать и связать",
|
||||
"linked": "Связано",
|
||||
"link_skipped": "Пропущено: {{count}}",
|
||||
"ip_history": "История по IP",
|
||||
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
|
||||
"resource_required": "Укажите ресурс перед выделением IP.",
|
||||
@@ -1452,7 +1466,40 @@
|
||||
"allocation_created": "Аллокация создана",
|
||||
"allocation_updated": "Аллокация обновлена",
|
||||
"delete_allocation_confirm": "Освободить IP {{ip}}?",
|
||||
"notes": "Заметки"
|
||||
"notes": "Заметки",
|
||||
"addresses": "IP-адреса",
|
||||
"add_address": "Добавить IP",
|
||||
"add_address_title": "Добавить IP-адрес",
|
||||
"add_address_description": "Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).",
|
||||
"add_address_submit": "Выдать адрес",
|
||||
"no_addresses": "Адресов в IPAM нет",
|
||||
"primary": "основной",
|
||||
"make_primary": "Сделать основным адресом",
|
||||
"primary_managed_by_nic": "Основной адрес задаётся конфигом интерфейса",
|
||||
"from_nic_config": "из конфига NIC",
|
||||
"applied": "применён",
|
||||
"not_applied": "не применён",
|
||||
"runtime_only": "до перезагрузки",
|
||||
"runtime_only_hint": "Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет",
|
||||
"discovered": "обнаружен",
|
||||
"apply": "Применить",
|
||||
"release": "Освободить",
|
||||
"release_title": "Освободить адрес?",
|
||||
"release_description": "Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.",
|
||||
"address_added": "Адрес {{ip}} назначен",
|
||||
"address_released": "Адрес {{ip}} освобождён",
|
||||
"address_released_not_removed": "{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}",
|
||||
"address_runtime_only": "{{ip}} поднят, но не закреплён: стек сети гостя не распознан",
|
||||
"address_reserved_not_applied": "{{ip}} зарезервирован, но не применён: {{error}}",
|
||||
"address_mode": "Адрес",
|
||||
"next_free": "Следующий свободный",
|
||||
"manual_ip": "Указать вручную",
|
||||
"target_interface": "Интерфейс гостя",
|
||||
"any_pool": "Любой",
|
||||
"pool": "Пул",
|
||||
"err_network_required": "Выберите сеть",
|
||||
"err_bad_ip": "Некорректный IP-адрес",
|
||||
"extra_addresses": "ещё {{count}}"
|
||||
},
|
||||
"logs": {
|
||||
"level": "Уровень",
|
||||
@@ -1674,7 +1721,11 @@
|
||||
"server": "Сервер",
|
||||
"node": "Узел",
|
||||
"started": "Запущено",
|
||||
"progress": "Прогресс"
|
||||
"progress": "Прогресс",
|
||||
"active_count_one": "{{count}} активная задача",
|
||||
"active_count_few": "{{count}} активные задачи",
|
||||
"active_count_many": "{{count}} активных задач",
|
||||
"no_active": "Нет активных задач"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Уведомления",
|
||||
@@ -1702,7 +1753,28 @@
|
||||
"connected": "Подключено",
|
||||
"error": "Ошибка",
|
||||
"terminal_closed": "Соединение с терминалом закрыто",
|
||||
"terminal_error": "Ошибка соединения с терминалом"
|
||||
"terminal_error": "Ошибка соединения с терминалом",
|
||||
"power": "Питание",
|
||||
"reboot": "Перезагрузить",
|
||||
"shutdown": "Выключить",
|
||||
"hard_reset": "Жёсткий сброс",
|
||||
"hard_stop": "Жёсткая остановка",
|
||||
"confirm_hard_reset": "Жёсткий сброс перезагрузит ВМ без корректного завершения работы ОС. Продолжить?",
|
||||
"confirm_hard_stop": "Жёсткая остановка принудительно завершит процесс ВМ без корректного выключения. Продолжить?",
|
||||
"action_sent": "Команда отправлена",
|
||||
"action_failed": "Не удалось отправить команду",
|
||||
"extra_keys": "Доп. клавиши",
|
||||
"ctrl_alt_del": "Ctrl+Alt+Del",
|
||||
"ctrl_alt_backspace": "Ctrl+Alt+Backspace",
|
||||
"scale_to_fit": "Масштабировать под окно",
|
||||
"actual_size": "Реальный размер",
|
||||
"clipboard": "Буфер обмена",
|
||||
"clipboard_title": "Отправить текст в буфер обмена ВМ",
|
||||
"clipboard_desc": "Текст будет отправлен в буфер обмена гостевой ОС через VNC.",
|
||||
"clipboard_placeholder": "Введите или вставьте текст для отправки...",
|
||||
"send": "Отправить",
|
||||
"screenshot": "Скриншот",
|
||||
"screenshot_failed": "Не удалось сделать скриншот"
|
||||
},
|
||||
"perms": {
|
||||
"role": {
|
||||
@@ -2002,5 +2074,75 @@
|
||||
"notafter": "Действителен до",
|
||||
"pubkey": "Ключ",
|
||||
"fingerprint": "Отпечаток"
|
||||
},
|
||||
"topology": {
|
||||
"title": "Топология сети",
|
||||
"subtitle": "Иерархическая карта кластеров, нод и гостей",
|
||||
"empty": "Нет доступных серверов",
|
||||
"error": "Не удалось загрузить топологию",
|
||||
"too_many": "Слишком много объектов — включена группировка",
|
||||
"filters": {
|
||||
"connection": "Подключение",
|
||||
"all_connections": "Все подключения",
|
||||
"vm_status": "Статус VM",
|
||||
"status_all": "Все",
|
||||
"status_running": "Запущенные",
|
||||
"status_stopped": "Остановленные",
|
||||
"threshold": "Группировать VM свыше",
|
||||
"threshold_off": "Выкл"
|
||||
},
|
||||
"actions": {
|
||||
"fit": "Вписать в экран",
|
||||
"fullscreen": "Полный экран",
|
||||
"exit_fullscreen": "Выйти из полного экрана",
|
||||
"export": "Экспорт",
|
||||
"export_png": "Экспорт в PNG",
|
||||
"export_svg": "Экспорт в SVG",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"export": {
|
||||
"progress": "Готовим изображение…",
|
||||
"done": "Изображение сохранено",
|
||||
"failed": "Не удалось экспортировать",
|
||||
"svg_hint": "SVG содержит HTML внутри; некоторые векторные редакторы отрисуют его частично"
|
||||
},
|
||||
"legend": {
|
||||
"title": "Легенда",
|
||||
"panel": "Панель",
|
||||
"cluster": "Кластер",
|
||||
"standalone": "Отдельный сервер",
|
||||
"node": "Нода Proxmox",
|
||||
"vm": "Виртуальная машина",
|
||||
"lxc": "Контейнер",
|
||||
"running": "Запущен",
|
||||
"stopped": "Остановлен",
|
||||
"stale": "Данные из кэша"
|
||||
},
|
||||
"node": {
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"clusters_count_one": "{{count}} кластер",
|
||||
"clusters_count_few": "{{count}} кластера",
|
||||
"clusters_count_many": "{{count}} кластеров",
|
||||
"nodes_count_one": "{{count}} нода",
|
||||
"nodes_count_few": "{{count}} ноды",
|
||||
"nodes_count_many": "{{count}} нод",
|
||||
"guests_count_one": "{{count}} гость",
|
||||
"guests_count_few": "{{count}} гостя",
|
||||
"guests_count_many": "{{count}} гостей"
|
||||
},
|
||||
"group": {
|
||||
"count_one": "{{count}} VM",
|
||||
"count_few": "{{count}} VM",
|
||||
"count_many": "{{count}} VM",
|
||||
"count_other": "{{count}} VM",
|
||||
"expand": "Показать все",
|
||||
"collapse": "Свернуть",
|
||||
"breakdown": "{{running}} запущено · {{stopped}} остановлено"
|
||||
},
|
||||
"warning": {
|
||||
"offline": "{{server}} недоступен — показаны данные из кэша",
|
||||
"error": "{{server}}: {{message}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const TemplatesPage = lazy(() => import('./features/templates/TemplatesPage.tsx'
|
||||
const ImagesPage = lazy(() => import('./features/images/ImagesPage.tsx'));
|
||||
const BackupsPage = lazy(() => import('./features/backups/BackupsPage.tsx'));
|
||||
const TasksPage = lazy(() => import('./features/tasks/TasksPage.tsx'));
|
||||
const TopologyPage = lazy(() => import('./features/topology/TopologyPage.tsx'));
|
||||
const IPAMDashboardPage = lazy(() => import('./features/ipam/IPAMDashboardPage.tsx'));
|
||||
const IPAMNetworksPage = lazy(() => import('./features/ipam/IPAMNetworksPage.tsx'));
|
||||
const IPAMNetworkDetailPage = lazy(() => import('./features/ipam/IPAMNetworkDetailPage.tsx'));
|
||||
@@ -111,6 +112,7 @@ export const router = createBrowserRouter([
|
||||
{ path: '/images', element: <Page perm="template:manage"><ImagesPage /></Page> },
|
||||
{ path: '/backups', element: <Page perm="backup:view"><BackupsPage /></Page> },
|
||||
{ path: '/tasks', element: <Page perm="vm:view"><TasksPage /></Page> },
|
||||
{ path: '/topology', element: <Page perm="network:view"><TopologyPage /></Page> },
|
||||
{ path: '/ipam', element: <Page perm="ipam:view"><IPAMDashboardPage /></Page> },
|
||||
{ path: '/ipam/networks', element: <Page perm="ipam:view"><IPAMNetworksPage /></Page> },
|
||||
{ path: '/ipam/network/:id', element: <Page perm="ipam:view"><IPAMNetworkDetailPage /></Page> },
|
||||
|
||||
@@ -116,6 +116,14 @@ export interface VMInstance {
|
||||
netin_rate?: number;
|
||||
netout_rate?: number;
|
||||
ip_address?: string;
|
||||
/** Все адреса гостя; ip_address — основной из этого списка */
|
||||
ips?: Array<{
|
||||
ip: string;
|
||||
is_primary: boolean;
|
||||
assignment_kind?: 'primary' | 'alias';
|
||||
target_interface?: string | null;
|
||||
apply_status?: GuestAddressApplyStatus | null;
|
||||
}>;
|
||||
tags?: string;
|
||||
template?: boolean;
|
||||
description?: string;
|
||||
@@ -397,10 +405,41 @@ export interface IPAMAllocation {
|
||||
fqdn?: string;
|
||||
allocation_type?: string;
|
||||
notes?: string;
|
||||
/** Основной адрес гостя — он попадает в колонку IP списка инстансов */
|
||||
is_primary?: boolean;
|
||||
/** primary — задан конфигом NIC, alias — навешен внутри гостя */
|
||||
assignment_kind?: 'primary' | 'alias';
|
||||
target_interface?: string | null;
|
||||
apply_status?: GuestAddressApplyStatus | null;
|
||||
apply_error?: string | null;
|
||||
applied_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Состояние применения alias-адреса к живому гостю */
|
||||
export type GuestAddressApplyStatus = 'applied' | 'runtime_only' | 'pending' | 'failed';
|
||||
|
||||
/** Один адрес инстанса на вкладке «Сеть» */
|
||||
export interface GuestAddress {
|
||||
id: number;
|
||||
ip_address: string;
|
||||
prefix?: string | null;
|
||||
network_id: number;
|
||||
network_name?: string | null;
|
||||
gateway?: string | null;
|
||||
mac_address?: string | null;
|
||||
status: string;
|
||||
is_primary: boolean;
|
||||
assignment_kind: 'primary' | 'alias';
|
||||
target_interface?: string | null;
|
||||
apply_status?: GuestAddressApplyStatus | null;
|
||||
apply_error?: string | null;
|
||||
applied_at?: string | null;
|
||||
hostname?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
// ==================== Template Types ====================
|
||||
|
||||
export interface OSTemplateGroup {
|
||||
|
||||
5
init.sql
5
init.sql
@@ -507,7 +507,6 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
|
||||
"template:view": true,
|
||||
"storage:view": true,
|
||||
"backup:view": true, "backup:create": true,
|
||||
"ipam:view": true,
|
||||
"user:view": true,
|
||||
"log:view": true, "log:export": true,
|
||||
"setting:view": true,
|
||||
@@ -523,7 +522,6 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
|
||||
"lxc:view": true, "lxc:start": true, "lxc:stop": true, "lxc:restart": true, "lxc:console": true,
|
||||
"template:view": true,
|
||||
"storage:view": true,
|
||||
"ipam:view": true,
|
||||
"notification:view": true, "notification:manage": true
|
||||
}', TRUE, TRUE),
|
||||
('demo', 'Demo User', 'Read-only access for demonstration', '{
|
||||
@@ -532,8 +530,7 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
|
||||
"vm:view": true,
|
||||
"lxc:view": true,
|
||||
"template:view": true,
|
||||
"storage:view": true,
|
||||
"ipam:view": true
|
||||
"storage:view": true
|
||||
}', TRUE, TRUE)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
|
||||
@@ -63,6 +63,16 @@ IMAGES=(
|
||||
"rocky-9-cloud|rocky|9|Rocky Linux 9 GenericCloud + QGA|arm64|https://download.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2"
|
||||
"alma-9-cloud|almalinux|9|AlmaLinux 9 GenericCloud + QGA|amd64|https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2"
|
||||
"alma-9-cloud|almalinux|9|AlmaLinux 9 GenericCloud + QGA|arm64|https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2"
|
||||
"alma-10-cloud|almalinux|10|AlmaLinux 10 GenericCloud + QGA|amd64|https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2"
|
||||
"alma-10-cloud|almalinux|10|AlmaLinux 10 GenericCloud + QGA|arm64|https://repo.almalinux.org/almalinux/10/cloud/aarch64/images/AlmaLinux-10-GenericCloud-latest.aarch64.qcow2"
|
||||
"centos-stream-9-cloud|centos|9-stream|CentOS Stream 9 GenericCloud + QGA|amd64|https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2"
|
||||
"centos-stream-9-cloud|centos|9-stream|CentOS Stream 9 GenericCloud + QGA|arm64|https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2"
|
||||
"debian-13-cloud|debian|13|Debian 13 (Trixie) GenericCloud + QGA|amd64|https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
|
||||
"debian-13-cloud|debian|13|Debian 13 (Trixie) GenericCloud + QGA|arm64|https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2"
|
||||
"fedora-41-cloud|fedora|41|Fedora 41 Cloud + QGA|amd64|https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2"
|
||||
"fedora-41-cloud|fedora|41|Fedora 41 Cloud + QGA|arm64|https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-41-1.4.aarch64.qcow2"
|
||||
"ubuntu-26.04-cloud|ubuntu|26.04|Ubuntu 26.04 LTS (Resolute) Cloud + QGA|amd64|https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
|
||||
"ubuntu-26.04-cloud|ubuntu|26.04|Ubuntu 26.04 LTS (Resolute) Cloud + QGA|arm64|https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img"
|
||||
)
|
||||
|
||||
# ── Определение пакетного менеджера и sudo ─────────────────────────────────────
|
||||
|
||||
48
sonar-project.properties
Normal file
48
sonar-project.properties
Normal file
@@ -0,0 +1,48 @@
|
||||
# SonarCloud project configuration for pvemanager (monorepo)
|
||||
# Place this file at the repository root.
|
||||
|
||||
sonar.organization=markmorado
|
||||
sonar.projectKey=markmorado_pvemanager
|
||||
sonar.projectName=PVEmanager
|
||||
|
||||
# --- Sources ---
|
||||
# Backend: FastAPI app under backend/app
|
||||
# Frontend: React/TS app under frontend/src
|
||||
sonar.sources=backend/app,frontend/src
|
||||
|
||||
# Tests are scanned separately so they show up as "Tests", not "Code"
|
||||
sonar.tests=backend/tests
|
||||
|
||||
# --- Exclusions (things we never want analyzed) ---
|
||||
sonar.exclusions=\
|
||||
**/node_modules/**,\
|
||||
**/dist/**,\
|
||||
**/build/**,\
|
||||
backend/alembic/versions/**,\
|
||||
backend/migrations/**,\
|
||||
frontend/public/**,\
|
||||
**/*.min.js,\
|
||||
**/locales/**,\
|
||||
**/static/**
|
||||
|
||||
# Don't compute duplication/complexity noise on generated or vendored files
|
||||
sonar.coverage.exclusions=\
|
||||
backend/alembic/**,\
|
||||
backend/migrations/**,\
|
||||
backend/tests/**,\
|
||||
frontend/src/**/*.test.ts,\
|
||||
frontend/src/**/*.test.tsx,\
|
||||
**/*.d.ts
|
||||
|
||||
# --- Python (backend) ---
|
||||
sonar.python.version=3.12
|
||||
# Matches the coverage.xml already produced by backend-tests.yml
|
||||
sonar.python.coverage.reportPaths=backend/coverage.xml
|
||||
|
||||
# --- TypeScript/JavaScript (frontend) ---
|
||||
# If you later add vitest/jest with lcov coverage, point this at the report:
|
||||
# sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info
|
||||
sonar.eslint.reportPaths=frontend/eslint-report.json
|
||||
|
||||
# --- Source encoding ---
|
||||
sonar.sourceEncoding=UTF-8
|
||||
Reference in New Issue
Block a user