Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c768da93ee | ||
|
|
f0ae1770f7 | ||
|
|
5a4dfcdaea | ||
|
|
851e5854b2 | ||
|
|
06bb127387 | ||
|
|
943207b6b7 | ||
|
|
619d08b674 | ||
|
|
3aa37991f7 | ||
|
|
917cc0a5bb | ||
|
|
da8bbce62a | ||
|
|
e7291bf23a | ||
|
|
932fcce768 | ||
|
|
d99e67a60e | ||
| f6d9932849 | |||
| 0db2f340f6 | |||
| 5bb349564f | |||
| 67fe01e9b1 | |||
|
|
3225214e43 | ||
|
|
e4c921022b | ||
|
|
73493d7ef2 | ||
|
|
9d57e388cd | ||
|
|
5f0a38e03d | ||
| 8cbba05dac |
66
.github/workflows/sonarcloud.yml
vendored
Normal file
66
.github/workflows/sonarcloud.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
name: SonarCloud Analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# SonarCloud needs full history for accurate blame/new-code detection
|
||||
fetch-depth: 0
|
||||
|
||||
# --- Backend: generate coverage.xml so Sonar can report Python coverage ---
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-dev.txt
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run pytest with coverage
|
||||
working-directory: backend
|
||||
run: |
|
||||
python -m pytest \
|
||||
--cov=app \
|
||||
--cov-report=xml \
|
||||
-p no:warnings
|
||||
# Don't fail the whole Sonar run just because a test flaked;
|
||||
# Sonar will simply show stale/no coverage for this run instead.
|
||||
continue-on-error: true
|
||||
|
||||
# --- Frontend: optional ESLint report for Sonar's JS/TS rules ---
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint (JSON report for Sonar)
|
||||
working-directory: frontend
|
||||
run: npx eslint . --format json --output-file eslint-report.json
|
||||
continue-on-error: true
|
||||
|
||||
# --- SonarCloud scan ---
|
||||
- name: SonarCloud Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 🎨 Интерфейс
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Современная веб-панель для управления серверами Proxmox, виртуальными машинами и контейнерами LXC**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
|
||||
@@ -15,6 +15,7 @@ from .pools import router as pools_router
|
||||
from .disks import router as disks_router
|
||||
from .access import router as access_router
|
||||
from .node_admin import router as node_admin_router
|
||||
from .topology import router as topology_router
|
||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
|
||||
router = APIRouter()
|
||||
@@ -34,3 +35,4 @@ router.include_router(pools_router)
|
||||
router.include_router(disks_router)
|
||||
router.include_router(access_router)
|
||||
router.include_router(node_admin_router)
|
||||
router.include_router(topology_router)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
from typing import List, Optional
|
||||
import re
|
||||
import ssl
|
||||
import asyncio
|
||||
import httpx
|
||||
@@ -18,6 +19,76 @@ from ...logging_service import LoggingService
|
||||
from ...ipam_service import IPAMService
|
||||
|
||||
|
||||
# ==================== OS name derivation ====================
|
||||
|
||||
# Known LXC distro slugs → human-readable display name.
|
||||
_LXC_DISTRO_NAMES = {
|
||||
"debian": "Debian",
|
||||
"ubuntu": "Ubuntu",
|
||||
"alpine": "Alpine",
|
||||
"fedora": "Fedora",
|
||||
"centos": "CentOS",
|
||||
"rockylinux": "Rocky Linux",
|
||||
"rocky": "Rocky Linux",
|
||||
"almalinux": "AlmaLinux",
|
||||
"alma": "AlmaLinux",
|
||||
"archlinux": "Arch Linux",
|
||||
"arch": "Arch Linux",
|
||||
"opensuse": "openSUSE",
|
||||
"gentoo": "Gentoo",
|
||||
"devuan": "Devuan",
|
||||
"nixos": "NixOS",
|
||||
"oracle": "Oracle Linux",
|
||||
"oraclelinux": "Oracle Linux",
|
||||
"kali": "Kali Linux",
|
||||
"mint": "Linux Mint",
|
||||
"slackware": "Slackware",
|
||||
"void": "Void Linux",
|
||||
}
|
||||
|
||||
|
||||
def pretty_os_from_template(template_name: Optional[str]) -> Optional[str]:
|
||||
"""Derive a friendly OS name (e.g. "Debian 13", "Ubuntu 24.04") from an
|
||||
LXC CT template volid/filename such as
|
||||
``local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst``.
|
||||
|
||||
Returns ``None`` if the input doesn't look like a CT template path, so the
|
||||
caller can fall back to its previous value.
|
||||
"""
|
||||
if not template_name:
|
||||
return None
|
||||
|
||||
# Only vztmpl-style paths carry a distro/version in the filename. VM clone
|
||||
# templates keep their own descriptive names — leave those untouched.
|
||||
if "vztmpl" not in template_name and "/" not in template_name and ".tar" not in template_name:
|
||||
return None
|
||||
|
||||
# Strip storage prefix and directory: keep just the filename.
|
||||
base = template_name.rsplit("/", 1)[-1]
|
||||
# Head is everything before the first underscore, e.g.
|
||||
# "debian-13-standard", "ubuntu-24.04-standard", "centos-9-stream-default".
|
||||
head = base.split("_", 1)[0]
|
||||
tokens = head.split("-")
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
distro = _LXC_DISTRO_NAMES.get(tokens[0].lower())
|
||||
if not distro:
|
||||
return None
|
||||
|
||||
# First token that looks like a version number (digits, optionally dotted).
|
||||
version = next((t for t in tokens[1:] if re.match(r"^\d+(\.\d+)*$", t)), None)
|
||||
# Preserve edition qualifiers like "stream" (CentOS Stream) when present.
|
||||
edition = "Stream" if "stream" in (t.lower() for t in tokens[1:]) else None
|
||||
|
||||
parts = [distro]
|
||||
if version:
|
||||
parts.append(version)
|
||||
if edition:
|
||||
parts.append(edition)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# ==================== Helper Functions for User Isolation ====================
|
||||
|
||||
def can_view_all_instances(current_user: User) -> bool:
|
||||
|
||||
@@ -316,6 +316,11 @@ async def create_vm_from_iso(
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
if not req.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Укажите имя ВМ")
|
||||
from ...schemas import validate_instance_name
|
||||
try:
|
||||
req.name = validate_instance_name(req.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not req.node:
|
||||
raise HTTPException(status_code=400, detail="Укажите ноду для размещения ВМ")
|
||||
if not req.iso:
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
@@ -53,6 +53,13 @@ class LXCDeployRequest(BaseModel):
|
||||
ipam_pool_id: Optional[int] = None
|
||||
password: Optional[str] = None
|
||||
ssh_keys: Optional[str] = None
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_min_length(cls, v):
|
||||
if v is not None and len(v) < 5:
|
||||
raise ValueError("Пароль должен содержать не менее 5 символов (требование Proxmox)")
|
||||
return v
|
||||
ssh_key_ids: Optional[List[int]] = None
|
||||
owner_id: Optional[int] = None
|
||||
nameserver: Optional[str] = None
|
||||
@@ -225,26 +232,31 @@ def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username:
|
||||
_update_task(task_id, "running", f"Создание LXC контейнера VMID {new_vmid}...", 35,
|
||||
vmid=new_vmid, node=deploy_node)
|
||||
net0_str = f"name=eth0,bridge={req.bridge},{ip_config},firewall=1"
|
||||
upid = client.create_lxc_container(
|
||||
node=deploy_node,
|
||||
vmid=new_vmid,
|
||||
ostemplate=req.ostemplate,
|
||||
hostname=req.name,
|
||||
cores=req.cores,
|
||||
memory=req.memory,
|
||||
swap=req.swap,
|
||||
storage=req.storage,
|
||||
rootfs_size=req.disk,
|
||||
net0=net0_str,
|
||||
nameserver=req.nameserver,
|
||||
searchdomain=req.searchdomain,
|
||||
password=req.password,
|
||||
ssh_public_keys=req.ssh_keys,
|
||||
unprivileged=req.unprivileged,
|
||||
start_after_create=False, # we start manually after config
|
||||
onboot=req.onboot,
|
||||
description=f"Created from {req.ostemplate} by {username}",
|
||||
)
|
||||
try:
|
||||
upid = client.create_lxc_container(
|
||||
node=deploy_node,
|
||||
vmid=new_vmid,
|
||||
ostemplate=req.ostemplate,
|
||||
hostname=req.name,
|
||||
cores=req.cores,
|
||||
memory=req.memory,
|
||||
swap=req.swap,
|
||||
storage=req.storage,
|
||||
rootfs_size=req.disk,
|
||||
net0=net0_str,
|
||||
nameserver=req.nameserver,
|
||||
searchdomain=req.searchdomain,
|
||||
password=req.password,
|
||||
ssh_public_keys=req.ssh_keys,
|
||||
unprivileged=req.unprivileged,
|
||||
start_after_create=False, # we start manually after config
|
||||
onboot=req.onboot,
|
||||
description=f"Created from {req.ostemplate} by {username}",
|
||||
)
|
||||
except Exception as e:
|
||||
_update_task(task_id, "failed", "Ошибка создания контейнера", 35,
|
||||
error=f"Proxmox: {e}")
|
||||
return
|
||||
if not upid:
|
||||
_update_task(task_id, "failed", "Ошибка создания контейнера", 35,
|
||||
error="Proxmox не вернул UPID задачи создания контейнера")
|
||||
@@ -447,6 +459,11 @@ async def deploy_lxc_from_template(
|
||||
raise HTTPException(status_code=400, detail="Необходимо указать ostemplate")
|
||||
if not req.name or len(req.name) < 1:
|
||||
raise HTTPException(status_code=400, detail="Укажите имя контейнера")
|
||||
from ...schemas import validate_instance_name
|
||||
try:
|
||||
req.name = validate_instance_name(req.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Resolve owner / SSH keys
|
||||
is_admin = current_user.has_permission("user:manage") or current_user.is_admin
|
||||
|
||||
486
backend/app/api/proxmox/topology.py
Normal file
486
backend/app/api/proxmox/topology.py
Normal file
@@ -0,0 +1,486 @@
|
||||
"""Aggregated network topology: panel -> cluster -> node -> guest.
|
||||
|
||||
One endpoint feeds the whole /topology page so the frontend makes a single
|
||||
request instead of fanning out over servers/nodes/sdn/vms. Everything the graph
|
||||
draws - plus the bridge/VLAN/MAC data the planned "group by VLAN" view will
|
||||
need - is collected here.
|
||||
|
||||
Kept separate from ``cluster.py``'s ``/api/cluster/topology``, which answers a
|
||||
different question (which servers form which cluster) and is consumed by the
|
||||
Cluster page.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time as time_lib
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
from ...db import get_db
|
||||
from ...models import IPAMAllocation, IPAMNetwork, ProxmoxServer, User, VMInstance
|
||||
from ...proxmox import _run_in_executor, parse_guest_nics
|
||||
from ...schemas import (NetworkTopologyResponse, TopologyBridge, TopologyCluster,
|
||||
TopologyGuest, TopologyNic, TopologyNode,
|
||||
TopologyPanelSummary, TopologyWarning)
|
||||
from ._helpers import _get_proxmox_client, can_view_all_instances, get_visible_server_ids
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Live cluster/resources + node networks, keyed by server id. Short TTL: the
|
||||
# page auto-refreshes every 30s and several users may watch it at once.
|
||||
_topo_cache: Dict[int, Dict[str, Any]] = {}
|
||||
_topo_lock = threading.Lock()
|
||||
TOPO_TTL = 15
|
||||
|
||||
# Guest configs change rarely, and reading them costs one HTTP call per guest.
|
||||
_nic_cache: Dict[Tuple[int, int], Dict[str, Any]] = {}
|
||||
_nic_lock = threading.Lock()
|
||||
NIC_TTL = 300
|
||||
NIC_MAX_WORKERS = 8
|
||||
NIC_MAX_FETCHES = 300 # hard ceiling so a huge cluster cannot hang the request
|
||||
|
||||
|
||||
def _truthy(value: Any) -> bool:
|
||||
"""Proxmox returns flags as 1/0, "1"/"0" or bools depending on the field."""
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# ==================== Live collection (runs off the request thread) ====================
|
||||
|
||||
def _collect_server(server: ProxmoxServer, include_bridges: bool) -> Dict[str, Any]:
|
||||
"""Everything one Proxmox connection can tell us, in as few calls as possible."""
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
# A single /cluster/resources call carries both node and guest metrics.
|
||||
resources = client.get_cluster_resources() or []
|
||||
nodes = [r for r in resources if r.get("type") == "node"]
|
||||
guests = [r for r in resources if r.get("type") in ("qemu", "lxc")]
|
||||
|
||||
bridges: Dict[str, List[Dict[str, Any]]] = {}
|
||||
if include_bridges:
|
||||
# Zones/vnets are cluster-wide; fetch once and filter per node below.
|
||||
try:
|
||||
zones = {z.get("zone"): z for z in (client.get_sdn_zones() or [])}
|
||||
vnets = client.get_sdn_vnets() or []
|
||||
except Exception as exc: # SDN is optional and older PVE lacks it
|
||||
logger.debug(f"SDN unavailable on {server.name}: {exc}")
|
||||
zones, vnets = {}, []
|
||||
|
||||
for node_res in nodes:
|
||||
node_name = node_res.get("node")
|
||||
if not node_name:
|
||||
continue
|
||||
entries: List[Dict[str, Any]] = []
|
||||
try:
|
||||
for iface in client.get_node_networks(node_name) or []:
|
||||
name = iface.get("iface") or iface.get("name")
|
||||
if not name:
|
||||
continue
|
||||
cidr = iface.get("cidr")
|
||||
if not cidr and iface.get("address") and iface.get("netmask"):
|
||||
cidr = "{0}/{1}".format(iface["address"], iface["netmask"])
|
||||
entries.append({
|
||||
"name": name,
|
||||
"type": iface.get("type"),
|
||||
"cidr": cidr,
|
||||
"vlan_aware": _truthy(iface.get("bridge_vlan_aware")),
|
||||
"ports": iface.get("bridge_ports") or iface.get("slaves"),
|
||||
"active": _truthy(iface.get("active")),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.debug(f"Could not read networks of {server.name}/{node_name}: {exc}")
|
||||
|
||||
# SDN vnets are attachable as bridges but never appear in /network.
|
||||
for vnet in vnets:
|
||||
vnet_name = vnet.get("vnet")
|
||||
if not vnet_name:
|
||||
continue
|
||||
zone_name = vnet.get("zone", "")
|
||||
zone_nodes = (zones.get(zone_name, {}).get("nodes") or "").replace(" ", "")
|
||||
if zone_nodes and node_name not in zone_nodes.split(","):
|
||||
continue
|
||||
entries.append({
|
||||
"name": vnet_name,
|
||||
"type": "vnet",
|
||||
"zone": zone_name,
|
||||
"active": True,
|
||||
})
|
||||
bridges[node_name] = entries
|
||||
|
||||
return {"nodes": nodes, "guests": guests, "bridges": bridges}
|
||||
|
||||
|
||||
def _fetch_nics(server: ProxmoxServer, targets: List[Tuple[str, int, str]]) -> Dict[int, List[Dict[str, Any]]]:
|
||||
"""``netN`` cards for the given guests. ``targets`` = [(node, vmid, type)]."""
|
||||
if not targets:
|
||||
return {}
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
def _one(node: str, vmid: int, vm_type: str) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
getter = client.get_vm_config if vm_type == "qemu" else client.get_container_config
|
||||
config = getter(node, vmid) or {}
|
||||
return vmid, [nic.to_dict() for nic in parse_guest_nics(config)]
|
||||
|
||||
result: Dict[int, List[Dict[str, Any]]] = {}
|
||||
with ThreadPoolExecutor(max_workers=min(NIC_MAX_WORKERS, len(targets))) as pool:
|
||||
futures = [pool.submit(_one, *target) for target in targets]
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
vmid, nics = future.result()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Could not read guest config on {server.name}: {exc}")
|
||||
continue
|
||||
result[vmid] = nics
|
||||
return result
|
||||
|
||||
|
||||
# ==================== Helpers ====================
|
||||
|
||||
def _split_tags(raw: Optional[str]) -> List[str]:
|
||||
"""Proxmox stores tags as a ``;``-separated string; the graph wants a list."""
|
||||
if not raw:
|
||||
return []
|
||||
return [t.strip() for t in str(raw).replace(",", ";").split(";") if t.strip()]
|
||||
|
||||
|
||||
def _to_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _empty_response() -> NetworkTopologyResponse:
|
||||
return NetworkTopologyResponse(
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
panel=TopologyPanelSummary(),
|
||||
clusters=[],
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
|
||||
# ==================== Endpoint ====================
|
||||
|
||||
@router.get("/api/network/topology", response_model=NetworkTopologyResponse)
|
||||
async def get_network_topology(
|
||||
request: Request,
|
||||
server_id: Optional[int] = Query(None, description="Limit to a single connection"),
|
||||
include_stopped: bool = Query(True),
|
||||
include_templates: bool = Query(False),
|
||||
include_bridges: bool = Query(True),
|
||||
include_nics: bool = Query(False, description="Read every guest config for bridge/VLAN/MAC - one call per guest"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("network:view")),
|
||||
):
|
||||
"""Infrastructure tree for the topology page.
|
||||
|
||||
Always answers 200 (bar a permission denial): an unreachable server
|
||||
degrades to cached rows plus a warning rather than failing the page.
|
||||
"""
|
||||
warnings: List[TopologyWarning] = []
|
||||
|
||||
# -- Visibility ----------------------------------------------------------
|
||||
# None means "no restriction" and is NOT the same as an empty set.
|
||||
visible_ids = get_visible_server_ids(request, db, current_user)
|
||||
if visible_ids is not None and not visible_ids:
|
||||
return _empty_response()
|
||||
|
||||
servers_q = db.query(ProxmoxServer)
|
||||
if visible_ids is not None:
|
||||
servers_q = servers_q.filter(ProxmoxServer.id.in_(visible_ids))
|
||||
if server_id is not None:
|
||||
if visible_ids is not None and server_id not in visible_ids:
|
||||
# 404 rather than 403 so the response cannot confirm the server exists.
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
servers_q = servers_q.filter(ProxmoxServer.id == server_id)
|
||||
servers = servers_q.order_by(ProxmoxServer.name).all()
|
||||
if not servers:
|
||||
return _empty_response()
|
||||
server_map = {s.id: s for s in servers}
|
||||
|
||||
# -- Ownership: live Proxmox data carries no owner, the DB does ----------
|
||||
see_all = can_view_all_instances(current_user)
|
||||
|
||||
db_q = (
|
||||
db.query(VMInstance)
|
||||
.options(joinedload(VMInstance.owner))
|
||||
.filter(VMInstance.deleted_at.is_(None), VMInstance.server_id.in_(server_map.keys()))
|
||||
)
|
||||
if not see_all:
|
||||
db_q = db_q.filter(VMInstance.owner_id == current_user.id)
|
||||
db_guests = db_q.all()
|
||||
db_by_key: Dict[Tuple[int, int], VMInstance] = {(g.server_id, g.vmid): g for g in db_guests}
|
||||
# Pair-scoped, not vmid-scoped: the same vmid on two servers is two guests.
|
||||
owned_keys = set(db_by_key.keys())
|
||||
|
||||
# -- IP addresses from IPAM, falling back to the cached instance row ------
|
||||
ipam_by_key: Dict[Tuple[int, int], str] = {}
|
||||
for alloc in (
|
||||
db.query(IPAMAllocation)
|
||||
.filter(IPAMAllocation.status.in_(["allocated", "reserved"]))
|
||||
.all()
|
||||
):
|
||||
if alloc.proxmox_server_id and alloc.proxmox_vmid and alloc.ip_address:
|
||||
ipam_by_key[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc.ip_address
|
||||
|
||||
ipam_nets = db.query(IPAMNetwork).filter(
|
||||
IPAMNetwork.proxmox_server_id.in_(server_map.keys())
|
||||
).all()
|
||||
ipam_by_bridge: Dict[Tuple[int, str], IPAMNetwork] = {
|
||||
(n.proxmox_server_id, n.proxmox_bridge): n for n in ipam_nets if n.proxmox_bridge
|
||||
}
|
||||
|
||||
# -- Live data, in parallel: one dead cluster must not stall the rest -----
|
||||
live: Dict[int, Dict[str, Any]] = {}
|
||||
stale_servers: List[ProxmoxServer] = []
|
||||
now = time_lib.time()
|
||||
with _topo_lock: # guards the dict only, never a network call
|
||||
for server in servers:
|
||||
cached = _topo_cache.get(server.id)
|
||||
if cached and (now - cached["time"]) < TOPO_TTL:
|
||||
live[server.id] = cached["data"]
|
||||
elif server.is_online is False:
|
||||
# Offline servers contribute nothing but a connection timeout.
|
||||
continue
|
||||
else:
|
||||
stale_servers.append(server)
|
||||
|
||||
if stale_servers:
|
||||
tasks = [_run_in_executor(_collect_server, s, include_bridges) for s in stale_servers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for server, result in zip(stale_servers, results):
|
||||
if isinstance(result, HTTPException):
|
||||
warnings.append(TopologyWarning(
|
||||
server_id=server.id, server_name=server.name,
|
||||
code="no_credentials", message=str(result.detail),
|
||||
))
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning(f"Topology collection failed for {server.name}: {result}")
|
||||
warnings.append(TopologyWarning(
|
||||
server_id=server.id, server_name=server.name,
|
||||
code="error", message=str(result),
|
||||
))
|
||||
continue
|
||||
live[server.id] = result
|
||||
with _topo_lock:
|
||||
_topo_cache[server.id] = {"time": time_lib.time(), "data": result}
|
||||
|
||||
for server in servers:
|
||||
if server.id not in live and not any(w.server_id == server.id for w in warnings):
|
||||
warnings.append(TopologyWarning(
|
||||
server_id=server.id, server_name=server.name,
|
||||
code="offline", message=f"'{server.name}' is unreachable - showing cached data",
|
||||
))
|
||||
|
||||
# -- NICs (opt-in): bridge / VLAN / MAC straight from the guest configs ---
|
||||
nics_by_key: Dict[Tuple[int, int], List[Dict[str, Any]]] = {}
|
||||
if include_nics:
|
||||
pending: Dict[int, List[Tuple[str, int, str]]] = {}
|
||||
budget = NIC_MAX_FETCHES
|
||||
truncated = False
|
||||
now = time_lib.time()
|
||||
for sid, data in live.items():
|
||||
for guest in data["guests"]:
|
||||
vmid = _to_int(guest.get("vmid"))
|
||||
node = guest.get("node")
|
||||
if vmid is None or not node:
|
||||
continue
|
||||
if not see_all and (sid, vmid) not in owned_keys:
|
||||
continue
|
||||
with _nic_lock:
|
||||
cached = _nic_cache.get((sid, vmid))
|
||||
if cached and (now - cached["time"]) < NIC_TTL:
|
||||
nics_by_key[(sid, vmid)] = cached["data"]
|
||||
continue
|
||||
if budget <= 0:
|
||||
truncated = True
|
||||
continue
|
||||
budget -= 1
|
||||
pending.setdefault(sid, []).append((node, vmid, guest.get("type", "qemu")))
|
||||
|
||||
if truncated:
|
||||
warnings.append(TopologyWarning(
|
||||
code="nics_truncated",
|
||||
message=f"Network card details limited to {NIC_MAX_FETCHES} guests per request",
|
||||
))
|
||||
|
||||
if pending:
|
||||
server_ids = list(pending.keys())
|
||||
tasks = [_run_in_executor(_fetch_nics, server_map[sid], pending[sid]) for sid in server_ids]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
stamp = time_lib.time()
|
||||
for sid, result in zip(server_ids, results):
|
||||
if isinstance(result, BaseException):
|
||||
logger.debug(f"NIC collection failed for server {sid}: {result}")
|
||||
continue
|
||||
for vmid, nics in result.items():
|
||||
nics_by_key[(sid, vmid)] = nics
|
||||
with _nic_lock:
|
||||
_nic_cache[(sid, vmid)] = {"time": stamp, "data": nics}
|
||||
|
||||
# -- Assemble ------------------------------------------------------------
|
||||
def _build_guest(sid: int, node_name: str, res: Optional[Dict[str, Any]],
|
||||
row: Optional[VMInstance]) -> Optional[TopologyGuest]:
|
||||
res = res or {}
|
||||
vmid = _to_int(res.get("vmid"))
|
||||
if vmid is None and row is not None:
|
||||
vmid = row.vmid
|
||||
if vmid is None:
|
||||
return None
|
||||
|
||||
vm_type = res.get("type") or (row.vm_type if row else None) or "qemu"
|
||||
is_template = _truthy(res.get("template")) or bool(row and row.is_template)
|
||||
if is_template and not include_templates:
|
||||
return None
|
||||
status = res.get("status") or (row.status if row else None) or "unknown"
|
||||
if not include_stopped and status != "running":
|
||||
return None
|
||||
|
||||
tags = _split_tags(res.get("tags")) or _split_tags(row.tags if row else None)
|
||||
name = res.get("name") or (row.name if row else None) or f"vm-{vmid}"
|
||||
|
||||
return TopologyGuest(
|
||||
id=f"guest:{sid}:{vmid}",
|
||||
server_id=sid,
|
||||
node=node_name,
|
||||
vmid=vmid,
|
||||
type=vm_type,
|
||||
name=name,
|
||||
status=status,
|
||||
is_template=is_template,
|
||||
lock=res.get("lock"),
|
||||
cpu=_to_float(res.get("cpu")),
|
||||
cores=_to_int(res.get("maxcpu")) or (row.cores if row else None),
|
||||
mem=_to_int(res.get("mem")),
|
||||
maxmem=_to_int(res.get("maxmem")) or (row.memory if row else None),
|
||||
disk=_to_int(res.get("disk")),
|
||||
maxdisk=_to_int(res.get("maxdisk")),
|
||||
uptime=_to_int(res.get("uptime")),
|
||||
tags=tags,
|
||||
owner_id=row.owner_id if row else None,
|
||||
owner_username=(row.owner.username if row and row.owner else None),
|
||||
ip=ipam_by_key.get((sid, vmid)) or (row.ip_address if row else None),
|
||||
nics=[
|
||||
TopologyNic(**{k: v for k, v in nic.items() if k in TopologyNic.model_fields})
|
||||
for nic in nics_by_key.get((sid, vmid), [])
|
||||
],
|
||||
)
|
||||
|
||||
# cluster_name groups connections; a server without one stands alone.
|
||||
cluster_buckets: Dict[str, Dict[str, Any]] = {}
|
||||
for server in servers:
|
||||
key = f"cluster:{server.cluster_name}" if server.cluster_name else f"cluster:server:{server.id}"
|
||||
bucket = cluster_buckets.setdefault(key, {
|
||||
"id": key,
|
||||
"name": server.cluster_name or server.name,
|
||||
"kind": "cluster" if server.cluster_name else "standalone",
|
||||
"server_ids": [],
|
||||
"online": False,
|
||||
"nodes": {}, # node name -> TopologyNode, deduplicated per cluster
|
||||
})
|
||||
bucket["server_ids"].append(server.id)
|
||||
if server.id in live:
|
||||
bucket["online"] = True
|
||||
|
||||
data = live.get(server.id)
|
||||
if data is None:
|
||||
# Rebuild from the 10s VM cache so an offline server still shows up.
|
||||
by_node: Dict[str, List[VMInstance]] = {}
|
||||
for row in db_guests:
|
||||
if row.server_id != server.id:
|
||||
continue
|
||||
by_node.setdefault(row.node or server.name, []).append(row)
|
||||
for node_name, node_rows in by_node.items():
|
||||
if node_name in bucket["nodes"]:
|
||||
continue
|
||||
guests = [g for g in (_build_guest(server.id, node_name, None, r) for r in node_rows) if g]
|
||||
guests.sort(key=lambda g: g.vmid)
|
||||
bucket["nodes"][node_name] = TopologyNode(
|
||||
id=f"node:{server.id}:{node_name}",
|
||||
server_id=server.id, server_name=server.name, node=node_name,
|
||||
status="unknown", stale=True, guests=guests,
|
||||
)
|
||||
continue
|
||||
|
||||
guests_by_node: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for res in data["guests"]:
|
||||
node_name = res.get("node")
|
||||
vmid = _to_int(res.get("vmid"))
|
||||
if not node_name or vmid is None:
|
||||
continue
|
||||
if not see_all and (server.id, vmid) not in owned_keys:
|
||||
continue
|
||||
guests_by_node.setdefault(node_name, []).append(res)
|
||||
|
||||
for node_res in data["nodes"]:
|
||||
node_name = node_res.get("node")
|
||||
if not node_name or node_name in bucket["nodes"]:
|
||||
continue # same PVE cluster reachable through two connections
|
||||
bridges = []
|
||||
for entry in data["bridges"].get(node_name, []):
|
||||
ipam_net = ipam_by_bridge.get((server.id, entry["name"]))
|
||||
bridges.append(TopologyBridge(
|
||||
**entry,
|
||||
ipam_network_id=ipam_net.id if ipam_net else None,
|
||||
ipam_cidr=ipam_net.network if ipam_net else None,
|
||||
ipam_name=ipam_net.name if ipam_net else None,
|
||||
))
|
||||
guests = [
|
||||
g for g in (
|
||||
_build_guest(server.id, node_name, res,
|
||||
db_by_key.get((server.id, _to_int(res.get("vmid")))))
|
||||
for res in guests_by_node.get(node_name, [])
|
||||
) if g
|
||||
]
|
||||
guests.sort(key=lambda g: g.vmid)
|
||||
bucket["nodes"][node_name] = TopologyNode(
|
||||
id=f"node:{server.id}:{node_name}",
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
node=node_name,
|
||||
status=node_res.get("status") or "unknown",
|
||||
stale=False,
|
||||
cpu=_to_float(node_res.get("cpu")),
|
||||
maxcpu=_to_int(node_res.get("maxcpu")),
|
||||
mem=_to_int(node_res.get("mem")),
|
||||
maxmem=_to_int(node_res.get("maxmem")),
|
||||
uptime=_to_int(node_res.get("uptime")),
|
||||
bridges=bridges,
|
||||
guests=guests,
|
||||
)
|
||||
|
||||
clusters = [
|
||||
TopologyCluster(
|
||||
id=b["id"], name=b["name"], kind=b["kind"],
|
||||
server_ids=b["server_ids"], online=b["online"],
|
||||
nodes=sorted(b["nodes"].values(), key=lambda n: n.node),
|
||||
)
|
||||
for b in cluster_buckets.values()
|
||||
]
|
||||
clusters.sort(key=lambda c: (c.kind != "cluster", c.name.lower()))
|
||||
|
||||
node_count = sum(len(c.nodes) for c in clusters)
|
||||
guest_count = sum(len(n.guests) for c in clusters for n in c.nodes)
|
||||
|
||||
return NetworkTopologyResponse(
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
panel=TopologyPanelSummary(
|
||||
cluster_count=len(clusters), node_count=node_count, guest_count=guest_count,
|
||||
),
|
||||
clusters=clusters,
|
||||
warnings=warnings,
|
||||
)
|
||||
@@ -22,7 +22,7 @@ from ...ipam_service import IPAMService
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
can_view_all_instances)
|
||||
can_view_all_instances, pretty_os_from_template)
|
||||
from ...services.metrics_history import query_instance_metrics, timeframe_to_range
|
||||
|
||||
router = APIRouter()
|
||||
@@ -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(
|
||||
|
||||
@@ -1072,6 +1072,12 @@ async def deploy_vm_from_template(
|
||||
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||
instance_owner_id = owner.id
|
||||
|
||||
from ..schemas import validate_instance_name
|
||||
try:
|
||||
deploy_data.name = validate_instance_name(deploy_data.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Enforce per-user resource quota (no-op if owner has no quota set)
|
||||
from ..services.quota_service import check_quota
|
||||
check_quota(db, instance_owner_id, add_cores=cores, add_memory_mb=memory, add_disk_gb=disk)
|
||||
|
||||
@@ -721,23 +721,26 @@ def poc_install(db: Session, spec: InstallSpec, on_step: StepCb = _noop) -> Inst
|
||||
on_step(f"Создание LXC VMID {vmid} на ноде {node}...", 20)
|
||||
ip_cfg = spec.ip_config or "ip=dhcp"
|
||||
net0 = f"name=eth0,bridge={spec.bridge},{ip_cfg},firewall=1"
|
||||
upid = client.create_lxc_container(
|
||||
node=node,
|
||||
vmid=vmid,
|
||||
ostemplate=spec.ostemplate,
|
||||
hostname=spec.name,
|
||||
cores=spec.cores,
|
||||
memory=spec.memory,
|
||||
swap=spec.swap,
|
||||
storage=spec.storage,
|
||||
rootfs_size=spec.disk,
|
||||
net0=net0,
|
||||
features=spec.features, # nesting=1,keyctl=1 — критично для Docker
|
||||
unprivileged=True,
|
||||
start_after_create=False,
|
||||
description=f"pvemanager-appstore: {spec.name}",
|
||||
nameserver=spec.nameserver or settings.APPSTORE_NAMESERVER,
|
||||
)
|
||||
try:
|
||||
upid = client.create_lxc_container(
|
||||
node=node,
|
||||
vmid=vmid,
|
||||
ostemplate=spec.ostemplate,
|
||||
hostname=spec.name,
|
||||
cores=spec.cores,
|
||||
memory=spec.memory,
|
||||
swap=spec.swap,
|
||||
storage=spec.storage,
|
||||
rootfs_size=spec.disk,
|
||||
net0=net0,
|
||||
features=spec.features, # nesting=1,keyctl=1 — критично для Docker
|
||||
unprivileged=True,
|
||||
start_after_create=False,
|
||||
description=f"pvemanager-appstore: {spec.name}",
|
||||
nameserver=spec.nameserver or settings.APPSTORE_NAMESERVER,
|
||||
)
|
||||
except Exception as e:
|
||||
raise PocError(f"Ошибка создания контейнера: {e}") from e
|
||||
if not upid:
|
||||
raise PocError("Proxmox не вернул UPID создания контейнера")
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ LXC-шаблоны (vztmpl) здесь НЕ хардкодятся: их спи
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
_GH = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-14'
|
||||
_GH2 = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-18'
|
||||
|
||||
# (id_base, os, version, name, icon, {arch: (url, checksum_or_None)})
|
||||
# amd64 → собственное GitHub-зеркало + sha256; arm64 → upstream без checksum
|
||||
@@ -67,6 +68,46 @@ _CLOUD_IMAGES = [
|
||||
'arm64': ('https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'alma-10-cloud', 'almalinux', '10', 'AlmaLinux 10 GenericCloud', 'almalinux',
|
||||
{
|
||||
'amd64': (f'{_GH2}/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2',
|
||||
'd0bdbc4fabb365b27e8d4a77118c1100ebdab42341312338e63724a2f9ee217f'),
|
||||
'arm64': ('https://repo.almalinux.org/almalinux/10/cloud/aarch64/images/AlmaLinux-10-GenericCloud-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'centos-stream-9-cloud', 'centos', '9-stream', 'CentOS Stream 9 GenericCloud', 'centos',
|
||||
{
|
||||
'amd64': (f'{_GH2}/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2',
|
||||
'63c13542e94678b0a41d318e7758f1cf23609564b1cbaa9051616457ce771903'),
|
||||
'arm64': ('https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'debian-13-cloud', 'debian', '13', 'Debian 13 (Trixie) GenericCloud', 'debian',
|
||||
{
|
||||
'amd64': (f'{_GH2}/debian-13-genericcloud-amd64.qcow2',
|
||||
'42a4931fc94840d75c9a58ba4cde0bc98365d3998645872c9f4c7244f682198e'),
|
||||
'arm64': ('https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'fedora-41-cloud', 'fedora', '41', 'Fedora 41 Cloud', 'fedora',
|
||||
{
|
||||
'amd64': (f'{_GH2}/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2',
|
||||
'6c12ad70a8b5058f032de10d463bda8c93391162e5a72a90c79dd548ecc8e378'),
|
||||
'arm64': ('https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-41-1.4.aarch64.qcow2', None),
|
||||
},
|
||||
),
|
||||
(
|
||||
'ubuntu-26.04-cloud', 'ubuntu', '26.04', 'Ubuntu 26.04 LTS (Resolute) Cloud', 'ubuntu',
|
||||
{
|
||||
'amd64': (f'{_GH2}/resolute-server-cloudimg-amd64.img',
|
||||
'b9cd03eb2b9b2ddd59c68c7d577a56835ba7da3d713e085336f493290daec9c8'),
|
||||
'arm64': ('https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img', None),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,18 @@ class VmMixin:
|
||||
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def reset_vm(self, node: str, vmid: int) -> Optional[str]:
|
||||
"""Жёсткий сброс VM (аналог кнопки Reset — без ACPI, без остановки/старта заново)."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).status.reset.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка жёсткого сброса VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def _wait_vm_stopped(self, node: str, vmid: int, timeout: int) -> bool:
|
||||
"""Опрашивать статус ВМ, пока не 'stopped' или не выйдет timeout секунд."""
|
||||
deadline = time.time() + timeout
|
||||
|
||||
139
backend/app/proxmox/net_parser.py
Normal file
139
backend/app/proxmox/net_parser.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Parser for Proxmox ``netN`` guest NIC strings.
|
||||
|
||||
Proxmox stores every guest network card as one comma-separated key=value
|
||||
string in the guest config (``net0``, ``net1``, …). Two dialects exist:
|
||||
|
||||
* QEMU — ``virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=100,firewall=1``
|
||||
The NIC model is itself the key holding the MAC address.
|
||||
* LXC — ``name=eth0,bridge=vmbr0,hwaddr=AA:BB:CC:DD:EE:FF,ip=10.0.0.5/24,``
|
||||
``gw=10.0.0.1,type=veth,firewall=1``
|
||||
|
||||
The repository only ever *builds* these strings (``api/proxmox/lxc.py``,
|
||||
``proxmox/mixins/vm.py``); this module reads them back so callers can learn
|
||||
which bridge/VLAN a guest actually sits on.
|
||||
|
||||
Deliberately free of any ``proxmoxer`` import so both the API layer and the
|
||||
client mixins can use it. Parsing never raises: an unparsable token is skipped
|
||||
rather than failing the whole request.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
import re
|
||||
|
||||
# NIC models QEMU accepts. The model name doubles as the key carrying the MAC,
|
||||
# which is the only way to tell "virtio=AA:.." from an ordinary option.
|
||||
QEMU_MODELS = {
|
||||
"virtio", "e1000", "e1000e", "e1000-82540em", "e1000-82544gc",
|
||||
"e1000-82545em", "rtl8139", "vmxnet3", "ne2k_pci", "ne2k_isa",
|
||||
"pcnet", "i82551", "i82557b", "i82559er",
|
||||
}
|
||||
|
||||
_NET_KEY_RE = re.compile(r"^net(\d+)$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNic:
|
||||
"""One guest network interface, normalised across the QEMU/LXC dialects."""
|
||||
|
||||
key: str # "net0"
|
||||
index: int # 0
|
||||
model: Optional[str] = None # virtio / e1000 / … (QEMU only)
|
||||
mac: Optional[str] = None # uppercase, from model= or hwaddr=
|
||||
bridge: Optional[str] = None # vmbr0 or an SDN vnet
|
||||
vlan_tag: Optional[int] = None
|
||||
trunks: List[int] = field(default_factory=list)
|
||||
firewall: bool = False
|
||||
link_down: bool = False
|
||||
name: Optional[str] = None # LXC: name=eth0
|
||||
ip: Optional[str] = None # LXC: "10.0.0.5/24" or "dhcp"
|
||||
ip6: Optional[str] = None
|
||||
gw: Optional[str] = None
|
||||
gw6: Optional[str] = None
|
||||
rate: Optional[float] = None # MB/s
|
||||
mtu: Optional[int] = None
|
||||
queues: Optional[int] = None
|
||||
raw: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _as_int(value: str) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _as_float(value: str) -> Optional[float]:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _as_bool(value: str) -> bool:
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def parse_net_string(key: str, value: str) -> ParsedNic:
|
||||
"""Parse a single ``netN`` value. Never raises."""
|
||||
match = _NET_KEY_RE.match(key or "")
|
||||
nic = ParsedNic(key=key or "", index=int(match.group(1)) if match else 0, raw=value or "")
|
||||
|
||||
for token in (value or "").split(","):
|
||||
token = token.strip()
|
||||
if not token or "=" not in token:
|
||||
# Bare tokens (rare, e.g. a stray flag) carry nothing we need.
|
||||
continue
|
||||
opt, _, raw_val = token.partition("=")
|
||||
opt = opt.strip().lower()
|
||||
raw_val = raw_val.strip()
|
||||
|
||||
if opt in QEMU_MODELS:
|
||||
nic.model = opt
|
||||
nic.mac = raw_val.upper() or None
|
||||
elif opt == "hwaddr":
|
||||
nic.mac = raw_val.upper() or None
|
||||
elif opt == "bridge":
|
||||
nic.bridge = raw_val or None
|
||||
elif opt == "tag":
|
||||
nic.vlan_tag = _as_int(raw_val)
|
||||
elif opt == "trunks":
|
||||
nic.trunks = [v for v in (_as_int(p) for p in raw_val.split(";")) if v is not None]
|
||||
elif opt == "firewall":
|
||||
nic.firewall = _as_bool(raw_val)
|
||||
elif opt == "link_down":
|
||||
nic.link_down = _as_bool(raw_val)
|
||||
elif opt == "name":
|
||||
nic.name = raw_val or None
|
||||
elif opt == "ip":
|
||||
nic.ip = raw_val or None
|
||||
elif opt == "ip6":
|
||||
nic.ip6 = raw_val or None
|
||||
elif opt == "gw":
|
||||
nic.gw = raw_val or None
|
||||
elif opt == "gw6":
|
||||
nic.gw6 = raw_val or None
|
||||
elif opt == "rate":
|
||||
nic.rate = _as_float(raw_val)
|
||||
elif opt == "mtu":
|
||||
nic.mtu = _as_int(raw_val)
|
||||
elif opt == "queues":
|
||||
nic.queues = _as_int(raw_val)
|
||||
|
||||
return nic
|
||||
|
||||
|
||||
def parse_guest_nics(config: Dict[str, Any]) -> List[ParsedNic]:
|
||||
"""Every ``netN`` entry of a QEMU/LXC config, ordered by interface index."""
|
||||
nics: List[ParsedNic] = []
|
||||
for key, value in (config or {}).items():
|
||||
if not isinstance(key, str) or not _NET_KEY_RE.match(key):
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
nics.append(parse_net_string(key, value))
|
||||
nics.sort(key=lambda n: n.index)
|
||||
return nics
|
||||
@@ -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] = []
|
||||
|
||||
186
frontend/package-lock.json
generated
186
frontend/package-lock.json
generated
@@ -18,8 +18,10 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
@@ -2033,6 +2035,15 @@
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
@@ -2063,6 +2074,12 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
@@ -2084,6 +2101,25 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
@@ -2162,7 +2198,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2540,6 +2576,76 @@
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz",
|
||||
"integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.80",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=17",
|
||||
"@types/react-dom": ">=17",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.80",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz",
|
||||
"integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -2956,6 +3062,12 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
@@ -3269,6 +3381,28 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
@@ -3324,6 +3458,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
@@ -3369,6 +3512,41 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
@@ -4716,6 +4894,12 @@
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
|
||||
44
frontend/src/components/shared/ActiveTasksIndicator.tsx
Normal file
44
frontend/src/components/shared/ActiveTasksIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ListTodo } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useActiveTaskCount } from '@/hooks/use-tasks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ActiveTasksIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data } = useActiveTaskCount();
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative"
|
||||
onClick={() => navigate('/tasks')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
{count > 0 && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="absolute -right-0.5 -top-0.5 h-4 min-w-4 px-1 text-2xs"
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{count > 0
|
||||
? t('tasks.active_count', { count })
|
||||
: t('tasks.no_active', 'No active tasks')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative grid w-full grid-cols-[0_1fr] items-start gap-x-2.5 gap-y-1 rounded-lg border px-3.5 py-3 text-sm has-[>svg]:grid-cols-[theme(spacing.4)_1fr] [&>svg]:mt-0.5 [&>svg]:size-4",
|
||||
"relative grid w-full grid-cols-[0_minmax(0,1fr)] items-start gap-x-2.5 gap-y-1 rounded-lg border px-3.5 py-3 text-sm has-[>svg]:grid-cols-[theme(spacing.4)_minmax(0,1fr)] [&>svg]:mt-0.5 [&>svg]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -55,7 +55,11 @@ function DialogContent({
|
||||
className={cn(
|
||||
// Default width: capped at max-w-lg and centered; on small screens w-[calc(100%-2rem)]
|
||||
// keeps a 1rem gap on each side. Individual dialogs override width via their own max-w-*.
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
// grid-cols-[minmax(0,1fr)] caps the implicit column at the dialog
|
||||
// width: an `auto` track is max-content sized, so long unbreakable
|
||||
// content (e.g. CT template names) would otherwise widen the column
|
||||
// and push fields past the dialog edge.
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg grid-cols-[minmax(0,1fr)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -68,7 +68,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
className={cn("flex min-w-0 flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -87,7 +87,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-full min-w-0 items-center justify-between gap-1.5 overflow-hidden rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
20
frontend/src/components/ui/slider.tsx
Normal file
20
frontend/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({ className, ...props }: SliderPrimitive.Root.Props) {
|
||||
return (
|
||||
<SliderPrimitive.Root data-slot="slider" {...props}>
|
||||
<SliderPrimitive.Control
|
||||
className={cn("flex w-full touch-none items-center py-2 select-none", className)}
|
||||
>
|
||||
<SliderPrimitive.Track className="h-1 w-full rounded-full bg-input select-none">
|
||||
<SliderPrimitive.Indicator className="rounded-full bg-primary select-none" />
|
||||
<SliderPrimitive.Thumb className="size-3.5 rounded-full bg-background border-2 border-primary shadow-xs outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50" />
|
||||
</SliderPrimitive.Track>
|
||||
</SliderPrimitive.Control>
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -1,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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -32,6 +32,39 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type InstanceKind = 'vm' | 'lxc' | 'iso';
|
||||
|
||||
const DNS_NAME_RE = /^[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
||||
function isValidDnsName(name: string): boolean {
|
||||
return name.length > 0 && name.length <= 63 && DNS_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
function NumericInput({ value, onChange, ...props }: Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'> & {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
}) {
|
||||
const [raw, setRaw] = useState(String(value));
|
||||
const committed = useRef(value);
|
||||
if (value !== committed.current) {
|
||||
committed.current = value;
|
||||
setRaw(String(value));
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
type="number"
|
||||
value={raw}
|
||||
onChange={e => {
|
||||
setRaw(e.target.value);
|
||||
const n = e.target.value === '' ? 0 : Number(e.target.value);
|
||||
if (!Number.isNaN(n)) {
|
||||
committed.current = n;
|
||||
onChange(n);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = ['server', 'type', 'template', 'config', 'confirm'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
@@ -235,9 +268,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
return !!selectedLXCTemplate;
|
||||
}
|
||||
if (step === 'config') {
|
||||
if (kind === 'vm') return config.name.length >= 2;
|
||||
if (kind === 'iso') return isoConfig.name.length >= 2;
|
||||
return lxcConfig.name.length >= 2;
|
||||
if (kind === 'vm') return isValidDnsName(config.name) && !!config.cloud_init_user.trim() && !!config.cloud_init_password.trim();
|
||||
if (kind === 'iso') return isValidDnsName(isoConfig.name);
|
||||
return isValidDnsName(lxcConfig.name);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -689,6 +722,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<div>
|
||||
<Label>{t('common.name')}</Label>
|
||||
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder={t('common.placeholder_vm_name')} />
|
||||
{config.name && !isValidDnsName(config.name) && (
|
||||
<p className="mt-1 text-xs text-destructive">{t('wizard.invalid_name')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.target_node')}</Label>
|
||||
@@ -719,30 +755,27 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>vCPU</Label>
|
||||
<Input
|
||||
type="number"
|
||||
<NumericInput
|
||||
min={selectedTemplate?.min_cores || 1}
|
||||
value={config.cores}
|
||||
onChange={e => setConfig(p => ({ ...p, cores: Number(e.target.value) }))}
|
||||
onChange={v => setConfig(p => ({ ...p, cores: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
<NumericInput
|
||||
min={selectedTemplate?.min_memory || 256}
|
||||
step={256}
|
||||
value={config.memory}
|
||||
onChange={e => setConfig(p => ({ ...p, memory: Number(e.target.value) }))}
|
||||
onChange={v => setConfig(p => ({ ...p, memory: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
<NumericInput
|
||||
min={selectedTemplate?.min_disk || 5}
|
||||
value={config.disk}
|
||||
onChange={e => setConfig(p => ({ ...p, disk: Number(e.target.value) }))}
|
||||
onChange={v => setConfig(p => ({ ...p, disk: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -799,12 +832,22 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm">Cloud-Init</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('wizard.ci_user')}</Label>
|
||||
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder={t('common.placeholder_ubuntu')} />
|
||||
<Label>{t('wizard.ci_user')} <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
value={config.cloud_init_user}
|
||||
onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))}
|
||||
placeholder={t('common.placeholder_ubuntu')}
|
||||
className={!config.cloud_init_user.trim() ? 'border-destructive' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.ci_password')}</Label>
|
||||
<Input type="password" value={config.cloud_init_password} onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))} />
|
||||
<Label>{t('wizard.ci_password')} <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={config.cloud_init_password}
|
||||
onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))}
|
||||
className={!config.cloud_init_password.trim() ? 'border-destructive' : ''}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<SSHOwnerSelector
|
||||
@@ -844,6 +887,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<div>
|
||||
<Label>{t('common.name')}</Label>
|
||||
<Input value={isoConfig.name} onChange={e => setIsoConfig(p => ({ ...p, name: e.target.value }))} placeholder={t('common.placeholder_vm_name')} />
|
||||
{isoConfig.name && !isValidDnsName(isoConfig.name) && (
|
||||
<p className="mt-1 text-xs text-destructive">{t('wizard.invalid_name')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.os_type')}</Label>
|
||||
@@ -887,15 +933,15 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>vCPU</Label>
|
||||
<Input type="number" min={1} value={isoConfig.cores} onChange={e => setIsoConfig(p => ({ ...p, cores: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={isoConfig.cores} onChange={v => setIsoConfig(p => ({ ...p, cores: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input type="number" min={256} step={256} value={isoConfig.memory} onChange={e => setIsoConfig(p => ({ ...p, memory: Number(e.target.value) }))} />
|
||||
<NumericInput min={256} step={256} value={isoConfig.memory} onChange={v => setIsoConfig(p => ({ ...p, memory: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input type="number" min={1} value={isoConfig.disk} onChange={e => setIsoConfig(p => ({ ...p, disk: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={isoConfig.disk} onChange={v => setIsoConfig(p => ({ ...p, disk: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.storage')}</Label>
|
||||
@@ -1018,6 +1064,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
onChange={e => setLxcConfig(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder={t('common.placeholder_vm_name')}
|
||||
/>
|
||||
{lxcConfig.name && !isValidDnsName(lxcConfig.name) && (
|
||||
<p className="mt-1 text-xs text-destructive">{t('wizard.invalid_name')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.storage')}</Label>
|
||||
@@ -1056,19 +1105,19 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>vCPU</Label>
|
||||
<Input type="number" min={1} value={lxcConfig.cores} onChange={e => setLxcConfig(p => ({ ...p, cores: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={lxcConfig.cores} onChange={v => setLxcConfig(p => ({ ...p, cores: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input type="number" min={64} step={128} value={lxcConfig.memory} onChange={e => setLxcConfig(p => ({ ...p, memory: Number(e.target.value) }))} />
|
||||
<NumericInput min={64} step={128} value={lxcConfig.memory} onChange={v => setLxcConfig(p => ({ ...p, memory: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.swap_mb')}</Label>
|
||||
<Input type="number" min={0} step={128} value={lxcConfig.swap} onChange={e => setLxcConfig(p => ({ ...p, swap: Number(e.target.value) }))} />
|
||||
<NumericInput min={0} step={128} value={lxcConfig.swap} onChange={v => setLxcConfig(p => ({ ...p, swap: v }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input type="number" min={1} value={lxcConfig.disk} onChange={e => setLxcConfig(p => ({ ...p, disk: Number(e.target.value) }))} />
|
||||
<NumericInput min={1} value={lxcConfig.disk} onChange={v => setLxcConfig(p => ({ ...p, disk: v }))} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { Fragment, useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
Settings2,
|
||||
Archive,
|
||||
Cpu,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Globe,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||
import BulkMigrateDialog from './BulkMigrateDialog';
|
||||
@@ -94,15 +98,25 @@ import type { VMInstance } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ==================== Inline Progress Bar ====================
|
||||
function InlineProgress({ value, max, className }: { value: number; max: number; className?: string }) {
|
||||
function InlineProgress({
|
||||
value,
|
||||
max,
|
||||
barClassName = 'w-16',
|
||||
textClassName = 'text-xs',
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
barClassName?: string;
|
||||
textClassName?: string;
|
||||
}) {
|
||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
||||
const color = pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-primary';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-16 overflow-hidden rounded-full bg-muted ${className}`}>
|
||||
<div className={`h-1.5 overflow-hidden rounded-full bg-muted ${barClassName}`}>
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
|
||||
<span className={`${textClassName} tabular-nums text-muted-foreground`}>{pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -268,6 +282,155 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Metric row (label + progress bar + detail) ====================
|
||||
function MetricRow({ label, value, max, detail }: { label: string; value: number | null | undefined; max: number; detail: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-16 shrink-0 text-sm font-medium text-muted-foreground">{label}</span>
|
||||
{value != null && max ? (
|
||||
<InlineProgress value={value} max={max} barClassName="w-32 sm:w-44" textClassName="text-sm" />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Info item (label + value) ====================
|
||||
function InfoItem({ label, value, mono, icon: Icon }: { label: string; value: string; mono?: boolean; icon?: typeof Globe }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className={`flex items-center gap-1 ${mono ? 'font-mono text-sm' : 'text-sm'}`}>
|
||||
{Icon && <Icon className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Expandable instance metrics panel ====================
|
||||
function InstanceMetricsPanel({ vm, nodeLabelName }: { vm: VMInstance; nodeLabelName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isQemu = vm.type === 'qemu';
|
||||
const owner =
|
||||
vm.owner_user?.email || vm.owner_user?.full_name || vm.owner_user?.username || vm.owner || '—';
|
||||
const os = vm.os || vm.os_template || '—';
|
||||
const isRunning = vm.status === 'running';
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/30">
|
||||
<div className="flex flex-wrap items-center gap-4 border-b px-4 py-2 text-sm">
|
||||
<Link
|
||||
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=settings`}
|
||||
className="flex items-center gap-1.5 text-primary hover:underline"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" /> {t('instances.parameters', 'Параметры')}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" /> {t('common.console', 'Console')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-6 p-4 sm:grid-cols-2">
|
||||
<div className="space-y-2.5">
|
||||
<MetricRow
|
||||
label="vCPU"
|
||||
value={vm.cpu != null ? vm.cpu * 100 : null}
|
||||
max={100}
|
||||
detail={vm.cores ? `${vm.cores} vCPU` : '—'}
|
||||
/>
|
||||
<MetricRow
|
||||
label="RAM"
|
||||
value={vm.mem ?? null}
|
||||
max={vm.maxmem || 0}
|
||||
detail={vm.mem != null && vm.maxmem ? `${formatBytes(vm.mem)} / ${formatBytes(vm.maxmem)}` : '—'}
|
||||
/>
|
||||
{!isQemu && (
|
||||
<MetricRow
|
||||
label="Storage"
|
||||
value={vm.disk ?? null}
|
||||
max={vm.maxdisk || 0}
|
||||
detail={vm.disk != null && vm.maxdisk ? `${formatBytes(vm.disk)} / ${formatBytes(vm.maxdisk)}` : '—'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 content-start gap-x-6 gap-y-3">
|
||||
<InfoItem label={t('instances.ip_address', 'IP-адрес')} value={vm.ip_address || '—'} mono icon={Globe} />
|
||||
<InfoItem label={t('common.node', 'Узел')} value={nodeLabelName} />
|
||||
<InfoItem label={t('instances.os_config', 'ОС')} value={os} />
|
||||
<InfoItem label={t('instances.owner', 'Владелец')} value={owner} />
|
||||
<InfoItem label={t('instances.uptime', 'Аптайм')} value={vm.uptime ? formatUptime(vm.uptime) : '—'} />
|
||||
</div>
|
||||
</div>
|
||||
{isRunning && (
|
||||
<div className="grid gap-3 border-t px-4 py-4 sm:grid-cols-2">
|
||||
<IoStatCard
|
||||
icon={Network}
|
||||
label={t('instances.network', 'Сеть')}
|
||||
rows={[
|
||||
{ icon: ArrowDown, iconClass: 'text-primary', label: t('instances.net_in', 'Входящий'), value: vm.netin_rate },
|
||||
{ icon: ArrowUp, iconClass: 'text-violet-500', label: t('instances.net_out', 'Исходящий'), value: vm.netout_rate },
|
||||
]}
|
||||
/>
|
||||
<IoStatCard
|
||||
icon={HardDrive}
|
||||
label={t('instances.disk_io', 'Диск I/O')}
|
||||
rows={[
|
||||
{ badge: 'R', badgeClass: 'text-cyan-500', label: t('instances.disk_read', 'Чтение'), value: vm.diskread_rate },
|
||||
{ badge: 'W', badgeClass: 'text-orange-500', label: t('instances.disk_write', 'Запись'), value: vm.diskwrite_rate },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== I/O stat card (network / disk throughput) ====================
|
||||
type IoStatRow = {
|
||||
label: string;
|
||||
value?: number;
|
||||
icon?: typeof ArrowDown;
|
||||
iconClass?: string;
|
||||
badge?: string;
|
||||
badgeClass?: string;
|
||||
};
|
||||
|
||||
function IoStatCard({ icon: Icon, label, rows }: { icon: typeof Network; label: string; rows: IoStatRow[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="mb-2.5 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<Icon className="h-4 w-4" /> {label}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
{row.icon ? (
|
||||
<row.icon className={`h-4 w-4 shrink-0 ${row.iconClass}`} />
|
||||
) : (
|
||||
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-sm bg-muted text-[10px] font-bold ${row.badgeClass}`}>
|
||||
{row.badge}
|
||||
</span>
|
||||
)}
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold tabular-nums">
|
||||
{row.value != null ? `${formatBytes(row.value)}/s` : '—'}
|
||||
</div>
|
||||
<div className="text-2xs text-muted-foreground">{row.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Instances Page ====================
|
||||
export default function InstancesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -376,6 +539,15 @@ export default function InstancesPage() {
|
||||
}
|
||||
});
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const toggleExpand = useCallback((rowId: string) => {
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowId)) next.delete(rowId);
|
||||
else next.add(rowId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset every filter that can reference a server/node identity when the
|
||||
// active workspace changes — those values belong to whatever workspace was
|
||||
@@ -668,7 +840,7 @@ export default function InstancesPage() {
|
||||
// component types, so a new columns array remounts EVERY cell — which closes
|
||||
// an open row dropdown menu the moment vms/servers/tasks data refreshes.
|
||||
// Cells still see fresh values: any store/query update re-renders the table.
|
||||
const cellCtx = { deployTasks, overlayByVm, bulkOverlayByVm, serverNameById, nodeLabel };
|
||||
const cellCtx = { deployTasks, overlayByVm, bulkOverlayByVm, serverNameById, nodeLabel, expandedRows, toggleExpand };
|
||||
const cellCtxRef = useRef(cellCtx);
|
||||
cellCtxRef.current = cellCtx;
|
||||
|
||||
@@ -727,11 +899,20 @@ export default function InstancesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const rowId = vmRowId(vm);
|
||||
const isExpanded = cellCtxRef.current.expandedRows.has(rowId);
|
||||
return (
|
||||
<Link
|
||||
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
|
||||
className="group flex items-center gap-2"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cellCtxRef.current.toggleExpand(rowId)}
|
||||
className="group flex items-center gap-2 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<StatusDot status={vm.status} pulse />
|
||||
<div>
|
||||
<span className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
@@ -739,7 +920,7 @@ export default function InstancesPage() {
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">#{vm.vmid}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -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>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
<ArrowDown className="h-4 w-4 text-muted-foreground" />
|
||||
{t('common.io_rates')}
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<IoRateCard
|
||||
label={t('common.disk_read_rate')}
|
||||
value={status?.diskread_rate}
|
||||
@@ -230,18 +230,6 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
icon={HardDrive}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
<IoRateCard
|
||||
label={t('common.net_in_rate')}
|
||||
value={status?.netin_rate}
|
||||
icon={ArrowDown}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
<IoRateCard
|
||||
label={t('common.net_out_rate')}
|
||||
value={status?.netout_rate}
|
||||
icon={ArrowUp}
|
||||
color="text-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk } from '@/hooks/use-instances';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk, useMoveDisk, useVMStatus, usePowerAction } from '@/hooks/use-instances';
|
||||
import { useLXCStorages } from '@/hooks/use-lxc-templates';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
@@ -264,3 +275,188 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Move disk to another storage ====================
|
||||
|
||||
type MoveStage = 'confirm' | 'stopping' | 'moving' | 'starting' | 'error';
|
||||
|
||||
export function DiskMoveCard({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
// Только хранилища, поддерживающие нужный тип контента (rootdir для LXC, images для VM) —
|
||||
// иначе Proxmox отклонит задачу переноса уже после запуска.
|
||||
const { data: storages = [] } = useLXCStorages(serverId, node, type === 'lxc' ? 'rootdir' : 'images');
|
||||
const moveDisk = useMoveDisk(serverId, vmid, type, node);
|
||||
const power = usePowerAction(serverId, vmid, type, node);
|
||||
// move_volume для LXC (в отличие от QEMU move_disk) не работает на запущенном контейнере —
|
||||
// Proxmox отклоняет задачу с "cannot move volumes of a running container".
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||
const isRunning = status?.status === 'running';
|
||||
const blockedByRunning = type === 'lxc' && isRunning;
|
||||
|
||||
const diskDevices = useMemo(() => {
|
||||
if (!config) return [] as string[];
|
||||
const re = type === 'lxc' ? /^(rootfs|mp\d+)$/ : /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
|
||||
return Object.entries(config)
|
||||
.filter(([k, v]) => re.test(k) && typeof v === 'string' && !v.includes('media=cdrom'))
|
||||
.map(([k]) => k);
|
||||
}, [config, type]);
|
||||
|
||||
const [moveDiskDev, setMoveDiskDev] = useState('');
|
||||
const [moveStorage, setMoveStorage] = useState('');
|
||||
const [moveDelete, setMoveDelete] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [stage, setStage] = useState<MoveStage>('confirm');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (diskDevices.length && !diskDevices.includes(moveDiskDev)) setMoveDiskDev(diskDevices[0]);
|
||||
}, [diskDevices, moveDiskDev]);
|
||||
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
const fetchStatus = () =>
|
||||
apiClient
|
||||
.get<{ status: string }>(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`)
|
||||
.then((r) => r.status);
|
||||
|
||||
const waitForStatus = async (target: string, timeoutMs = 60000, intervalMs = 1500) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await fetchStatus()) === target) return true;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const runMove = () =>
|
||||
moveDisk.mutateAsync({ disk: moveDiskDev, target_storage: moveStorage, delete: moveDelete });
|
||||
|
||||
const openConfirm = () => {
|
||||
if (!moveDiskDev || !moveStorage) return;
|
||||
setErrorMsg('');
|
||||
setStage('confirm');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// Прямой перенос — контейнер/VM уже в подходящем состоянии.
|
||||
const confirmDirectMove = async () => {
|
||||
setStage('moving');
|
||||
try {
|
||||
await runMove();
|
||||
setDialogOpen(false);
|
||||
toast.success(t('instances.disk_moved'));
|
||||
setMoveStorage('');
|
||||
} catch (e) {
|
||||
setStage('error');
|
||||
setErrorMsg((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// Согласованный сценарий для запущенного LXC: стоп -> перенос -> запуск обратно.
|
||||
const confirmStopAndMove = async () => {
|
||||
setStage('stopping');
|
||||
try {
|
||||
await power.mutateAsync({ action: 'stop' });
|
||||
const stopped = await waitForStatus('stopped');
|
||||
if (!stopped) throw new Error(t('instances.disk_move_stop_timeout'));
|
||||
|
||||
setStage('moving');
|
||||
await runMove();
|
||||
|
||||
setStage('starting');
|
||||
await power.mutateAsync({ action: 'start' });
|
||||
|
||||
setDialogOpen(false);
|
||||
toast.success(t('instances.disk_moved'));
|
||||
setMoveStorage('');
|
||||
} catch (e) {
|
||||
// Даже если перенос упал, возвращаем контейнер в исходное состояние.
|
||||
try { await power.mutateAsync({ action: 'start' }); } catch { /* уже могло стартовать */ }
|
||||
setStage('error');
|
||||
setErrorMsg((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = stage === 'stopping' || stage === 'moving' || stage === 'starting';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t('instances.disk_move')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.disk_device')}</Label>
|
||||
<Select value={moveDiskDev} onValueChange={(v) => v && setMoveDiskDev(v)}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.disk_device')} /></SelectTrigger>
|
||||
<SelectContent>{diskDevices.map((d) => <SelectItem key={d} value={d}>{d}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.migrate_target_storage')}</Label>
|
||||
<Select value={moveStorage} onValueChange={(v) => v && setMoveStorage(v)}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.migrate_target_storage')} /></SelectTrigger>
|
||||
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<Checkbox checked={moveDelete} onChange={(e) => setMoveDelete(e.target.checked)} />
|
||||
<span>{t('instances.disk_move_delete')}</span>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={openConfirm} disabled={!moveDiskDev || !moveStorage}>
|
||||
{t('instances.disk_move')}
|
||||
</Button>
|
||||
{blockedByRunning && (
|
||||
<p className="text-xs text-muted-foreground">{t('instances.disk_move_stop_hint')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!open && !busy) setDialogOpen(false); }}>
|
||||
<DialogContent showCloseButton={!busy}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('instances.disk_move_confirm_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{blockedByRunning
|
||||
? t('instances.disk_move_confirm_running', { dev: moveDiskDev, storage: moveStorage })
|
||||
: t('instances.disk_move_confirm_plain', { dev: moveDiskDev, storage: moveStorage })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{stage === 'stopping' && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />{t('instances.disk_move_stage_stopping')}
|
||||
</p>
|
||||
)}
|
||||
{stage === 'moving' && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />{t('instances.disk_move_stage_moving')}
|
||||
</p>
|
||||
)}
|
||||
{stage === 'starting' && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />{t('instances.disk_move_stage_starting')}
|
||||
</p>
|
||||
)}
|
||||
{stage === 'error' && (
|
||||
<p className="text-sm text-destructive">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={blockedByRunning ? confirmStopAndMove : confirmDirectMove}
|
||||
>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{blockedByRunning ? t('instances.disk_move_stop_and_move') : t('instances.disk_move')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useVMConfig, useUpdateConfig, useVMOwner, useSetVMOwner, useExecuteScri
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { ComputeResourcesCard, DiskResizeCard } from './ResourceCards';
|
||||
import { ComputeResourcesCard, DiskResizeCard, DiskMoveCard } from './ResourceCards';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
@@ -736,6 +736,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
|
||||
{type === 'lxc' && <DiskResizeCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
{type === 'lxc' && <DiskMoveCard serverId={serverId} vmid={vmid} type={type} node={node} />}
|
||||
|
||||
<SavedConfigCard serverId={serverId} vmid={vmid} />
|
||||
|
||||
{/* Raw Config */}
|
||||
|
||||
@@ -119,8 +119,16 @@ const OS_MAP: Array<{ keys: string[]; info: OsInfo }> = [
|
||||
bg: '#294172',
|
||||
fg: '#fff',
|
||||
svg: (
|
||||
<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm4 11h-3v3a1 1 0 0 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 2 0v3h3a1 1 0 0 1 0 2z" />
|
||||
<svg viewBox="0 0 267 267" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 266.62575,133.50613 C 266.62575,59.98128 207.02222,0.37583 133.49792,0.37583 C 60.00668,0.37583 0.42639,59.93123 0.37425,133.41225 L 0.37425,236.4333 C 0.4138,253.11763 13.94545,266.62417 30.64027,266.62417 L 133.55192,266.62417 C 207.05167,266.59532 266.62575,207.01142 266.62575,133.50613" fill="#294172"/>
|
||||
<path d="M 77.126289,142.09756 C 77.126289,142.09756 124.97104,142.09756 124.97104,142.09756 C 124.97104,142.09756 124.97104,189.94234 124.97104,189.94234 C 124.97104,216.35263 103.53659,237.78707 77.126289,237.78707 C 50.715979,237.78707 29.28153,216.35263 29.28153,189.94234 C 29.28153,163.53203 50.715979,142.09756 77.126289,142.09756 z" fill="none" stroke="#3c6eb4" strokeWidth="29.21"/>
|
||||
<use transform="matrix(-1,0,0,-1,249.71151,284.2882)" xlinkHref="#fedora-in"/>
|
||||
<path d="M 139.6074,127.52923 L 139.6074,189.87541 C 139.6074,224.37943 111.63203,252.35541 77.12679,252.35541 C 71.89185,252.35541 68.1703,251.7644 63.32444,250.49771 C 56.25849,248.64859 50.48398,242.85518 50.48158,236.1166 C 50.48158,227.97147 56.39394,222.0467 65.23187,222.0467 C 69.43824,222.0467 70.96454,222.85435 77.12679,222.85435 C 95.3184,222.85435 110.07443,208.11916 110.10634,189.92756 L 110.10634,161.27099 C 110.10634,158.70324 108.01971,156.62274 105.44767,156.62274 L 83.78246,156.61846 C 75.71034,156.61846 69.18845,150.18003 69.18845,142.0858 C 69.18414,133.94124 75.77725,127.52923 83.93653,127.52923" fill="#ffffff"/>
|
||||
<use transform="matrix(-1,0,0,-1,249.71152,284.28821)" xlinkHref="#fedora-free"/>
|
||||
<defs>
|
||||
<path d="M 77.126289,142.09756 C 77.126289,142.09756 124.97104,142.09756 124.97104,142.09756 C 124.97104,142.09756 124.97104,189.94234 124.97104,189.94234 C 124.97104,216.35263 103.53659,237.78707 77.126289,237.78707 C 50.715979,237.78707 29.28153,216.35263 29.28153,189.94234 C 29.28153,163.53203 50.715979,142.09756 77.126289,142.09756 z" id="fedora-in" fill="none" stroke="#3c6eb4" strokeWidth="29.21"/>
|
||||
<path d="M 139.6074,127.52923 L 139.6074,189.87541 C 139.6074,224.37943 111.63203,252.35541 77.12679,252.35541 C 71.89185,252.35541 68.1703,251.7644 63.32444,250.49771 C 56.25849,248.64859 50.48398,242.85518 50.48158,236.1166 C 50.48158,227.97147 56.39394,222.0467 65.23187,222.0467 C 69.43824,222.0467 70.96454,222.85435 77.12679,222.85435 C 95.3184,222.85435 110.07443,208.11916 110.10634,189.92756 L 110.10634,161.27099 C 110.10634,158.70324 108.01971,156.62274 105.44767,156.62274 L 83.78246,156.61846 C 75.71034,156.61846 69.18845,150.18003 69.18845,142.0858 C 69.18414,133.94124 75.77725,127.52923 83.93653,127.52923" id="fedora-free" fill="#ffffff"/>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -159,7 +159,8 @@ export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: ()
|
||||
<SelectTrigger className="mt-1"><SelectValue placeholder={t('templates.select_template')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{available.map(a => (
|
||||
<SelectItem key={a.template} value={a.template}>{a.template}{a.headline ? ` — ${a.headline}` : ''}</SelectItem>
|
||||
// Show the friendly name only; the raw file name stays in the tooltip.
|
||||
<SelectItem key={a.template} value={a.template} title={a.template}>{a.headline || a.template}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -60,9 +60,9 @@ export default function TemplatesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={() => setDownloadOpen(true)}>
|
||||
<PackagePlus className="mr-2 h-4 w-4" />{t('templates.download_ct')}
|
||||
</Button>
|
||||
@@ -79,21 +79,21 @@ export default function TemplatesPage() {
|
||||
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Select value={groupFilter} onValueChange={v => { if (v !== null) setGroupFilter(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
|
||||
<SelectTrigger className="w-full sm:w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_groups')}</SelectItem>
|
||||
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={serverFilter} onValueChange={v => { if (v !== null) setServerFilter(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
|
||||
<SelectTrigger className="w-full sm:w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_servers')}</SelectItem>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={v => { if (v !== null) setTypeFilter(v); }}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue placeholder={t('templates.all_types')} /></SelectTrigger>
|
||||
<SelectTrigger className="w-full sm:w-[160px]"><SelectValue placeholder={t('templates.all_types')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_types')}</SelectItem>
|
||||
<SelectItem value="qemu">{t('templates.type_kvm')}</SelectItem>
|
||||
@@ -115,8 +115,8 @@ export default function TemplatesPage() {
|
||||
{filtered.map(tpl => (
|
||||
<Card key={tpl.id} className="group">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="shrink-0">
|
||||
{tpl.vm_type === 'qemu' ? (
|
||||
<OsLogo name={tpl.name} className="h-7 w-7" />
|
||||
) : tpl.group_icon ? (
|
||||
@@ -124,12 +124,12 @@ export default function TemplatesPage() {
|
||||
) : (
|
||||
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{tpl.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate" title={tpl.name}>{tpl.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
119
frontend/src/features/topology/TopologyCanvas.tsx
Normal file
119
frontend/src/features/topology/TopologyCanvas.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TopologyLegend } from './TopologyLegend';
|
||||
import { ClusterNode } from './nodes/ClusterNode';
|
||||
import { GuestGroupNode } from './nodes/GuestGroupNode';
|
||||
import { GuestNode } from './nodes/GuestNode';
|
||||
import { PanelNode } from './nodes/PanelNode';
|
||||
import { PveNode } from './nodes/PveNode';
|
||||
import './topology.css';
|
||||
|
||||
// Declared at module scope: recreating this object on every render makes React
|
||||
// Flow remount every node and the canvas crawls.
|
||||
const nodeTypes = {
|
||||
panel: PanelNode,
|
||||
cluster: ClusterNode,
|
||||
pve: PveNode,
|
||||
guest: GuestNode,
|
||||
guestGroup: GuestGroupNode,
|
||||
} as const;
|
||||
|
||||
const defaultEdgeOptions = { type: 'smoothstep' as const, animated: true };
|
||||
|
||||
// Above this many nodes React Flow only mounts what is on screen, and the
|
||||
// minimap (which renders every node again) is dropped.
|
||||
const VIRTUALIZE_ABOVE = 150;
|
||||
const MINIMAP_LIMIT = 400;
|
||||
|
||||
interface TopologyCanvasProps {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
onExpandNode: (nodeId: string) => void;
|
||||
/** Rendered inside the canvas only in fullscreen, where the page chrome is gone. */
|
||||
toolbar?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TopologyCanvas({ nodes, edges, onExpandNode, toolbar }: TopologyCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
// The panel drives its theme through this store, not next-themes (which
|
||||
// ships only as a sonner dependency and has no provider mounted here).
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// The group node's expand button lives in node data; buildGraph is pure and
|
||||
// cannot know about the callback, so it is attached here.
|
||||
const decorated = useMemo(
|
||||
() =>
|
||||
nodes.map((node) =>
|
||||
node.type === 'guestGroup'
|
||||
? { ...node, data: { ...node.data, onExpand: onExpandNode } }
|
||||
: node,
|
||||
),
|
||||
[nodes, onExpandNode],
|
||||
);
|
||||
|
||||
const handleInit = useCallback(() => {
|
||||
fitView({ padding: 0.15, duration: 0 });
|
||||
}, [fitView]);
|
||||
|
||||
// Filtering, grouping or expanding changes how much space the tree needs, so
|
||||
// refit whenever the node count moves — otherwise the graph drifts off-screen
|
||||
// or stays zoomed out after a filter shrinks it.
|
||||
const nodeCount = decorated.length;
|
||||
useEffect(() => {
|
||||
if (nodeCount === 0) return;
|
||||
const timer = window.setTimeout(() => fitView({ padding: 0.15, duration: 300 }), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [nodeCount, fitView]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={decorated}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
colorMode={theme === 'light' ? 'light' : 'dark'}
|
||||
onInit={handleInit}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.05}
|
||||
maxZoom={2}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
zoomOnDoubleClick={false}
|
||||
proOptions={{ hideAttribution: false }}
|
||||
onlyRenderVisibleElements={decorated.length > VIRTUALIZE_ABOVE}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={18} size={1} />
|
||||
{/* Top-right keeps the zoom buttons clear of the toolbar and the legend. */}
|
||||
<Controls showInteractive={false} position="top-right" orientation="horizontal" />
|
||||
{decorated.length <= MINIMAP_LIMIT ? <MiniMap pannable zoomable className="!h-24 !w-40" /> : null}
|
||||
{toolbar ? (
|
||||
<Panel position="top-left" className="!m-3 max-w-[calc(100%-1.5rem)]">
|
||||
{toolbar}
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel position="bottom-left" className="!m-3">
|
||||
<TopologyLegend />
|
||||
</Panel>
|
||||
{decorated.length === 0 ? (
|
||||
<Panel position="top-center" className="!mt-24">
|
||||
<p className="text-sm text-muted-foreground">{t('topology.empty', 'No servers available')}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
42
frontend/src/features/topology/TopologyLegend.tsx
Normal file
42
frontend/src/features/topology/TopologyLegend.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Boxes, Monitor, Network, Server, WifiOff } from 'lucide-react';
|
||||
|
||||
const ITEMS = [
|
||||
{ key: 'panel', Icon: Boxes, className: 'text-primary' },
|
||||
{ key: 'cluster', Icon: Network, className: 'text-muted-foreground' },
|
||||
{ key: 'node', Icon: Server, className: 'text-muted-foreground' },
|
||||
{ key: 'vm', Icon: Monitor, className: 'text-emerald-500' },
|
||||
{ key: 'lxc', Icon: Box, className: 'text-emerald-500' },
|
||||
{ key: 'stale', Icon: WifiOff, className: 'text-amber-500' },
|
||||
] as const;
|
||||
|
||||
export function TopologyLegend() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-export-ignore="true"
|
||||
className="rounded-lg border border-border bg-card/95 px-3 py-2 shadow-sm backdrop-blur"
|
||||
>
|
||||
<div className="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('topology.legend.title', 'Legend')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ITEMS.map(({ key, Icon, className }) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Icon className={`h-3 w-3 shrink-0 ${className}`} />
|
||||
<span>{t(`topology.legend.${key}`, key)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span>{t('topology.legend.running', 'Running')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-muted-foreground/50" />
|
||||
<span>{t('topology.legend.stopped', 'Stopped')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
frontend/src/features/topology/TopologyPage.tsx
Normal file
196
frontend/src/features/topology/TopologyPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useNetworkTopology } from '@/hooks/use-topology';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TopologyCanvas } from './TopologyCanvas';
|
||||
import { TopologyToolbar } from './TopologyToolbar';
|
||||
import { buildGraph, listConnections } from './lib/build-graph';
|
||||
import { exportGraph } from './lib/export';
|
||||
import type { TopologyFilters } from './lib/types';
|
||||
|
||||
const THRESHOLD_STORAGE_KEY = 'pve-topology-threshold';
|
||||
const DEFAULT_THRESHOLD = 20;
|
||||
// Past this many guests an ungrouped graph is unusable, so grouping is forced.
|
||||
const FORCE_GROUPING_ABOVE = 1500;
|
||||
const EXPORT_NODE_LIMIT = 1200;
|
||||
|
||||
function readStoredThreshold(): number {
|
||||
// Guard the missing key explicitly: Number(null) is 0, which would silently
|
||||
// turn "never stored" into "grouping off" on a first visit.
|
||||
const raw = localStorage.getItem(THRESHOLD_STORAGE_KEY);
|
||||
if (raw === null) return DEFAULT_THRESHOLD;
|
||||
const stored = Number(raw);
|
||||
return Number.isFinite(stored) && stored >= 0 && stored <= 100 ? stored : DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
function TopologyView() {
|
||||
const { t } = useTranslation();
|
||||
const { fitView, getNodesBounds } = useReactFlow();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<TopologyFilters>(() => ({
|
||||
serverId: 'all',
|
||||
vmStatus: 'all',
|
||||
groupThreshold: readStoredThreshold(),
|
||||
groupMode: 'hierarchy',
|
||||
}));
|
||||
|
||||
// The whole tree is fetched once and every filter — connection included — is
|
||||
// applied in buildGraph. Putting the connection in the query key instead
|
||||
// would blank the graph on each switch while the new response is in flight.
|
||||
const { data, isLoading, error, refetch, isFetching } = useNetworkTopology();
|
||||
|
||||
const tooManyGuests = (data?.panel.guest_count ?? 0) > FORCE_GROUPING_ABOVE;
|
||||
const effectiveFilters = useMemo<TopologyFilters>(
|
||||
() =>
|
||||
tooManyGuests && filters.groupThreshold === 0
|
||||
? { ...filters, groupThreshold: DEFAULT_THRESHOLD }
|
||||
: filters,
|
||||
[filters, tooManyGuests],
|
||||
);
|
||||
|
||||
const { nodes, edges } = useMemo(
|
||||
() => buildGraph(data, effectiveFilters, expanded),
|
||||
[data, effectiveFilters, expanded],
|
||||
);
|
||||
const connections = useMemo(() => listConnections(data), [data]);
|
||||
|
||||
const handleFiltersChange = useCallback((next: TopologyFilters) => {
|
||||
setFilters((previous) => {
|
||||
// Expanding is per-node state; a different connection shows other nodes.
|
||||
if (previous.serverId !== next.serverId) setExpanded(new Set());
|
||||
if (previous.groupThreshold !== next.groupThreshold) {
|
||||
localStorage.setItem(THRESHOLD_STORAGE_KEY, String(next.groupThreshold));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpandNode = useCallback((nodeId: string) => {
|
||||
setExpanded((previous) => new Set(previous).add(nodeId));
|
||||
}, []);
|
||||
|
||||
const handleFit = useCallback(() => {
|
||||
fitView({ padding: 0.15, duration: 300 });
|
||||
}, [fitView]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(document.fullscreenElement === wrapperRef.current);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
if (document.fullscreenElement === wrapperRef.current) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void wrapperRef.current?.requestFullscreen();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(format: 'png' | 'svg') => {
|
||||
// getNodesBounds must come from the hook (not the standalone helper) so
|
||||
// it reads each node's actual measured width/height instead of zero.
|
||||
const bounds = getNodesBounds(nodes);
|
||||
toast.promise(exportGraph(bounds, nodes.length > 0, format, 'topology'), {
|
||||
loading: t('topology.export.progress', 'Rendering image…'),
|
||||
success: t('topology.export.done', 'Image saved'),
|
||||
error: t('topology.export.failed', 'Export failed'),
|
||||
});
|
||||
},
|
||||
[nodes, getNodesBounds, t],
|
||||
);
|
||||
|
||||
const toolbar = (
|
||||
<TopologyToolbar
|
||||
filters={effectiveFilters}
|
||||
onChange={handleFiltersChange}
|
||||
connections={connections}
|
||||
isFullscreen={isFullscreen}
|
||||
isFetching={isFetching}
|
||||
onFit={handleFit}
|
||||
onFullscreen={handleFullscreen}
|
||||
onRefresh={() => void refetch()}
|
||||
onExport={handleExport}
|
||||
exportDisabled={nodes.length === 0 || nodes.length > EXPORT_NODE_LIMIT}
|
||||
/>
|
||||
);
|
||||
|
||||
const warnings = data?.warnings ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('topology.title', 'Network topology')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('topology.subtitle', 'Hierarchical map of clusters, nodes and guests')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<div className="space-y-1 rounded-lg border border-amber-500/40 bg-amber-500/5 px-3 py-2">
|
||||
{warnings.map((warning, index) => (
|
||||
<div key={`${warning.code}-${index}`} className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{warning.code === 'offline'
|
||||
? t('topology.warning.offline', { server: warning.server_name ?? '' })
|
||||
: warning.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tooManyGuests ? (
|
||||
<p className="text-xs text-muted-foreground">{t('topology.too_many', 'Too many objects; grouping enabled')}</p>
|
||||
) : null}
|
||||
|
||||
{/* Above the canvas normally; in fullscreen it moves inside, where it is
|
||||
the only way to reach the filters. */}
|
||||
{isFullscreen ? null : toolbar}
|
||||
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn(
|
||||
'topology-canvas overflow-hidden border border-border bg-background',
|
||||
// In fullscreen the browser sizes the element, so the page-layout
|
||||
// height (and the rounded frame) would only leave a dead strip.
|
||||
isFullscreen ? 'h-full w-full' : 'h-[calc(100vh-19rem)] min-h-[480px] rounded-lg',
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full flex-col gap-3 p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-destructive">
|
||||
{t('topology.error', 'Failed to load topology')}
|
||||
</div>
|
||||
) : (
|
||||
<TopologyCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onExpandNode={handleExpandNode}
|
||||
toolbar={isFullscreen ? toolbar : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopologyPage() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<TopologyView />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
154
frontend/src/features/topology/TopologyToolbar.tsx
Normal file
154
frontend/src/features/topology/TopologyToolbar.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Maximize2, Minimize2, RefreshCw, Scan } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TopologyFilters, VmStatusFilter } from './lib/types';
|
||||
|
||||
const STATUSES: VmStatusFilter[] = ['all', 'running', 'stopped'];
|
||||
|
||||
interface TopologyToolbarProps {
|
||||
filters: TopologyFilters;
|
||||
onChange: (filters: TopologyFilters) => void;
|
||||
connections: Array<{ id: number; name: string }>;
|
||||
isFullscreen: boolean;
|
||||
isFetching: boolean;
|
||||
onFit: () => void;
|
||||
onFullscreen: () => void;
|
||||
onRefresh: () => void;
|
||||
onExport: (format: 'png' | 'svg') => void;
|
||||
exportDisabled: boolean;
|
||||
}
|
||||
|
||||
export function TopologyToolbar({
|
||||
filters,
|
||||
onChange,
|
||||
connections,
|
||||
isFullscreen,
|
||||
isFetching,
|
||||
onFit,
|
||||
onFullscreen,
|
||||
onRefresh,
|
||||
onExport,
|
||||
exportDisabled,
|
||||
}: TopologyToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-export-ignore="true"
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card/95 px-3 py-2 shadow-sm backdrop-blur"
|
||||
>
|
||||
<Select
|
||||
value={String(filters.serverId)}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...filters, serverId: value === 'all' ? 'all' : Number(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[190px] text-xs">
|
||||
<SelectValue placeholder={t('topology.filters.connection', 'Connection')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('topology.filters.all_connections', 'All connections')}</SelectItem>
|
||||
{connections.map((connection) => (
|
||||
<SelectItem key={connection.id} value={String(connection.id)}>
|
||||
{connection.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('topology.filters.vm_status', 'VM status')}:</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-border">
|
||||
{STATUSES.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...filters, vmStatus: status })}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs transition-colors',
|
||||
filters.vmStatus === status
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{t(`topology.filters.status_${status}`, status)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
||||
{t('topology.filters.threshold', 'Group VMs above')}
|
||||
</span>
|
||||
<Slider
|
||||
className="w-28"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={filters.groupThreshold}
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...filters, groupThreshold: Array.isArray(value) ? value[0] : value })
|
||||
}
|
||||
/>
|
||||
<span className="w-8 text-xs tabular-nums text-muted-foreground">
|
||||
{filters.groupThreshold === 0 ? t('topology.filters.threshold_off', 'Off') : filters.groupThreshold}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh} title={t('topology.actions.refresh', 'Refresh')}>
|
||||
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onFit} title={t('topology.actions.fit', 'Fit to screen')}>
|
||||
<Scan className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={exportDisabled}
|
||||
title={t('topology.actions.export', 'Export')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onExport('png')}>
|
||||
{t('topology.actions.export_png', 'Export PNG')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onExport('svg')} title={t('topology.export.svg_hint', '')}>
|
||||
{t('topology.actions.export_svg', 'Export SVG')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onFullscreen}
|
||||
title={
|
||||
isFullscreen
|
||||
? t('topology.actions.exit_fullscreen', 'Exit fullscreen')
|
||||
: t('topology.actions.fullscreen', 'Fullscreen')
|
||||
}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
frontend/src/features/topology/lib/build-graph.ts
Normal file
158
frontend/src/features/topology/lib/build-graph.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Edge, Node } from '@xyflow/react';
|
||||
import { layoutTree, type LayoutNode } from './layout';
|
||||
import type { NetworkTopology, TopologyFilters, TopologyGuest } from './types';
|
||||
|
||||
const PANEL_ID = 'panel:root';
|
||||
// Wrapping earlier keeps the tree closer to the viewport's aspect ratio:
|
||||
// one wide row per node forces fitView to shrink everything to a ribbon.
|
||||
const GUESTS_PER_ROW = 6;
|
||||
|
||||
export interface BuildGraphResult {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
/** Guests left out by the status filter — shown in the toolbar counter. */
|
||||
filteredOut: number;
|
||||
}
|
||||
|
||||
function matchesStatus(guest: TopologyGuest, filter: TopologyFilters['vmStatus']): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'running') return guest.status === 'running';
|
||||
return guest.status !== 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the API hierarchy into React Flow nodes and edges.
|
||||
*
|
||||
* Pure and synchronous so it can live inside a `useMemo` and rerun on every
|
||||
* filter or slider change. Edges are derived from nesting — the API does not
|
||||
* ship them.
|
||||
*/
|
||||
export function buildGraph(
|
||||
data: NetworkTopology | undefined,
|
||||
filters: TopologyFilters,
|
||||
expanded: Set<string>,
|
||||
): BuildGraphResult {
|
||||
if (!data) return { nodes: [], edges: [], filteredOut: 0 };
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
let filteredOut = 0;
|
||||
let widestGuestList = 0;
|
||||
|
||||
const tree: LayoutNode = { id: PANEL_ID, kind: 'panel', children: [] };
|
||||
|
||||
const clusters =
|
||||
filters.serverId === 'all'
|
||||
? data.clusters
|
||||
: data.clusters.filter((cluster) => cluster.server_ids.includes(filters.serverId as number));
|
||||
|
||||
nodes.push({
|
||||
id: PANEL_ID,
|
||||
type: 'panel',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { panel: data.panel },
|
||||
draggable: false,
|
||||
});
|
||||
|
||||
for (const cluster of clusters) {
|
||||
const clusterBranch: LayoutNode = { id: cluster.id, kind: 'cluster', children: [] };
|
||||
const pveNodes =
|
||||
filters.serverId === 'all'
|
||||
? cluster.nodes
|
||||
: cluster.nodes.filter((node) => node.server_id === filters.serverId);
|
||||
if (pveNodes.length === 0) continue;
|
||||
|
||||
let clusterGuests = 0;
|
||||
|
||||
for (const node of pveNodes) {
|
||||
const visible = node.guests.filter((guest) => matchesStatus(guest, filters.vmStatus));
|
||||
filteredOut += node.guests.length - visible.length;
|
||||
clusterGuests += visible.length;
|
||||
|
||||
const nodeBranch: LayoutNode = { id: node.id, kind: 'pve', children: [] };
|
||||
|
||||
const collapse =
|
||||
filters.groupThreshold > 0 &&
|
||||
visible.length > filters.groupThreshold &&
|
||||
!expanded.has(node.id);
|
||||
|
||||
if (collapse) {
|
||||
const groupId = `${node.id}:group`;
|
||||
const running = visible.filter((guest) => guest.status === 'running').length;
|
||||
nodes.push({
|
||||
id: groupId,
|
||||
type: 'guestGroup',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
parentId: node.id,
|
||||
total: visible.length,
|
||||
running,
|
||||
stopped: visible.length - running,
|
||||
},
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${node.id}:${groupId}`, source: node.id, target: groupId });
|
||||
nodeBranch.children.push({ id: groupId, kind: 'guestGroup', children: [] });
|
||||
} else {
|
||||
widestGuestList = Math.max(widestGuestList, visible.length);
|
||||
for (const guest of visible) {
|
||||
nodes.push({
|
||||
id: guest.id,
|
||||
type: 'guest',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { guest },
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${node.id}:${guest.id}`, source: node.id, target: guest.id });
|
||||
nodeBranch.children.push({ id: guest.id, kind: 'guest', children: [] });
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: 'pve',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { node, guestCount: visible.length },
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${cluster.id}:${node.id}`, source: cluster.id, target: node.id });
|
||||
clusterBranch.children.push(nodeBranch);
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: cluster.id,
|
||||
type: 'cluster',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { cluster, nodeCount: pveNodes.length, guestCount: clusterGuests },
|
||||
draggable: false,
|
||||
});
|
||||
edges.push({ id: `e:${PANEL_ID}:${cluster.id}`, source: PANEL_ID, target: cluster.id });
|
||||
tree.children.push(clusterBranch);
|
||||
}
|
||||
|
||||
// A single row of dozens of guests makes the graph a thin unreadable
|
||||
// ribbon, so long lists wrap into a block once they exceed this.
|
||||
const positions = layoutTree(tree, {
|
||||
guestsPerRow: widestGuestList > GUESTS_PER_ROW + 2 ? GUESTS_PER_ROW : undefined,
|
||||
});
|
||||
for (const node of nodes) {
|
||||
const position = positions.get(node.id);
|
||||
if (position) node.position = position;
|
||||
}
|
||||
|
||||
return { nodes, edges, filteredOut };
|
||||
}
|
||||
|
||||
/** Connections present in the response, for the toolbar's connection filter. */
|
||||
export function listConnections(data: NetworkTopology | undefined): Array<{ id: number; name: string }> {
|
||||
if (!data) return [];
|
||||
const byId = new Map<number, string>();
|
||||
for (const cluster of data.clusters) {
|
||||
for (const node of cluster.nodes) {
|
||||
if (!byId.has(node.server_id)) byId.set(node.server_id, node.server_name);
|
||||
}
|
||||
}
|
||||
return [...byId.entries()]
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
102
frontend/src/features/topology/lib/export.ts
Normal file
102
frontend/src/features/topology/lib/export.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { getViewportForBounds, type Rect } from '@xyflow/react';
|
||||
import { toPng, toSvg } from 'html-to-image';
|
||||
|
||||
const MAX_SIDE = 4096;
|
||||
|
||||
/**
|
||||
* Saves the graph as an image.
|
||||
*
|
||||
* Custom nodes are HTML, so a native SVG serialisation is impossible — the SVG
|
||||
* variant wraps the markup in `<foreignObject>`, which browsers render fine but
|
||||
* vector editors only partially. PNG is the default for that reason.
|
||||
*
|
||||
* The capture always covers the whole graph rather than the current viewport,
|
||||
* which is what people expect from an export button.
|
||||
*
|
||||
* `bounds` must come from the `useReactFlow().getNodesBounds()` hook, not the
|
||||
* standalone `getNodesBounds` helper - the standalone version has no access to
|
||||
* each node's measured width/height and silently treats them as zero, which
|
||||
* undersizes the canvas and clips the last row/column of the graph.
|
||||
*/
|
||||
export async function exportGraph(
|
||||
bounds: Rect,
|
||||
hasNodes: boolean,
|
||||
format: 'png' | 'svg',
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const viewport = document.querySelector<HTMLElement>('.react-flow__viewport');
|
||||
if (!viewport || !hasNodes) return;
|
||||
|
||||
// Edges live inside a nested <svg> (`.react-flow__edges`). html-to-image
|
||||
// clones any <svg> with the browser's native `cloneNode(true)` instead of
|
||||
// its own computed-style-inlining walk (see cloneChildren in
|
||||
// html-to-image/lib/clone-node.js, which returns early for svg elements),
|
||||
// so nothing that comes from an external stylesheet - including React
|
||||
// Flow's own `.react-flow__edge-path { stroke: var(--xy-edge-stroke) }` -
|
||||
// survives into the clone. The path has no inline style of its own, so the
|
||||
// exported edge ends up with no stroke at all. Give every edge element an
|
||||
// explicit inline stroke (its current resolved color) right before
|
||||
// capture, then revert once the capture is done.
|
||||
const edgeLayer = viewport.querySelector<SVGElement>('.react-flow__edges');
|
||||
const restoreEdgeStyles: Array<() => void> = [];
|
||||
if (edgeLayer) {
|
||||
const styledEls = edgeLayer.querySelectorAll<SVGElement>('path, circle, polygon, rect');
|
||||
styledEls.forEach((el) => {
|
||||
const computed = getComputedStyle(el);
|
||||
const prevStroke = el.style.stroke;
|
||||
const prevFill = el.style.fill;
|
||||
const stroke = computed.stroke;
|
||||
const fill = computed.fill;
|
||||
if (stroke && stroke !== 'none') el.style.stroke = stroke;
|
||||
if (fill && fill !== 'none') el.style.fill = fill;
|
||||
restoreEdgeStyles.push(() => {
|
||||
el.style.stroke = prevStroke;
|
||||
el.style.fill = prevFill;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const padding = 40;
|
||||
// Size the canvas to the graph itself, not an arbitrary minimum - forcing a
|
||||
// larger canvas than the content needs a zoom above the 2x cap to fill it,
|
||||
// so small graphs end up tiny in a mostly-empty image.
|
||||
const width = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.width + padding * 2)));
|
||||
const height = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.height + padding * 2)));
|
||||
// getViewportForBounds's padding is a fraction of width/height unless given
|
||||
// as a "px" string - a bare number here (e.g. 40) is read as 4000% padding,
|
||||
// which collapses the zoom to the floor and leaves the graph tiny.
|
||||
const transform = getViewportForBounds(bounds, width, height, 0.2, 2, `${padding}px`);
|
||||
|
||||
const background =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--background').trim() || '#0a0a0a';
|
||||
|
||||
const options = {
|
||||
backgroundColor: background,
|
||||
width,
|
||||
height,
|
||||
style: {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
|
||||
},
|
||||
// Controls and the minimap are UI chrome, not part of the diagram.
|
||||
filter: (element: HTMLElement) =>
|
||||
!element?.classList?.contains?.('react-flow__minimap') &&
|
||||
!element?.classList?.contains?.('react-flow__controls') &&
|
||||
!element?.dataset?.exportIgnore,
|
||||
cacheBust: true,
|
||||
pixelRatio: format === 'png' ? 2 : 1,
|
||||
};
|
||||
|
||||
let dataUrl: string;
|
||||
try {
|
||||
dataUrl = format === 'png' ? await toPng(viewport, options) : await toSvg(viewport, options);
|
||||
} finally {
|
||||
restoreEdgeStyles.forEach((restore) => restore());
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${filename}.${format}`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
153
frontend/src/features/topology/lib/layout.ts
Normal file
153
frontend/src/features/topology/lib/layout.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Tidy-tree layout for the topology graph.
|
||||
*
|
||||
* The graph is a strict tree of fixed depth (panel → cluster → node → guest),
|
||||
* so a dedicated layout engine would be all cost and no benefit: this is ~60
|
||||
* synchronous lines, deterministic, and O(n) — it can rerun on every slider
|
||||
* tick without the flicker an async layout pass introduces.
|
||||
*
|
||||
* Orientation is top-to-bottom: each depth level is a fixed row, siblings
|
||||
* spread out horizontally within it.
|
||||
*/
|
||||
|
||||
export type LayoutKind = 'panel' | 'cluster' | 'pve' | 'guest' | 'guestGroup';
|
||||
|
||||
export interface LayoutNode {
|
||||
id: string;
|
||||
kind: LayoutKind;
|
||||
children: LayoutNode[];
|
||||
}
|
||||
|
||||
export interface XY {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Fixed vertical position of every depth level. */
|
||||
export const ROW_Y: Record<LayoutKind, number> = {
|
||||
panel: 0,
|
||||
cluster: 200,
|
||||
pve: 420,
|
||||
guest: 640,
|
||||
guestGroup: 640,
|
||||
};
|
||||
|
||||
export const NODE_SIZE: Record<LayoutKind, { width: number; height: number }> = {
|
||||
panel: { width: 230, height: 92 },
|
||||
cluster: { width: 250, height: 96 },
|
||||
pve: { width: 270, height: 132 },
|
||||
guest: { width: 250, height: 84 },
|
||||
guestGroup: { width: 250, height: 96 },
|
||||
};
|
||||
|
||||
const GAP_X = 30;
|
||||
const SUBTREE_GAP = 40;
|
||||
const ROW_GAP_Y = 26;
|
||||
|
||||
export interface LayoutOptions {
|
||||
/**
|
||||
* Wrap a node's guests into extra rows once the list is longer than this.
|
||||
* Keeps a busy node from stretching the graph into an unreadable ribbon.
|
||||
*/
|
||||
guestsPerRow?: number;
|
||||
}
|
||||
|
||||
/** How many rows a node's guest list is wrapped into. */
|
||||
function rowsFor(node: LayoutNode, opts: LayoutOptions): number {
|
||||
if (node.kind !== 'pve' || !opts.guestsPerRow) return 1;
|
||||
return Math.max(1, Math.ceil(node.children.length / opts.guestsPerRow));
|
||||
}
|
||||
|
||||
/** Horizontal space a subtree needs, including the gaps between its children. */
|
||||
function subtreeWidth(node: LayoutNode, opts: LayoutOptions): number {
|
||||
const own = NODE_SIZE[node.kind].width;
|
||||
if (node.children.length === 0) return own;
|
||||
|
||||
const rows = rowsFor(node, opts);
|
||||
if (rows > 1) {
|
||||
const perRow = Math.ceil(node.children.length / rows);
|
||||
const widest = Math.max(...node.children.map((c) => subtreeWidth(c, opts)));
|
||||
return Math.max(own, perRow * widest + (perRow - 1) * GAP_X);
|
||||
}
|
||||
|
||||
const childrenWidth = node.children.reduce(
|
||||
(sum, child, index) => sum + subtreeWidth(child, opts) + (index > 0 ? GAP_X : 0),
|
||||
0,
|
||||
);
|
||||
return Math.max(own, childrenWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute position of every node, keyed by id. Parents are centred on the
|
||||
* horizontal span of their children.
|
||||
*/
|
||||
export function layoutTree(root: LayoutNode, opts: LayoutOptions = {}): Map<string, XY> {
|
||||
const positions = new Map<string, XY>();
|
||||
|
||||
function place(node: LayoutNode, left: number): number {
|
||||
const size = NODE_SIZE[node.kind];
|
||||
const width = subtreeWidth(node, opts);
|
||||
const y = ROW_Y[node.kind];
|
||||
|
||||
if (node.children.length === 0) {
|
||||
positions.set(node.id, { x: left + (width - size.width) / 2, y });
|
||||
return width;
|
||||
}
|
||||
|
||||
const rows = rowsFor(node, opts);
|
||||
|
||||
if (rows > 1) {
|
||||
// Wrap the guest list into a block so a busy node stays readable.
|
||||
const perRow = Math.ceil(node.children.length / rows);
|
||||
const childHeight = NODE_SIZE[node.children[0].kind].height;
|
||||
node.children.forEach((child, index) => {
|
||||
const row = Math.floor(index / perRow);
|
||||
const col = index % perRow;
|
||||
const childWidth = NODE_SIZE[child.kind].width;
|
||||
positions.set(child.id, {
|
||||
x: left + col * (childWidth + GAP_X),
|
||||
y: ROW_Y[child.kind] + row * (childHeight + ROW_GAP_Y),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
let cursor = left;
|
||||
node.children.forEach((child, index) => {
|
||||
if (index > 0) cursor += GAP_X;
|
||||
cursor += place(child, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
positions.set(node.id, { x: left + (width - size.width) / 2, y });
|
||||
return width;
|
||||
}
|
||||
|
||||
place(root, 0);
|
||||
|
||||
// Separate top-level subtrees a little more than siblings inside one.
|
||||
let extra = 0;
|
||||
for (const cluster of root.children) {
|
||||
if (extra > 0) shift(cluster, extra, positions);
|
||||
extra += SUBTREE_GAP;
|
||||
}
|
||||
if (root.children.length > 0) {
|
||||
const clusterPositions = root.children
|
||||
.map((c) => positions.get(c.id))
|
||||
.filter((p): p is XY => Boolean(p));
|
||||
if (clusterPositions.length > 0) {
|
||||
const first = clusterPositions[0].x;
|
||||
const last = clusterPositions[clusterPositions.length - 1].x;
|
||||
positions.set(root.id, {
|
||||
x: (first + last) / 2 + (NODE_SIZE.cluster.width - NODE_SIZE.panel.width) / 2,
|
||||
y: ROW_Y.panel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function shift(node: LayoutNode, delta: number, positions: Map<string, XY>): void {
|
||||
const position = positions.get(node.id);
|
||||
if (position) positions.set(node.id, { ...position, x: position.x + delta });
|
||||
for (const child of node.children) shift(child, delta, positions);
|
||||
}
|
||||
137
frontend/src/features/topology/lib/types.ts
Normal file
137
frontend/src/features/topology/lib/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/** Shapes returned by GET /proxmox/api/network/topology (backend schemas.py). */
|
||||
|
||||
export interface TopologyNic {
|
||||
key: string;
|
||||
model?: string | null;
|
||||
mac?: string | null;
|
||||
bridge?: string | null;
|
||||
vlan_tag?: number | null;
|
||||
trunks: number[];
|
||||
firewall: boolean;
|
||||
link_down: boolean;
|
||||
name?: string | null;
|
||||
ip?: string | null;
|
||||
gw?: string | null;
|
||||
rate?: number | null;
|
||||
mtu?: number | null;
|
||||
}
|
||||
|
||||
export interface TopologyBridge {
|
||||
name: string;
|
||||
type?: string | null;
|
||||
cidr?: string | null;
|
||||
vlan_aware: boolean;
|
||||
ports?: string | null;
|
||||
active: boolean;
|
||||
zone?: string | null;
|
||||
ipam_network_id?: number | null;
|
||||
ipam_cidr?: string | null;
|
||||
ipam_name?: string | null;
|
||||
}
|
||||
|
||||
export interface TopologyGuest {
|
||||
id: string;
|
||||
server_id: number;
|
||||
node: string;
|
||||
vmid: number;
|
||||
type: 'qemu' | 'lxc' | string;
|
||||
name: string;
|
||||
status: string;
|
||||
is_template: boolean;
|
||||
lock?: string | null;
|
||||
cpu?: number | null;
|
||||
cores?: number | null;
|
||||
mem?: number | null;
|
||||
maxmem?: number | null;
|
||||
disk?: number | null;
|
||||
maxdisk?: number | null;
|
||||
uptime?: number | null;
|
||||
tags: string[];
|
||||
owner_id?: number | null;
|
||||
owner_username?: string | null;
|
||||
ip?: string | null;
|
||||
nics: TopologyNic[];
|
||||
}
|
||||
|
||||
export interface TopologyNode {
|
||||
id: string;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
node: string;
|
||||
status: string;
|
||||
/** Rebuilt from the local VM cache because the connection is unreachable. */
|
||||
stale: boolean;
|
||||
cpu?: number | null;
|
||||
maxcpu?: number | null;
|
||||
mem?: number | null;
|
||||
maxmem?: number | null;
|
||||
uptime?: number | null;
|
||||
bridges: TopologyBridge[];
|
||||
guests: TopologyGuest[];
|
||||
}
|
||||
|
||||
export interface TopologyCluster {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'cluster' | 'standalone' | string;
|
||||
server_ids: number[];
|
||||
online: boolean;
|
||||
nodes: TopologyNode[];
|
||||
}
|
||||
|
||||
export interface TopologyWarning {
|
||||
server_id?: number | null;
|
||||
server_name?: string | null;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TopologyPanelSummary {
|
||||
name: string;
|
||||
cluster_count: number;
|
||||
node_count: number;
|
||||
guest_count: number;
|
||||
}
|
||||
|
||||
export interface NetworkTopology {
|
||||
generated_at: string;
|
||||
panel: TopologyPanelSummary;
|
||||
clusters: TopologyCluster[];
|
||||
warnings: TopologyWarning[];
|
||||
}
|
||||
|
||||
export type VmStatusFilter = 'all' | 'running' | 'stopped';
|
||||
|
||||
/** Grouping mode. Only `hierarchy` ships today; VLAN/tag views reuse this switch. */
|
||||
export type GroupMode = 'hierarchy';
|
||||
|
||||
export interface TopologyFilters {
|
||||
serverId: number | 'all';
|
||||
vmStatus: VmStatusFilter;
|
||||
/** Collapse a node's guests into one badge above this count. 0 disables it. */
|
||||
groupThreshold: number;
|
||||
groupMode: GroupMode;
|
||||
}
|
||||
|
||||
/** Data carried by each React Flow node type. */
|
||||
export interface PanelNodeData extends Record<string, unknown> {
|
||||
panel: TopologyPanelSummary;
|
||||
}
|
||||
export interface ClusterNodeData extends Record<string, unknown> {
|
||||
cluster: TopologyCluster;
|
||||
nodeCount: number;
|
||||
guestCount: number;
|
||||
}
|
||||
export interface PveNodeData extends Record<string, unknown> {
|
||||
node: TopologyNode;
|
||||
guestCount: number;
|
||||
}
|
||||
export interface GuestNodeData extends Record<string, unknown> {
|
||||
guest: TopologyGuest;
|
||||
}
|
||||
export interface GuestGroupNodeData extends Record<string, unknown> {
|
||||
parentId: string;
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
}
|
||||
48
frontend/src/features/topology/nodes/ClusterNode.tsx
Normal file
48
frontend/src/features/topology/nodes/ClusterNode.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Network, Server } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ClusterNodeData } from '../lib/types';
|
||||
|
||||
function ClusterNodeImpl({ data }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { cluster, nodeCount, guestCount } = data as ClusterNodeData;
|
||||
const isCluster = cluster.kind === 'cluster';
|
||||
const Icon = isCluster ? Network : Server;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[250px] rounded-lg border bg-card px-3 py-2.5 shadow-sm',
|
||||
cluster.online ? 'border-emerald-500/60' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">{cluster.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto h-2 w-2 shrink-0 rounded-full',
|
||||
cluster.online ? 'bg-emerald-500' : 'bg-muted-foreground/50',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{isCluster
|
||||
? t('topology.legend.cluster', 'Cluster')
|
||||
: t('topology.legend.standalone', 'Standalone')}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('topology.node.nodes_count', { count: nodeCount })} · {t('topology.node.guests_count', { count: guestCount })}
|
||||
</span>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClusterNode = memo(ClusterNodeImpl);
|
||||
38
frontend/src/features/topology/nodes/GuestGroupNode.tsx
Normal file
38
frontend/src/features/topology/nodes/GuestGroupNode.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layers } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { GuestGroupNodeData } from '../lib/types';
|
||||
|
||||
interface GuestGroupNodeProps extends NodeProps {
|
||||
data: GuestGroupNodeData & { onExpand?: (nodeId: string) => void };
|
||||
}
|
||||
|
||||
function GuestGroupNodeImpl({ data }: GuestGroupNodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { parentId, total, running, stopped, onExpand } = data;
|
||||
|
||||
return (
|
||||
<div className="w-[250px] rounded-md border border-dashed border-primary/60 bg-card px-2.5 py-2 shadow-sm">
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!bg-border" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="text-xs font-medium">{t('topology.group.count', { count: total })}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{t('topology.group.breakdown', { running, stopped })}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-6 w-full text-[10px]"
|
||||
onClick={() => onExpand?.(parentId)}
|
||||
>
|
||||
{t('topology.group.expand', 'Show all')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GuestGroupNode = memo(GuestGroupNodeImpl);
|
||||
54
frontend/src/features/topology/nodes/GuestNode.tsx
Normal file
54
frontend/src/features/topology/nodes/GuestNode.tsx
Normal 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);
|
||||
27
frontend/src/features/topology/nodes/PanelNode.tsx
Normal file
27
frontend/src/features/topology/nodes/PanelNode.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Boxes } from 'lucide-react';
|
||||
import type { PanelNodeData } from '../lib/types';
|
||||
|
||||
function PanelNodeImpl({ data }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { panel } = data as PanelNodeData;
|
||||
|
||||
return (
|
||||
<div className="w-[230px] rounded-lg border-2 border-primary bg-card px-3 py-2.5 shadow-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="h-5 w-5 shrink-0 text-primary" />
|
||||
<span className="truncate text-sm font-semibold">{panel.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{t('topology.node.clusters_count', { count: panel.cluster_count })}</span>
|
||||
<span>{t('topology.node.nodes_count', { count: panel.node_count })}</span>
|
||||
<span>{t('topology.node.guests_count', { count: panel.guest_count })}</span>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PanelNode = memo(PanelNodeImpl);
|
||||
79
frontend/src/features/topology/nodes/PveNode.tsx
Normal file
79
frontend/src/features/topology/nodes/PveNode.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, WifiOff } from 'lucide-react';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PveNodeData } from '../lib/types';
|
||||
|
||||
function Meter({ label, value, hint }: { label: string; value: number | null; hint?: string }) {
|
||||
const percent = value === null ? 0 : Math.min(100, Math.max(0, value * 100));
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span>{value === null ? '—' : `${percent.toFixed(0)}%`}{hint ? ` · ${hint}` : ''}</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-input">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-[width]',
|
||||
percent > 90 ? 'bg-red-500' : percent > 70 ? 'bg-amber-500' : 'bg-primary',
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PveNodeImpl({ data }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, guestCount } = data as PveNodeData;
|
||||
const online = node.status === 'online';
|
||||
const memRatio = node.mem && node.maxmem ? node.mem / node.maxmem : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[270px] rounded-lg border bg-card px-3 py-2.5 shadow-sm',
|
||||
node.stale ? 'border-dashed border-amber-500/60' : online ? 'border-emerald-500/50' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">{node.node}</span>
|
||||
{node.stale ? (
|
||||
<WifiOff className="ml-auto h-3.5 w-3.5 shrink-0 text-amber-500" aria-label={t('topology.legend.stale', 'Cached data')} />
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto h-2 w-2 shrink-0 rounded-full',
|
||||
online ? 'bg-emerald-500' : 'bg-muted-foreground/50',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span className="truncate">{node.server_name}</span>
|
||||
<span>{t('topology.node.guests_count', { count: guestCount })}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Meter
|
||||
label={t('topology.node.cpu', 'CPU')}
|
||||
value={node.cpu ?? null}
|
||||
hint={node.maxcpu ? `${node.maxcpu} vCPU` : undefined}
|
||||
/>
|
||||
<Meter
|
||||
label={t('topology.node.ram', 'RAM')}
|
||||
value={memRatio}
|
||||
hint={node.maxmem ? formatBytes(node.maxmem, 0) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PveNode = memo(PveNodeImpl);
|
||||
35
frontend/src/features/topology/topology.css
Normal file
35
frontend/src/features/topology/topology.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/* React Flow chrome follows the panel's design tokens, in both themes. */
|
||||
.topology-canvas .react-flow {
|
||||
--xy-background-color: var(--background);
|
||||
--xy-edge-stroke: var(--primary);
|
||||
--xy-edge-stroke-selected: var(--primary);
|
||||
--xy-node-border-radius: var(--radius);
|
||||
--xy-controls-button-background-color: var(--card);
|
||||
--xy-controls-button-background-color-hover: var(--accent);
|
||||
--xy-controls-button-color: var(--foreground);
|
||||
--xy-controls-button-color-hover: var(--foreground);
|
||||
--xy-controls-button-border-color: var(--border);
|
||||
--xy-minimap-background-color: var(--card);
|
||||
--xy-minimap-mask-background-color-props: color-mix(in oklab, var(--background) 70%, transparent);
|
||||
--xy-attribution-background-color: transparent;
|
||||
}
|
||||
|
||||
/* Nodes bring their own card styling; the default white box would fight it. */
|
||||
.topology-canvas .react-flow__node {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.topology-canvas .react-flow__handle {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.topology-canvas .react-flow__attribution {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getTasksWebSocket } from '@/lib/websocket';
|
||||
import { vmKeys } from './use-instances';
|
||||
import { nodeKeys } from './use-nodes';
|
||||
import { topologyKeys } from './use-topology';
|
||||
|
||||
/**
|
||||
* Глобальная подписка на real-time события WebSocket, которая инвалидирует
|
||||
@@ -41,11 +42,13 @@ export function useGlobalRealtimeSync() {
|
||||
old?.filter((vm) => !(vm.server_id === msg.server_id && vm.vmid === msg.vmid))
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
qc.invalidateQueries({ queryKey: topologyKeys.all });
|
||||
});
|
||||
|
||||
const unsubVmCreated = ws.onType('vm_created', () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
qc.invalidateQueries({ queryKey: topologyKeys.all });
|
||||
});
|
||||
|
||||
const unsubVmOwnerChanged = ws.onType('vm_owner_changed', (data: unknown) => {
|
||||
@@ -73,6 +76,8 @@ export function useGlobalRealtimeSync() {
|
||||
// Список VM/LXC зависит от набора серверов и их online-статуса
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
// Топология рисует те же серверы/ноды — иначе граф останется старым
|
||||
qc.invalidateQueries({ queryKey: topologyKeys.all });
|
||||
};
|
||||
|
||||
const unsubServerAdded = ws.onType('server_added', invalidateServerCaches);
|
||||
|
||||
35
frontend/src/hooks/use-topology.ts
Normal file
35
frontend/src/hooks/use-topology.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { NetworkTopology } from '@/features/topology/lib/types';
|
||||
|
||||
export interface TopologyParams {
|
||||
serverId?: number;
|
||||
includeNics?: boolean;
|
||||
includeTemplates?: boolean;
|
||||
}
|
||||
|
||||
export const topologyKeys = {
|
||||
all: ['network-topology'] as const,
|
||||
filtered: (params: TopologyParams) => ['network-topology', params] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole infrastructure tree in one request.
|
||||
*
|
||||
* The VM status filter is applied client-side (see build-graph) rather than
|
||||
* through `include_stopped`, so flipping it never costs a round-trip.
|
||||
*/
|
||||
export function useNetworkTopology(params: TopologyParams = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.serverId !== undefined) query.set('server_id', String(params.serverId));
|
||||
if (params.includeNics) query.set('include_nics', 'true');
|
||||
if (params.includeTemplates) query.set('include_templates', 'true');
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
|
||||
return useQuery({
|
||||
queryKey: topologyKeys.filtered(params),
|
||||
queryFn: () => apiClient.get<NetworkTopology>(`/proxmox/api/network/topology${suffix}`),
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { useGlobalRealtimeSync } from '@/hooks/use-realtime-sync';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import NotificationsDropdown from '@/components/shared/NotificationsDropdown';
|
||||
import ActiveTasksIndicator from '@/components/shared/ActiveTasksIndicator';
|
||||
import CommandPalette from '@/components/shared/CommandPalette';
|
||||
import MinimizedOpsTray from '@/components/shared/MinimizedOpsTray';
|
||||
import { navGroups, canSeeNavItem } from '@/lib/nav-items';
|
||||
@@ -275,6 +276,7 @@ function TopBar() {
|
||||
<TooltipContent>{theme === 'dark' ? 'Light mode' : 'Dark mode'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ActiveTasksIndicator />
|
||||
<NotificationsDropdown />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Package,
|
||||
Terminal,
|
||||
UserCog,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
import { hasPermission } from './permissions';
|
||||
|
||||
@@ -64,6 +65,7 @@ export const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: 'Network',
|
||||
items: [
|
||||
{ label: 'Topology', icon: Waypoints, path: '/topology', permission: 'network:view' },
|
||||
{ label: 'IPAM', icon: Globe, path: '/ipam', permission: 'ipam:view' },
|
||||
{ label: 'Networks', icon: Network, path: '/networks', permission: 'server:manage' },
|
||||
],
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"my apps": "My Apps",
|
||||
"search": "Search sections",
|
||||
"searchPlaceholder": "Jump to a section...",
|
||||
"searchEmpty": "No results found"
|
||||
"searchEmpty": "No results found",
|
||||
"topology": "Topology"
|
||||
},
|
||||
"appstore": {
|
||||
"title": "App Store",
|
||||
@@ -491,6 +492,15 @@
|
||||
"disk_move": "Move disk",
|
||||
"disk_move_delete": "Delete source after move",
|
||||
"disk_moved": "Disk move started",
|
||||
"disk_move_stop_hint": "The container is running — it needs to be stopped before the move. The wizard will stop it, move the disk, and start it back automatically.",
|
||||
"disk_move_confirm_title": "Move disk?",
|
||||
"disk_move_confirm_plain": "Disk {{dev}} will be moved to storage {{storage}}.",
|
||||
"disk_move_confirm_running": "The container is currently running. Moving disk {{dev}} to {{storage}} requires it to be stopped first. It will be stopped, the disk moved, then the container will be started again.",
|
||||
"disk_move_stage_stopping": "Stopping container...",
|
||||
"disk_move_stage_moving": "Moving disk...",
|
||||
"disk_move_stage_starting": "Starting container back up...",
|
||||
"disk_move_stop_and_move": "Stop and move",
|
||||
"disk_move_stop_timeout": "Timed out waiting for the container to stop — move cancelled",
|
||||
"disk_add": "Add disk",
|
||||
"disk_added": "Disk {{dev}} added",
|
||||
"disk_bus": "Bus",
|
||||
@@ -755,7 +765,8 @@
|
||||
"quota_exceeded_hint": "Reduce the resources above to continue.",
|
||||
"quota_used_of": "{{used}} of {{limit}}",
|
||||
"quota_projection_note": "Including the instance being created.",
|
||||
"ip_cidr_placeholder": "10.10.10.5/24 or dhcp"
|
||||
"ip_cidr_placeholder": "10.10.10.5/24 or dhcp",
|
||||
"invalid_name": "Name must be a valid DNS name: start with a letter, contain only letters, digits and hyphens, end with a letter or digit (e.g. my-app-01)"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Servers",
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -63,6 +63,16 @@ IMAGES=(
|
||||
"rocky-9-cloud|rocky|9|Rocky Linux 9 GenericCloud + QGA|arm64|https://download.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2"
|
||||
"alma-9-cloud|almalinux|9|AlmaLinux 9 GenericCloud + QGA|amd64|https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2"
|
||||
"alma-9-cloud|almalinux|9|AlmaLinux 9 GenericCloud + QGA|arm64|https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2"
|
||||
"alma-10-cloud|almalinux|10|AlmaLinux 10 GenericCloud + QGA|amd64|https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2"
|
||||
"alma-10-cloud|almalinux|10|AlmaLinux 10 GenericCloud + QGA|arm64|https://repo.almalinux.org/almalinux/10/cloud/aarch64/images/AlmaLinux-10-GenericCloud-latest.aarch64.qcow2"
|
||||
"centos-stream-9-cloud|centos|9-stream|CentOS Stream 9 GenericCloud + QGA|amd64|https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2"
|
||||
"centos-stream-9-cloud|centos|9-stream|CentOS Stream 9 GenericCloud + QGA|arm64|https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2"
|
||||
"debian-13-cloud|debian|13|Debian 13 (Trixie) GenericCloud + QGA|amd64|https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
|
||||
"debian-13-cloud|debian|13|Debian 13 (Trixie) GenericCloud + QGA|arm64|https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2"
|
||||
"fedora-41-cloud|fedora|41|Fedora 41 Cloud + QGA|amd64|https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2"
|
||||
"fedora-41-cloud|fedora|41|Fedora 41 Cloud + QGA|arm64|https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-41-1.4.aarch64.qcow2"
|
||||
"ubuntu-26.04-cloud|ubuntu|26.04|Ubuntu 26.04 LTS (Resolute) Cloud + QGA|amd64|https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
|
||||
"ubuntu-26.04-cloud|ubuntu|26.04|Ubuntu 26.04 LTS (Resolute) Cloud + QGA|arm64|https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img"
|
||||
)
|
||||
|
||||
# ── Определение пакетного менеджера и sudo ─────────────────────────────────────
|
||||
|
||||
48
sonar-project.properties
Normal file
48
sonar-project.properties
Normal file
@@ -0,0 +1,48 @@
|
||||
# SonarCloud project configuration for pvemanager (monorepo)
|
||||
# Place this file at the repository root.
|
||||
|
||||
sonar.organization=markmorado
|
||||
sonar.projectKey=markmorado_pvemanager
|
||||
sonar.projectName=PVEmanager
|
||||
|
||||
# --- Sources ---
|
||||
# Backend: FastAPI app under backend/app
|
||||
# Frontend: React/TS app under frontend/src
|
||||
sonar.sources=backend/app,frontend/src
|
||||
|
||||
# Tests are scanned separately so they show up as "Tests", not "Code"
|
||||
sonar.tests=backend/tests
|
||||
|
||||
# --- Exclusions (things we never want analyzed) ---
|
||||
sonar.exclusions=\
|
||||
**/node_modules/**,\
|
||||
**/dist/**,\
|
||||
**/build/**,\
|
||||
backend/alembic/versions/**,\
|
||||
backend/migrations/**,\
|
||||
frontend/public/**,\
|
||||
**/*.min.js,\
|
||||
**/locales/**,\
|
||||
**/static/**
|
||||
|
||||
# Don't compute duplication/complexity noise on generated or vendored files
|
||||
sonar.coverage.exclusions=\
|
||||
backend/alembic/**,\
|
||||
backend/migrations/**,\
|
||||
backend/tests/**,\
|
||||
frontend/src/**/*.test.ts,\
|
||||
frontend/src/**/*.test.tsx,\
|
||||
**/*.d.ts
|
||||
|
||||
# --- Python (backend) ---
|
||||
sonar.python.version=3.12
|
||||
# Matches the coverage.xml already produced by backend-tests.yml
|
||||
sonar.python.coverage.reportPaths=backend/coverage.xml
|
||||
|
||||
# --- TypeScript/JavaScript (frontend) ---
|
||||
# If you later add vitest/jest with lcov coverage, point this at the report:
|
||||
# sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info
|
||||
sonar.eslint.reportPaths=frontend/eslint-report.json
|
||||
|
||||
# --- Source encoding ---
|
||||
sonar.sourceEncoding=UTF-8
|
||||
Reference in New Issue
Block a user