23 Commits

Author SHA1 Message Date
markmorado
c768da93ee docs: release notes and version bump for v1.17.0
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m27s
2026-08-20 05:46:47 +05:00
markmorado
f0ae1770f7 fix: drop net in/out cards from instance overview I/O rates
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m29s
Network throughput is already shown in the collapsible instance list
metrics panel; keep the overview tab's I/O rates section to disk only.
2026-08-20 05:43:23 +05:00
markmorado
5a4dfcdaea docs: add changelog entries for LXC disk move and metrics panel collapse 2026-08-20 05:43:08 +05:00
markmorado
851e5854b2 feat: add confirmation dialog for LXC disk move with stop/start flow
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m23s
Deploy to Production / Trigger production update (push) Failing after 9s
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Proxmox rejects lxc move_volume on a running container. Instead of
just disabling the button, show a modal that explains the container
will be stopped, walks through stop -> move -> start with progress,
and always restarts the container even if the move itself fails.
For VMs (and already-stopped containers) it's a plain confirm modal.
2026-08-20 05:37:10 +05:00
markmorado
06bb127387 fix: block LXC disk move on running containers
Proxmox rejects lxc move_volume on a running container
("cannot move volumes of a running container"), unlike qemu
move_disk which works live. Surface this as a disabled button
and hint instead of a raw 500 error.
2026-08-20 05:32:22 +05:00
markmorado
943207b6b7 fix: support moving LXC container disks to other storage
Disk move was hardcoded to the QEMU move_disk Proxmox API and was
never reachable in the UI for LXC (no Hardware tab for containers,
and the disk list regex only matched VM device names).

- add move_container_disk() using lxc/{vmid}/move_volume
- add POST /api/{server_id}/container/{vmid}/disk/move endpoint
- route useMoveDisk by instance type (vm vs container prefix)
- recognize rootfs/mp\d+ as LXC disk devices
- add a Move disk card on the Settings tab for LXC instances
- filter target storage list by supported content type
  (rootdir for LXC, images for VM) instead of showing all storages
2026-08-20 05:29:28 +05:00
markmorado
619d08b674 style: widen and enlarge vCPU/RAM bars in instance metrics panel
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
SonarCloud / SonarCloud Analysis (push) Failing after 2m23s
Increase the progress bar width and label/detail font size for the
vCPU/RAM rows so they're easier to read at a glance, matching feedback
on the collapsed metrics panel added earlier.
2026-08-20 05:28:24 +05:00
markmorado
3aa37991f7 feat: collapse instance list metrics into expandable panel
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
SonarCloud / SonarCloud Analysis (push) Failing after 2m23s
Move CPU/RAM/Disk/Net/Disk I/O/Uptime out of the always-visible table
columns into a per-row expandable panel (opened by clicking the
instance name), matching VMmanager's disclosure UX. Adds a Parameters
link into the panel pointing to the instance settings tab, and
clearer stat cards for network/disk throughput.
2026-08-20 05:17:23 +05:00
markmorado
917cc0a5bb fix: correct topology export bounds, edge rendering, and switch to top-down layout
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m22s
- Export: use useReactFlow().getNodesBounds() (measured dimensions) instead
  of the standalone helper, which treated every node's size as zero and
  clipped the last row/column of the exported image
- Export: give edge paths an explicit inline stroke before capture -
  html-to-image clones <svg> natively and skips computed-style inlining for
  it, so edges came out with no stroke at all
- Enable the edge flow animation (was hardcoded off)
- Switch the graph from left-to-right to top-to-bottom, including flipping
  every node's handle positions, and recolor edges from the barely-visible
  border tone to the primary accent
2026-08-20 04:50:41 +05:00
markmorado
da8bbce62a fix: console viewport sizing and add VNC control toolbar
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m22s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m30s
Fix nested h-screen flexbox layout bug that let the noVNC canvas overflow
below the viewport until the browser zoom level was changed (missing
min-h-0 on flex children). Also add a fuller console control bar: power
menu (reboot/shutdown/hard reset/hard stop), extra-keys menu (Ctrl+Alt+Del,
Ctrl+Alt+Backspace, Tab, Esc, Ctrl+Alt+F1-F12), scale toggle, clipboard
paste to guest, and console screenshot. Adds a VM hard-reset backend
action (qemu status/reset) alongside the existing start/stop/restart/shutdown.
2026-08-20 02:04:15 +05:00
markmorado
e7291bf23a fix: pass export padding as a px string, not a bare number
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 12s
SonarCloud / SonarCloud Analysis (push) Failing after 2m43s
getViewportForBounds treats a numeric padding as a fraction of the
canvas size (not pixels) unless given a "Npx" string, so the padding=40
passed in the previous fix was read as 4000% padding and still
collapsed the zoom to its floor - the graph rendered small on a mostly
empty canvas exactly as before. Verified in-browser: PNG/SVG exports
now fill ~98% of the canvas instead of ~20%.
2026-08-20 01:55:12 +05:00
markmorado
932fcce768 fix: size topology export canvas to the graph, not a fixed minimum
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m31s
Forcing a 1200x800 minimum canvas made getViewportForBounds compute a
zoom above the 2x cap for small graphs, leaving the exported PNG/SVG
mostly empty with the graph rendered tiny in the corner.
2026-08-20 01:41:32 +05:00
markmorado
d99e67a60e feat: add network topology page
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m22s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m22s
Interactive infrastructure map (panel -> cluster -> node -> guest) rendered
with React Flow, backed by a single aggregated /api/network/topology endpoint
instead of fanning out over servers/nodes/SDN/VMs. Adds a netN NIC parser for
QEMU/LXC guest configs, status/threshold filters, canvas tools, and PNG/SVG
export.
2026-08-20 01:31:37 +05:00
f6d9932849 fix: show only friendly template name in CT template dropdown
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 7m59s
The CT template select listed both the raw archive file name and the
headline on one line, which was long and hard to scan. Render the
headline alone and keep the file name in the title tooltip, matching
the convention already used on the images page. Falls back to the file
name when the repository provides no headline.
2026-08-19 18:23:05 +05:00
0db2f340f6 fix: cap dialog grid column so fields cannot overflow the modal
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m20s
The dialog body is a grid whose implicit column is auto-sized, i.e.
max-content. Long unbreakable option labels (e.g. CT template file
names) widened that column and pushed the select fields and footer
buttons past the dialog edge.

Use grid-cols-[minmax(0,1fr)] on DialogContent to cap the track at the
dialog width, and apply the same guard to the Alert grid template.
Verified: dialog scrollWidth now equals clientWidth at both 1280px and
375px viewports, with no horizontal page overflow.
2026-08-19 17:36:20 +05:00
5bb349564f fix: prevent select fields from overflowing dialog width
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m21s
Add min-w-0 and overflow-hidden to SelectTrigger and min-w-0 to
SelectValue so long option labels (e.g. CT template names) and grid
columns no longer push select fields past the modal boundary. Fixes
overflow across all dialogs sharing the Select component.
2026-08-19 17:23:24 +05:00
67fe01e9b1 fix: show friendly OS name for LXC deployed from CT template
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m26s
Deploy to Production / Trigger production update (push) Failing after 12s
SonarCloud / SonarCloud Analysis (push) Failing after 12m14s
Derive a human-readable OS name (e.g. Debian 13, Ubuntu 24.04) from the
vztmpl volid instead of showing the raw template path like
local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst in the instances list.
Applied at the display layer, so existing instances are fixed too.
2026-08-19 15:30:34 +05:00
Dilshod Rasulov
3225214e43 Add files via upload 2026-08-18 23:59:21 +05:00
Dilshod Rasulov
e4c921022b Add files via upload 2026-08-18 23:59:05 +05:00
markmorado
73493d7ef2 Add active tasks indicator, propagate LXC create errors, fix Fedora logo
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m24s
Deploy to Production / Trigger production update (push) Failing after 10s
- Surface a topbar indicator with a live count of active tasks
- Propagate exceptions from Proxmox LXC container creation instead of
  swallowing them, and validate minimum password length
- Replace the Fedora catalog icon with the official logo artwork
2026-08-18 23:34:32 +05:00
markmorado
9d57e388cd feat: add 5 new cloud images to built-in catalog
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m28s
Deploy to Production / Trigger production update (push) Failing after 1m37s
Add AlmaLinux 10, CentOS Stream 9, Debian 13 (Trixie), Fedora 41, and
Ubuntu 26.04 LTS (Resolute) with pre-installed qemu-guest-agent. Images
patched via virt-customize and hosted on GitHub release 2026-08-18.
2026-08-18 23:11:13 +05:00
markmorado
5f0a38e03d fix: improve templates page responsiveness for long names and small screens
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
Prevent edit/delete action buttons from overlapping with long template
names by using shrink-0 on icon and button containers with flex-1
min-w-0 on the text area. Make header and filter selects responsive
with flex-wrap and full-width on mobile breakpoints.
2026-08-18 20:28:08 +05:00
8cbba05dac fix: validate instance name as DNS label and improve wizard UX
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m23s
Deploy to Production / Trigger production update (push) Failing after 10s
Add DNS name validation (RFC 952/1123) for VM, LXC and ISO instance
names on both backend and frontend. Proxmox rejects names with
underscores or special characters but the error was only surfaced as
a raw 400 from the API.

Also require Cloud-Init user and password fields for VM deploys
(highlighted when empty, Next button blocked), and fix numeric input
fields that showed "0" on clear and concatenated values like "04096".
2026-08-17 16:24:55 +05:00
61 changed files with 3876 additions and 283 deletions

66
.github/workflows/sonarcloud.yml vendored Normal file
View 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 }}

View File

@@ -4,6 +4,29 @@ All notable changes to PVEmanager will be documented in this file.
---
## [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

View File

@@ -4,6 +4,29 @@
---
## [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
### 🎨 Интерфейс

View File

@@ -4,7 +4,7 @@
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
[![Version](https://img.shields.io/badge/version-1.16.1-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.17.0-blue?style=flat-square)](CHANGELOG.md)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)

View File

@@ -4,7 +4,7 @@
**Современная веб-панель для управления серверами Proxmox, виртуальными машинами и контейнерами LXC**
[![Версия](https://img.shields.io/badge/версия-1.16.1-blue?style=flat-square)](CHANGELOG.md)
[![Версия](https://img.shields.io/badge/версия-1.17.0-blue?style=flat-square)](CHANGELOG.md)
[![Лицензия](https://img.shields.io/badge/лицензия-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-готов-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)

View File

@@ -1 +1 @@
1.16.1
1.17.0

View File

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

View File

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

View File

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

View File

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

View 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,
)

View File

@@ -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()
@@ -283,8 +283,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()
@@ -1923,6 +1930,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,
@@ -3942,16 +3989,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 +4041,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 +4051,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 +4070,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(

View File

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

View File

@@ -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 создания контейнера")

View File

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

View File

@@ -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',
]

View File

@@ -1541,6 +1541,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
@@ -2093,7 +2121,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,

View File

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

View 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

View File

@@ -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."""
@@ -837,3 +861,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] = []

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View 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 }

View File

@@ -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,14 @@ interface VNCData {
auth_ticket?: string;
}
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,18 +88,25 @@ 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);
// ==================== VNC Console (QEMU) ====================
const connectVNC = useCallback(async () => {
@@ -92,7 +157,7 @@ export default function ConsolePage() {
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.resizeSession = false;
rfb.addEventListener('connect', () => {
setStatus('connected');
@@ -108,7 +173,8 @@ export default function ConsolePage() {
}
});
rfbRef.current = rfb;
rfbRef.current = rfb as RFBHandle;
setScaleToFit(true);
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
@@ -253,13 +319,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 +421,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 +529,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">
@@ -340,8 +590,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>

View File

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

View File

@@ -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>
);
},
},
@@ -905,124 +1086,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 +1374,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>

View File

@@ -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('');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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));
}

View 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();
}

View 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);
}

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

View 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);

View 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);

View File

@@ -0,0 +1,54 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
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 (
<div
className={cn(
'w-[250px] rounded-md border bg-card px-2.5 py-2 shadow-sm',
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" />
</div>
);
}
export const GuestNode = memo(GuestNodeImpl);

View 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);

View 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);

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

View File

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

View File

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

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

View File

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

View File

@@ -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' },
],

View File

@@ -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",
@@ -1689,7 +1700,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 +1731,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 +1929,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}}"
}
}
}

View File

@@ -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": "Серверы",
@@ -1674,7 +1684,11 @@
"server": "Сервер",
"node": "Узел",
"started": "Запущено",
"progress": "Прогресс"
"progress": "Прогресс",
"active_count_one": "{{count}} активная задача",
"active_count_few": "{{count}} активные задачи",
"active_count_many": "{{count}} активных задач",
"no_active": "Нет активных задач"
},
"notifications": {
"title": "Уведомления",
@@ -1702,7 +1716,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 +2037,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}}"
}
}
}

View File

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

View File

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